diff --git a/.gitignore b/.gitignore index 68871c972..756e74151 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,17 @@ __pycache__/ build/ develop-eggs/ dist/ +# [REV-7] Vite 빌드 산출물은 prebuilt 정책으로 commit 한다 (위 dist/ 규칙의 예외) +!webui/static/v2/dist/ +!webui/static/v2/dist/** downloads/ eggs/ .eggs/ lib/ lib64/ +# Python lib/ 규칙의 예외 — Svelte 소스 디렉터리는 commit 한다 +!webui/v2_src/src/lib/ +!webui/v2_src/src/lib/** parts/ sdist/ var/ @@ -48,16 +54,38 @@ Desktop.ini *.parquet *.h5 *.hdf5 +_database/ +finetune_csv/data/stom_*.csv +finetune_csv/data/stom_*.json +webui/stom_predictions/*.csv +webui/stom_predictions/*.json +webui/stom_predictions/ +webui/qlib_backtests/*.json +webui/qlib_backtests/*.csv +webui/rl_runs/ +finetune/qlib_exports/ +finetune/qlib_backtests/ +finetune/outputs/ +finetune/data/processed_datasets/ +finetune/data/stom_qlib*/ +qlib_data/ # Model files (large files) *.pth *.pt *.ckpt *.bin +finetune_csv/finetuned/ # Logs *.log logs/ +.omx/ +# Local agent/workflow/session state +.codegraph/ +.gjc/ +.omc/ +.omo/ # Environment .env diff --git a/.omc/skill-candidates.md b/.omc/skill-candidates.md new file mode 100644 index 000000000..801a1a518 --- /dev/null +++ b/.omc/skill-candidates.md @@ -0,0 +1,135 @@ +# Kronos v2 — 스킬 후보 (session-wrap 2026-05-18 추출) + +본 세션에서 7개 탭에 걸쳐 반복적으로 등장한 패턴들. 추후 `/oh-my-claudecode:skillify` 또는 `/oh-my-claudecode:learner` 로 재사용 스킬로 변환 가능. + +--- + +## P1 · 5단 카드 헤더 패턴 + +**등장**: 7개 탭 전부, ~20곳 이상 + +```svelte +
+
+
+
{ENDPOINT_OR_PHASE}
+
{HUMAN_TITLE}
+
+ {STATUS_LABEL} +
+ +
+``` + +5층 계층: card → header → eyebrow(작은 라벨) → title(굵은 제목) → 우측 status pill. 데이터 출처 표기(/api/...)는 eyebrow 에, 사람이 읽는 제목은 title 에, 현재 상태는 pill 에. + +--- + +## P2 · KPI 메트릭 스트립 (.metric × N) + +**등장**: Live Training, History, System Health, STOM, Artifacts + +```svelte +
+
+
+ {LABEL} + ▼ {pct}% +
+
{value}{unit}
+
{context_data}
+
+ ... +
+``` + +핵심: `tabular-nums` 로 숫자 정렬 + delta 화살표 색상 분기(▼=down=success / ▲=up=danger) + 미니 컨텍스트. + +--- + +## P3 · Status Pill 4-way 분기 + +**등장**: 모든 탭 + +```ts +function statusKind(s: string): 'success' | 'danger' | 'accent' | 'warn' | '' { + if (['completed','complete','success','done'].includes(s)) return 'success'; + if (['failed','error'].includes(s)) return 'danger'; + if (['running','active'].includes(s)) return 'accent'; + if (['waiting','pending'].includes(s)) return 'warn'; + return ''; +} +``` + +색상 매핑 단일 owner 함수 → CSS `.pill.success / .danger / .accent / .warn` 와 자동 매칭. + +--- + +## P4 · ECharts theme-aware palette 추출 + +**등장**: W3, W5, SystemHealth, Forecast + +```ts +let palette = $derived.by(() => { + void currentTheme; // ← theme store 의존성 강제 등록 + if (typeof window === 'undefined') return null; + const cs = getComputedStyle(document.documentElement); + return { + accent: cs.getPropertyValue('--accent').trim(), + grid: cs.getPropertyValue('--border-faint').trim(), + text: cs.getPropertyValue('--fg').trim(), + // ... + }; +}); +``` + +light/dark 전환 시 CSS 변수가 자동으로 바뀌고 `theme` store 구독으로 derived 가 재계산 → ECharts option 이 새 palette 로 setOption 호출됨. + +--- + +## P5 · Svelte 5 stores + subscribe 패턴 + +**등장**: 모든 컴포넌트 + +```svelte + +``` + +핵심: `$state + subscribe + $derived` 3단 체인으로 store 데이터를 reactive 하게 사용. App.svelte 가 polling 시작 → stores 갱신 → 모든 컴포넌트가 자동 업데이트. + +--- + +## P6 · data-active 속성 기반 토글 + +**등장**: Sidebar nav, tabs, seg buttons, file rows + +```svelte + +``` + +```css +.nav-item[data-active="true"] { background: var(--accent-soft); border-color: var(--accent); } +.tabs button[data-active="true"] { border-bottom: 2px solid var(--accent); color: var(--accent-strong); } +``` + +class 분기 대신 data attribute 로 토글 — CSS attribute selector 가 더 가벼우면서 JS 코드 단순화. + +--- + +## 변환 우선순위 + +가장 가치 큰 순서: +1. **P1 (5단 카드 헤더)** — 새 탭 추가 시 boilerplate 자동화 +2. **P4 (ECharts palette 추출)** — 다른 차트 라이브러리 도입 시 동일 패턴 재사용 +3. **P3 (Status pill 분기)** — 다른 도메인의 status 표시 통일 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..5a435f079 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,98 @@ +# Kronos Project Knowledge Base + +**Generated:** 2026-06-03 KST +**Last reviewed:** 2026-06-03 KST, init-deep update mode +**Branch observed:** `feature/stom-rl-lab` +**Commit observed:** `943222b` + +## Overview + +Kronos is now a combined research/operations repo: core Kronos model code, STOM +1-second/tick data pipelines, rule/RL trading research, and the official Flask + +Svelte dashboard for inspection. Treat it as an experimental trading research +platform, not as a live-trading product. + +## Current Direction + +| Area | Current stance | Reason | +|---|---|---| +| `ts_imb` opening gap-up rule | Main research baseline | Prior docs show the strongest useful curve here; it is a RULE strategy, not RL. | +| Skip-gate / state-exit gates | Deprioritize unless new hypothesis is preregistered | Full-universe docs report `NO-GO`. | +| Plain PPO/DQN RL | Do not present as usable | Existing PPO/DQN/orderbook runs are `NO-GO` or research-only. | +| Orderbook RL | Keep as isolated experiment/falsification tool | Useful for testing action design and dashboard comparison, not live readiness. | +| Dashboard | Continue as evidence viewer | It should expose failure, baselines, costs, and split metadata clearly. | + +## Structure + +```text +Kronos/ ++-- model/ # Kronos model/tokenizer implementation ++-- finetune/ # STOM/Qlib export, training, evaluation CLIs ++-- stom_rl/ # STOM rule/RL experiments, gates, backtests, readiness ++-- webui/ # Flask API and official dashboard adapters ++-- webui/v2_src/ # Svelte/Vite dashboard source (internal path name) ++-- tests/ # pytest coverage for model, STOM, web, dashboard ++-- docs/ # handoff, verdict, preregistration, result documents ++-- _database/ # local data; do not mutate casually ++-- .omx/artifacts/, webui/rl_runs/ # generated experiment artifacts +``` + +## Where To Look + +| Task | Location | Notes | +|---|---|---| +| RL/orderbook environment | `stom_rl/orderbook_rl_env.py` | Marketable-fill, orderbook feature environment. | +| SB3 orderbook smoke | `stom_rl/orderbook_sb3_smoke.py` | Research-only DQN/PPO-style experiments. | +| Gap-up rule baseline | `stom_rl/gap_up_backtest.py` | Main `ts_imb` rule reference. | +| Skip/state gates | `stom_rl/skip_gate.py`, `stom_rl/state_exit_gate.py` | Existing full-universe `NO-GO` results. | +| RL dashboard backend | `webui/rl_dashboard.py`, `webui/app.py` | Read-only artifact/API layer. | +| RL dashboard frontend | `webui/v2_src/src/tabs/RLTradingTab.svelte` | Main RL/trading dashboard tab. | +| Latest direction docs | `docs/stom_rl_resume_commit_2026-05-29.md`, `docs/stom_state_exit_result_2026-06-02.md` | Re-read before changing strategy direction. | + +## Trading Honesty Rules + +- Do **not** call the gap-up `ts_imb` curve "reinforcement learning". It is a + rule strategy unless a real RL policy produced it. +- Do **not** claim live-trading readiness, profitability, or broker readiness. + Current work is local backtest/research/dashboard evidence only. +- Primary cost assumption is 23bp round trip unless a test/document explicitly + states otherwise. +- For high-frequency/opening work, prefer marketable-fill accounting where + possible (`buy@ask`, `sell@bid`) and label assumptions. +- Preserve leading-zero stock codes. Never coerce codes like `000250` to int. +- Treat local Korean DB columns and generated docs as UTF-8-sensitive. +- Negative/shuffle controls and OOS splits are not optional for alpha claims. +- If a model fails cost gate, baseline comparison, or drawdown gate, surface + `NO-GO` plainly in docs and UI. + +## Commands + +```powershell +# Core RL/dashboard regression set +py -3.11 -m pytest tests/test_stom_rl_dashboard_api.py tests/test_stom_rl_dashboard_tab.py tests/test_stom_rl_orderbook_env.py tests/test_stom_rl_orderbook_sb3.py -q + +# Broader STOM rule/gate checks +py -3.11 -m pytest tests/test_stom_rl_gap_up_backtest.py tests/test_stom_rl_skip_gate.py tests/test_stom_rl_state_exit_gate.py tests/test_stom_rl_marketable_fill.py -q + +# Svelte dashboard build/check +cd webui/v2_src +npm run build +``` + +## Status Hygiene + +- Keep source/docs changes separate from generated outputs. +- Treat `.omc/`, `.codegraph/`, `.omx/artifacts/`, `webui/rl_runs/`, and + frontend dist assets as generated/session state unless explicitly requested. +- If committing later, review untracked files carefully before staging. +- `webui/v2_src` expects Node 20 or 22 and npm 9+ per `package.json`. + +## Gotchas + +- `webui/rl_runs/`, `.omx/artifacts/`, `finetune/outputs/`, and large CSV/DB + directories are generated data. Use them as evidence; do not design from one + cherry-picked artifact. +- `webui/v2_src` is a separate frontend project with its own package scripts. +- `webui/app.py` is broad and central; API changes need targeted tests. +- Existing docs intentionally use `NO-GO` heavily. Treat those as guardrails, + not as failure to hide. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..b6bf87328 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,134 @@ +# Design + +## Source of truth +- Status: Active +- Last refreshed: 2026-05-20 +- Primary product surfaces: + - `http://127.0.0.1:5070/` + - `http://127.0.0.1:5070/training` + - STOM/Kronos 학습 상태, GPU 상태, 예측/실제값 비교, 데이터 진단 대시보드 +- Evidence reviewed: + - `webui/v2_src/src/styles/core.css` + - `webui/v2_src/src/styles/components.css` + - `webui/v2_src/src/tabs/LiveTrainingTab.svelte` + - `webui/v2_src/src/tabs/StomDiagnosticsTab.svelte` + - `webui/v2_src/src/layout/HeroStrip.svelte` + - `webui/training_monitor.py` + - `finetune/qlib_exports/stom_1s_grid_pred60_2025/stom_qlib_export_report.json` + +## Brand +- Personality: 차분하고 신뢰 가능한 ML 운영 도구, 숫자 중심이지만 비전문가도 빠르게 이해할 수 있는 설명형 대시보드. +- Trust signals: 실제 데이터 범위, 학습 단계, ETA, GPU, checkpoint, 검증 지표를 숨기지 않고 카드와 표로 노출. +- Avoid: 과장된 수익 표현, “정확도 보장” 같은 단정, 맥락 없는 원시 로그만 던지는 화면, 페이지마다 다른 색/간격/타이포그래피. + +## Product goals +- Goals: + - STOM tick/1초봉 데이터를 Kronos 파인튜닝에 어떤 범위로 사용 중인지 즉시 확인. + - 장시간 학습 중 현재 단계, 속도, ETA, GPU, 손실 추이를 웹에서 계속 감시. + - 학습 완료 후 실제값과 예측값을 종목/전체 통계로 비교할 수 있는 흐름을 유지. +- Non-goals: + - 실거래 자동 주문 판단을 대시보드가 단독으로 보장하지 않는다. + - 모델 성능을 검증하지 않은 상태에서 투자 의사결정으로 직접 연결하지 않는다. +- Success signals: + - 사용자가 “지금 무엇을 학습 중인지”, “얼마나 남았는지”, “어떤 데이터 범위인지”를 10초 안에 이해. + - 모든 주요 수치는 API 근거와 로그 근거로 재현 가능. + +## Personas and jobs +- Primary personas: + - STOM/Kronos 학습을 운영하는 개발자/트레이더. + - 장시간 GPU 학습을 감시하고 중간 실패를 빠르게 파악해야 하는 사용자. +- User jobs: + - 데이터 범위와 feature가 의도한 전체 STOM tick 학습과 일치하는지 확인. + - tokenizer/predictor 진행률, ETA, checkpoint 준비 상태를 추적. + - 학습 완료 후 예측 품질을 그래프와 통계로 검증. +- Key contexts of use: + - 로컬 워크스테이션 RTX 4080 SUPER/Threadripper 환경. + - 장시간 학습 중 브라우저와 Codex 대화에서 병행 모니터링. + +## Information architecture +- Primary navigation: 좌측 사이드바 중심의 탭형 구조. +- Core routes/screens: + - `/`: 개요/홈. + - `/training`: 실시간 학습 대시보드. + - STOM 진단/예측 비교 관련 탭: 데이터 품질, 예측 결과, 통계 요약. +- Content hierarchy: + 1. 현재 학습 상태와 핵심 지표. + 2. 학습 데이터 범위/feature/분할 정보. + 3. 손실 곡선, 변동성, ETA, GPU, 로그. + 4. 학습 완료 후 예측-실제 비교와 종목별 통계. + +## Design principles +- Principle 1: “근거 먼저” — 각 수치가 어떤 run/report/log에서 나온 것인지 숨기지 않는다. +- Principle 2: “장시간 감시 친화” — 새로고침과 상태 변화가 눈에 잘 보이되 시끄럽지 않게 표현한다. +- Principle 3: “한글 우선, 약어 보조” — 사용자가 보는 UI는 한글 설명을 우선하고, step/GPU/ETA 같은 약어는 보조로 유지한다. +- Tradeoffs: + - 원시 정보량이 많으므로 첫 화면은 요약 카드, 상세는 표/칩/로그로 분리한다. + - 모델 성능은 방향성/검증 지표로 표현하고 투자 판단 단정은 피한다. + +## Visual language +- Color: 기존 토큰을 그대로 사용한다. 메인 포인트는 민트/틸 `--accent`, 주의/보완 설명은 warm/warn 계열. +- Typography: Pretendard + Malgun Gothic fallback, 숫자는 JetBrains Mono/D2Coding과 tabular number 사용. +- Spacing/layout rhythm: 16px 카드 간격, 20px 카드 padding, 큰 화면은 4열/3:1 grid, 작은 화면은 1열로 축소. +- Shape/radius/elevation: `--r-lg`, `--r-pill`, `--shadow-glow`, `--border` 기반의 부드러운 카드. +- Motion: 실시간 상태는 pulse/soft transition만 사용, 장시간 감시 화면에서 과한 애니메이션 금지. +- Imagery/iconography: 추가 이미지보다 badge, dot, chip, sparkline, chart로 설명. + +## Components +- Existing components to reuse: + - `.card`, `.metric`, `.pill`, `.text-eyebrow`, `.text-caption`, `.text-mono`, `.tnum` + - `W3LossCurve`, `W4EtaTimeline`, `W5GpuSparkline`, `W6LossVolatility`, `W9LogTail` +- New/changed components: + - `/training` 상단 데이터 범위 요약 카드: STOM 2025, 1초봉, 09:00~09:30, lookback/pred, split, feature를 표시. +- Variants and states: + - 데이터 요약 사용 가능: accent 카드. + - report 미탐지/불완전: warn pill과 간단한 안내. + - 진행 중: live badge와 ETA/step 유지. +- Token/component ownership: + - 전역 토큰은 `core.css`, 공통 컴포넌트 스타일은 `components.css`. + - 탭 단위 특수 레이아웃은 해당 Svelte 파일의 scoped style에 둔다. + +## Accessibility +- Target standard: WCAG 2.1 AA 수준의 대비와 키보드 포커스 유지. +- Keyboard/focus behavior: 탭/버튼은 `:focus-visible`을 유지하고 포커스 outline을 제거하지 않는다. +- Contrast/readability: dark/light theme 모두 `--fg`, `--muted`, `--accent` 토큰만 사용. +- Screen-reader semantics: section, table, header, caption성 텍스트를 의미 있게 사용. +- Reduced motion and sensory considerations: 핵심 정보 이해가 animation에 의존하지 않도록 한다. + +## Responsive behavior +- Supported breakpoints/devices: 데스크톱 우선, 1200px 이하 2열, 720px/560px 이하 1열. +- Layout adaptations: 요약 카드는 grid에서 stack으로 자연스럽게 변경. +- Touch/hover differences: hover에만 의존하지 않고 모든 상태를 텍스트/색/숫자로 함께 표시. + +## Interaction states +- Loading: 수치가 없을 때 `-` 또는 “확인 중”으로 표시. +- Empty: report/API 없음은 경고 카드로 원인과 다음 확인 항목 표시. +- Error: 원시 오류 대신 “데이터 요약을 읽지 못했습니다” + path/근거 일부 표시. +- Success: checkpoint/데이터 요약/실시간 상태는 success 또는 accent pill 사용. +- Disabled: 실행 불가 버튼은 낮은 대비와 설명 텍스트를 함께 제공. +- Offline/slow network, if applicable: 마지막 갱신 시각과 seconds since update를 함께 표시. + +## Content voice +- Tone: 짧고 직접적인 한국어, 중요한 숫자는 표기 단위를 붙인다. +- Terminology: + - “전체 진행률”은 tokenizer+predictor 전체 run 기준. + - “단계 진행률”은 현재 tokenizer 또는 predictor 내부 기준. + - “samples”는 sliding window 학습 샘플 수. + - “rows”는 1초봉으로 정규화된 행 수. +- Microcopy rules: + - 예측 성능은 “검증 필요”, “비교 가능”, “개선 후보”처럼 보수적으로 표현. + - 사용자가 혼동한 용어는 한글 설명과 영문 약어를 병기. + +## Implementation constraints +- Framework/styling system: Svelte 5 + Vite + repo CSS design tokens. +- Design-token constraints: 새 색상/새 폰트/새 디자인 시스템을 추가하지 않는다. +- Performance constraints: `/training`은 주기 갱신되므로 API 응답은 요약만 반환하고 대형 report 원문은 보내지 않는다. +- Compatibility constraints: Windows 로컬 경로, 한국 시간 표시, 장시간 학습 중 웹 재시작 가능성을 고려. +- Test/screenshot expectations: + - `npm run build` + - `python -m py_compile webui/training_monitor.py` + - `/api/training/status`와 `/training` HTTP 200 확인 + - 가능하면 브라우저에서 `/training` 직접 확인 + +## Open questions +- [ ] 학습 완료 후 예측-실제 비교 대시보드에서 기본 정렬 기준을 loss/방향정확도/종목별 MAE 중 무엇으로 둘지 결정 필요. +- [ ] 1초봉 pred30/pred60/pred120 비교를 같은 화면에서 토글할지, 별도 실험 페이지로 분리할지 결정 필요. diff --git a/docs/AGENTS.md b/docs/AGENTS.md new file mode 100644 index 000000000..d9041c69c --- /dev/null +++ b/docs/AGENTS.md @@ -0,0 +1,32 @@ +# docs Knowledge + +## Overview + +`docs/` is the decision ledger for STOM/Kronos research. Handoffs, preregistration +notes, result reports, and verdicts here are evidence artifacts, not marketing. + +## Rules + +- Preserve exact dates, commands, costs, splits, and verdict labels. +- Use `RULE`, `supervised gate`, `RL experiment`, and `baseline` precisely. +- Do not rewrite a prior `NO-GO` into a softer conclusion without new evidence. +- If a document reports a trading result, include the cost assumption and whether + it is in-sample, OOS, smoke, full-universe, or paper/read-only. +- Prefer a new dated result document over mutating an old verdict document. +- Mark generated/session files separately from durable project documents. + +## Where To Look + +| Need | Document type | +|---|---| +| Resume context | `*_resume_*`, `*_handoff_*` | +| Pre-registered hypothesis | `*_prereg_*` | +| Final experimental result | `*_result_*`, `*_verdict_*`, `*_candidate_*` | +| Current direction | `stom_development_direction_review_2026-06-03.md` | + +## Anti-Patterns + +- Calling a rule backtest an RL result. +- Reporting a favorable curve without baseline/no-trade/cost context. +- Treating dashboard visuals as proof of profitability. +- Editing old result docs to hide failed experiments. diff --git a/docs/ai_handoff_retrain_option_d_2026-05-19.md b/docs/ai_handoff_retrain_option_d_2026-05-19.md new file mode 100644 index 000000000..b450ca26c --- /dev/null +++ b/docs/ai_handoff_retrain_option_d_2026-05-19.md @@ -0,0 +1,327 @@ +# AI 핸드오프 — STOM tokenizer 재학습 (옵션 D) + 대시보드 모니터링 + +> **이 문서 하나로 다른 AI 세션이 사용자의 재학습 작업을 그대로 이어받아 진행할 수 있다.** 코드 변경, 변수 선정 근거, 명령어, 예상 시간, 위험 대응, 다음 단계까지 단일 파일에 정리됨. + +**작성일**: 2026-05-19 KST +**작성자**: Claude (이전 세션) +**상태**: 코드/대시보드 준비 완료, **사용자가 학습 명령 실행 직전** +**대상 AI**: 이 문서를 받은 다음 Claude 세션 +**프로젝트**: `D:\Chanil_Park\Project\Programming\Kronos` + +--- + +## 0. 30초 컨텍스트 + +- **무엇을 하나**: validation OOM 으로 실패한 STOM tokenizer 학습을 옵션 D (풀 최대 활용) 변수로 재시작하고, v2 대시보드에서 실시간 모니터링. +- **왜**: 이전 학습 99.98% (step 4.7M 거의 끝) 에서 validation forward OOM → checkpoint 0개 → predictor 미시작 → STOM 예측 진단/Forecast 워크벤치 검증 불가. +- **무엇이 준비됐나**: finetune 코드에 AMP/torch.compile/persistent_workers opt-in flag 추가 (commit `dc7315a`), W9 로그 tail 위젯 추가 (commit `0563734`). +- **남은 1단계**: 사용자가 §4 명령어를 별도 PowerShell 콘솔에 붙여넣고 실행. 보정된 명령은 이전 실패 run 과 동일한 `n-train-iter=18806883`, `n-val-iter=3925397` 및 predictor 고속 설정을 포함한다. 옵션 D 성공 시 6~12시간 목표, 보수적으로 8~16시간 후 학습 종료 예상. + +--- + +## 1. 시스템 사양 (실측 — 변수 선정 근거) + +| 자원 | 값 | +|---|---| +| **CPU** | AMD Ryzen Threadripper 3990X · 32 cores / 64 logical threads | +| **GPU** | NVIDIA RTX 4080 SUPER · 16 GiB VRAM (free 12.4 GiB) · driver 591.86 | +| **System RAM** | 273 GB | +| **PyTorch** | 2.9.0 + CUDA 12.8 | +| **GPU arch** | Ada Lovelace (sm_89) — bf16 native 지원 | +| **OS** | Windows 11 | +| **Python** | C:\Python\64\Python3119\python.exe (3.11.13) | +| **Disk D:** | 1.9 TB / 528 GB free | + +--- + +## 2. 이전 실패 진단 + +| 항목 | 값 | +|---|---| +| 실패 commit | `7742cb8` — "장시간 tokenizer 학습 결과를 validation OOM 전에 보존하다" | +| 마지막 step | **4,701,000 / 4,701,721** = 99.98% (train loop 거의 완료) | +| OOM 위치 | `finetune/train_tokenizer.py:196` rotary attention forward (validation 진입 직전 또는 직후) | +| 학습 시간 | 약 83시간 (2026-05-11 04:43 → 2026-05-14 15:39) | +| 체크포인트 | **0개** (pre_validation_checkpoint 코드는 line 201 에 있으나 OOM 이 그 전에 발생) | +| 안전 옵션 추가 | commit `7742cb8` 에서 `--tokenizer-val-batch-size`, `latest_train_model` 자동 저장, CUDA cache 정리, validation_failure.json 기록 | + +--- + +## 3. 본 세션에서 추가된 작업 + +### 3.1 finetune 코드 — 옵션 C/D opt-in flag 도입 (commit `dc7315a`) + +`finetune/config.py` 신규 attribute 5종 + KRONOS_* 환경변수 5종: +- `persistent_workers` / `prefetch_factor` +- `tokenizer_enable_amp` / `tokenizer_amp_dtype` (bf16/fp16/fp32) +- `tokenizer_enable_compile` / `tokenizer_compile_mode` / `tokenizer_compile_fullgraph` + +`finetune/train_tokenizer.py` 적용: +- DataLoader 에 `persistent_workers`/`prefetch_factor` (num_workers > 0 일 때만) +- `autocast_ctx()` 컨텍스트 매니저 (AMP 비활성 시 nullcontext) +- train forward + loss 를 autocast 로 래핑 +- GradScaler (fp16 일 때만 — bf16 은 불필요) +- validation forward 도 autocast 로 래핑 +- 모델 생성 직후 `torch.compile()` 적용 (try/except 로 실패 시 eager fallback) + +`finetune/run_stom_1s_finetune.py` CLI args 7종 추가: +- `--persistent-workers` / `--prefetch-factor` +- `--tokenizer-amp` / `--tokenizer-amp-dtype` +- `--tokenizer-compile` / `--tokenizer-compile-mode` / `--tokenizer-compile-fullgraph` +- 옵션 활성 시 자동 env 전파 (KRONOS_* 환경변수) + +**모든 옵션은 opt-in (default False) — 기존 학습 명령은 영향 없음.** + +### 3.2 대시보드 — W9 로그 tail 위젯 (commit `0563734`) + +`webui/v2_src/src/widgets/W9_LogTail.svelte` 신규: +- `/api/training/logs?stage=<>&lines=N` 폴링 (10초 주기) +- 색상 분기: **Loss=시안**, **LR=주황**, **sps/checkpoint=초록**, **step=흰**, **compile=시안진**, **AMP=초록**, **error/OOM=빨강** +- 10/20/50 tail 토글 +- Live Training 탭 하단 (W5 GPU 다음) 자동 노출 +- 신규 API endpoint 0건 (기존 `/api/training/logs` 활용) + +### 3.3 테스트 갱신 +`tests/test_training_monitor.py` P6 cutover 경로 갱신 (`/training` → `/v1/training`). + +### 3.4 runbook 갱신 +`docs/retrain_stom_1s_grid_pred60_2025_full_small.md` §2.1 옵션 D 명령어 + §2.1a 대시보드 검증 흐름 + §2.1b 폴백 옵션 추가. + +--- + +## 4. 학습 시작 명령 (사용자가 별도 PowerShell 콘솔에 붙여넣기) + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos + +# 1. 실패 run archive (기존 logs 보존) +Rename-Item -Path 'finetune\outputs\stom_1s_grid_pred60_2025_full_small' -NewName 'stom_1s_grid_pred60_2025_full_small_failed_OOM_20260514' + +# 2. validation OOM 회피 (필수) +$env:KRONOS_TOKENIZER_VAL_BATCH_SIZE = "1" + +# 3. 옵션 D 풀 최대 활용 학습 시작 +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 --mode full --train-stage both ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred60_2025_full_small ` + --dataset-sample-mode full_sequential ` + --n-train-iter 18806883 ` + --n-val-iter 3925397 ` + --tokenizer-batch-size 64 ` + --tokenizer-val-batch-size 1 ` + --predictor-batch-size 16 ` + --predictor-num-workers 2 ` + --epochs 1 ` + --num-workers 12 ` + --persistent-workers ` + --prefetch-factor 6 ` + --tokenizer-amp ` + --tokenizer-amp-dtype bf16 ` + --tokenizer-compile ` + --tokenizer-compile-mode max-autotune +``` + +### 4.1 변수 선정 근거 + +| 변수 | 값 | 근거 | +|---|---:|---| +| `--tokenizer-batch-size` | **64** | AMP bf16 으로 VRAM ~12 GiB (4 GiB 안전 마진) | +| `--num-workers` | **12** | 64-core 중 19% 활용 — 데이터 로딩 충분 | +| `--prefetch-factor` | **6** | RAM 273 GB 이라 부담 0 | +| `--persistent-workers` | ✅ | DataLoader 재초기화 비용 ↓ | +| `--tokenizer-amp-dtype` | **bf16** | 4080 SUPER native + GradScaler 불필요 | +| `--tokenizer-compile-mode` | **max-autotune** | 6~12시간 목표 학습이라 컴파일 오버헤드 (~120s) ROI 충분 | +| `--tokenizer-val-batch-size` | **1** | validation OOM 회피 (env 강제) | + +--- + +## 5. 예상 타임라인 (옵션 D) + +| 구간 | 시간 | 누적 | +|---|---:|---:| +| 명령 실행 → 첫 step 진입 | ~30s | 30s | +| **torch.compile max-autotune 1st epoch** | ~60~180s | ~3분 | +| tokenizer train loop (약 294k step, batch 64, train 18,806,883 samples) | ~5~10h | ~5~10h | +| tokenizer validation (batch=1, val 3,925,397 samples) | ~30~120min | ~6~12h | +| predictor train loop | ~1~4h (batch 16 + num_workers 2) | ~7~16h | +| predictor validation | ~10~30min | ~7~16h | +| **총 예상** | | **6~12h 목표 / 8~16h 보수** | + +기존 83h 대비 **약 5~14x faster 목표**. 실제 ETA는 첫 5~10분 sps 실측으로 재계산한다. + +--- + +## 6. 대시보드 모니터링 흐름 + +### 6.1 학습 시작 직후 즉시 확인 (브라우저) + +`http://127.0.0.1:5070/` → 좌측 NAV "실시간 학습" 탭 → 하단으로 스크롤 → **W9 학습 로그 tail** 카드: + +| 시점 | 보이는 줄 (색상) | +|---|---| +| 1분 | `[Rank 0] BATCHSIZE (per GPU): 64` | +| 1분 | `[Rank 0] AMP enabled — dtype=bf16 scaler=False` (초록) | +| 1분 | `[Rank 0] torch.compile enabled — mode=max-autotune fullgraph=False` (시안) | +| 3분 후 | `[Rank 0, Epoch 1/1, Step N/294000] LR 0.00X (주황), Loss: -0.XX (시안)` | +| 학습 중 | `samples/s=500~700 (초록)` | +| OOM 발생 시 | `out of memory / Traceback (빨강 강조)` | +| checkpoint 저장 시 | `checkpoint saved / pre-validation epoch 1 (초록)` | + +### 6.2 옵션 D 작동 신호 — 메트릭 카드 + +- **현재 손실**: 시안 색상 (정상) +- **학습 속도**: **500~700 samples/s** (이전 옵션 C 의 5배+, 기존 batch 4 의 10배+) +- **학습률 (LR)**: 주황 색상, scheduled +- **현재 Epoch**: 1/1 + +### 6.3 GPU 트렌드 (W5) +- util 90%+ 도달 → 자원 풀가동 신호 +- VRAM ~12 GiB / 16 GiB ~ 75% → 옵션 D batch 64 정상 +- 온도 ~60~70°C (학습 부하 적정) + +### 6.4 ETA (W2/W4) +- 학습 시작 5~10분 후 **6~12시간 목표 / 8~16시간 보수** 범위 표시 → 옵션 D 작동 +- KST 완료 예상 시각 자동 표시 + +--- + +## 7. 위험 + 대응 + +| 위험 | 신호 | 대응 | +|---|---|---| +| batch 64 → train OOM | W9 에 빨간 `out of memory` | 즉시 학습 중단 → §7.1 옵션 폴백 | +| torch.compile max-autotune 실패 | W9 에 주황 `torch.compile failed` | 자동 eager fallback (학습 계속) — 또는 §7.2 | +| compile 컴파일 너무 오래 (~5분+) | W9 에 step 증가 안 함 | `--tokenizer-compile-mode reduce-overhead` 로 변경 | +| validation OOM 재발 | tokenizer.stdout.log 에 OOM | pre_validation `latest_train_model` weights 자동 보존됨 — 별도 PR 로 복구 | +| GPU 다른 프로세스 점유 | nvidia-smi 에 다른 PID | 해당 프로세스 종료 후 재시작 | + +### 7.1 옵션 D OOM 시 폴백 (runbook §2.1b) +```powershell +# 옵션 B 수준 (안전) +... --tokenizer-batch-size 16 --num-workers 4 --persistent-workers --tokenizer-amp --tokenizer-amp-dtype bf16 +# torch.compile 제거, batch 4분의 1 +``` + +### 7.2 max-autotune 실패 시 +```powershell +... --tokenizer-compile-mode reduce-overhead # max-autotune → reduce-overhead +``` + +--- + +## 8. 학습 종료 후 다음 단계 + +학습이 성공적으로 끝나면 (`/api/training/status` 가 `status=completed` + `readiness.predictor_complete=true`): + +1. **v2 대시보드 자동 갱신** — 모든 탭이 새 predictor 데이터로 채워짐 +2. **예측 워크벤치 탭** — 새 모델로 실제 예측 실행 가능 +3. **예측 진단 (STOM) 탭** — 새 prediction CSV 가 file 목록에 자동 노출 +4. **아티팩트 & 모델 탭** — checkpoint/weight 카운트 정상 표시 +5. **P5 정식 quality gate** — Lighthouse a11y/perf 측정 (docs/session-wrap-followups-2026-05-18.md §2 참조) + +--- + +## 9. 핵심 commit 추적 (시간순) + +| Commit | 설명 | +|---|---| +| `7742cb8` | 이전 OOM 실패 + 안전 옵션 추가 (--tokenizer-val-batch-size 등) | +| `908766c` | 재학습 runbook 정식 docs 고정 | +| `dc7315a` | tokenizer 재학습 풀 최적화 — torch.compile + bf16 AMP + persistent workers 옵션 도입 | +| `0563734` | **옵션 D 풀 활용 + W9 로그 tail 카드로 대시보드 가시성 완성** ← 본 세션 핵심 | + +--- + +## 10. 다른 AI 가 작업 이어가기 (재진입 절차) + +### 10.1 새 AI 세션 시작 시 첫 명령 + +```powershell +# 1. 본 핸드오프 문서 읽기 (필수) +# (Read tool 사용) +# D:\Chanil_Park\Project\Programming\Kronos\docs\ai_handoff_retrain_option_d_2026-05-19.md + +# 2. git 히스토리 확인 — 본 세션 마지막 commit 도달했는지 +cd D:\Chanil_Park\Project\Programming\Kronos +git log --oneline -5 +# 마지막 commit 이 0563734 (옵션 D + W9) 인지 확인 + +# 3. 작업 디렉터리 클린 상태 확인 +git status --short + +# 4. 학습 진행 상태 확인 (Flask 가 떠있는지) +curl -s -o NUL -w "HTTP=%{http_code}\n" http://127.0.0.1:5070/ + +# 5. Flask 가 죽었으면 재시작 +$env:KRONOS_WEBUI_PORT = "5070" +$env:KRONOS_WEBUI_OPEN_BROWSER = "0" +$env:KRONOS_V2_DIST = "1" +C:\Python\64\Python3119\python.exe webui\run.py +# (별도 콘솔에서 실행 — 백그라운드 유지) + +# 6. 학습 진행 상태 (API 직접 호출) +curl -s http://127.0.0.1:5070/api/training/status | python -m json.tool +``` + +### 10.2 학습이 아직 시작 안 됐다면 + +본 문서 §4 명령어를 사용자에게 안내. 사용자가 별도 PowerShell 콘솔에 붙여넣고 실행. + +### 10.3 학습이 진행 중이라면 + +- W9 로그 tail 카드 확인 → AMP/compile 활성 정상인지 검증 +- 메트릭 카드의 sps 가 500+ 인지 확인 → 옵션 D 작동 신호 +- ETA 6~12시간 목표 또는 8~16시간 보수 범위 표시 확인 +- 사용자에게 진행률 보고 + +### 10.4 학습이 실패했다면 (OOM 등) + +- `finetune/outputs/stom_1s_grid_pred60_2025_full_small/logs/tokenizer.stdout.log` tail 확인 +- §7.1 옵션 B 폴백 명령 제시 (batch 16, no compile) +- 또는 §7.2 compile mode reduce-overhead 변경 + +### 10.5 학습이 성공했다면 + +- §8 다음 단계 진행 (예측 워크벤치 검증 → STOM 진단 → P5 quality gate) +- 별도 PR 로 P3.5 (Forecast Candlestick), P4.5 (STOM Plotly heatmap) 진행 — `docs/session-wrap-followups-2026-05-18.md` 참조 + +--- + +## 11. 주요 참조 문서 색인 + +| 파일 | 용도 | +|---|---| +| `docs/ai_handoff_retrain_option_d_2026-05-19.md` | **본 문서** (AI 핸드오프) | +| `docs/retrain_stom_1s_grid_pred60_2025_full_small.md` | 재학습 runbook (옵션 D 명령 + 모니터링 + 폴백) | +| `docs/claude_designer_handoff.md` | v2 SPA 디자이너 핸드오프 (전체 8 탭 구조) | +| `docs/kronos_dashboard_overhaul_plan.md` | P0~P6 ralplan 합의 마스터 플랜 | +| `docs/session-wrap-followups-2026-05-18.md` | 11개 후속 작업 우선순위 | +| `.omc/skill-candidates.md` | 6 재사용 패턴 (skillify 후보) | +| `webui/v2_src/README.md` | v2 SPA 빌드/배포/디렉터리 | + +--- + +## 12. 절대 금지 사항 (Hard Constraints) + +| 영역 | 이유 | +|---|---| +| `webui/app.py` 의 `/api/*` 엔드포인트 수정 | 모든 v2 탭 + v1 화면이 공유 | +| `webui/templates/{index,training_dashboard,stom_dashboard}.html` | v1 archive (6개월 보존) | +| `finetune/` 외 학습 코드, `_database/` | 데이터/모델 무결성 | +| 신규 `/api/*` endpoint 추가 | 기존 24 개만 사용 | +| predictor 미완료 상태에서 정확도/수익률 ready 표시 | readiness gate 정책 | +| KRONOS_TOKENIZER_VAL_BATCH_SIZE=1 해제 | validation OOM 회피 정책 (commit 7742cb8 Directive) | + +--- + +## 13. 한 줄 미션 (다음 AI 에게) + +> **"사용자가 §4 명령으로 옵션 D 학습을 시작했는지 확인하고, W9 로그 tail + 메트릭 카드의 sps/ETA 로 옵션 D 작동을 검증한 뒤, 6~12시간 목표 또는 보수 8~16시간 후 학습이 끝나면 §8 다음 단계 (예측 검증 + STOM 진단 + P5 gate) 로 자연스럽게 이어가라. OOM/compile 실패 시 §7 폴백 명령 제시."** + +--- + +*작성: Claude (현 세션, 2026-05-19 KST)* +*다음 세션은 본 문서를 first read 로 진입할 것* +*세션 종료 후에도 본 문서 + commit 히스토리만 살아있으면 작업 100% 복원 가능* diff --git a/docs/claude_designer_handoff.md b/docs/claude_designer_handoff.md new file mode 100644 index 000000000..1586af78a --- /dev/null +++ b/docs/claude_designer_handoff.md @@ -0,0 +1,231 @@ +# Kronos 통합 대시보드 — Claude Designer 리모델링 핸드오프 + +> **이 문서 하나로 Claude `frontend-design` 스킬이 전체 대시보드를 시각적으로 리모델링할 수 있도록 작성한 단일 핸드오프 파일이다. 기존 도메인 기능과 API는 그대로 두고 UI 표현 계층만 재구성한다.** + +**작성일**: 2026-05-16 KST · **마지막 갱신**: 2026-05-18 KST (Designer 프로토타입 통합 + P6 cutover 반영) +**대상 스킬**: `document-skills:frontend-design` +**핸드오프 범위**: webui v2 SPA (`/`) 전체 시각 리모델링 + 미구현 탭(P2~P6) 디자인 사양 포함 +**대상 디렉터리**: `D:\Chanil_Park\Project\Programming\Kronos\webui\v2_src\src\` +**금지 디렉터리**: `D:\Chanil_Park\Project\Programming\Kronos\webui\app.py`, `finetune/`, `model/`, `_database/` (백엔드/모델 무변경) + +--- + +## 0. 최종 상태 (2026-05-18) + +| Phase | 상태 | Commit | +|---|---|---| +| P0 합의 계획 | ✅ 완료 | 5c46ccd | +| P1 SSR Jinja shell | ✅ 완료 | 888bb08 | +| P1.5 Vite+Svelte 빌드 인프라 | ✅ 완료 | 2695151 | +| 디자인 시스템 v2 (light+dark, mint accent) | ✅ 완료 | ce42ab4 | +| Live Training 탭 리디자인 | ✅ 완료 | a631b54 | +| Artifacts/SystemHealth/Settings/History 탭 | ✅ 완료 | 7ff0d71 ~ 8f1beb4 | +| Forecast Workbench (P3) | ✅ 완료 | 7e38c18 | +| STOM Diagnostics (P4) | ✅ 완료 | 98d66d2 | +| **P6 Cutover** — `/` = v2, `/v1/*` archive, `/v2` → 301 | ✅ 완료 | **8562c64** | +| P5 Lighthouse a11y/perf gate | ⏸ 미진행 | - | + +**핵심 결과**: +- 사용자는 이제 `http://127.0.0.1:5070/` 로 진입하면 새 통합 대시보드를 본다 +- 기존 화면 3개 (`/`, `/training`, `/stom`) 는 `/v1/*` 로 archive (6개월 후 삭제 판단) +- `/v2` 북마크는 `/` 로 영구 리다이렉트 (301) +- 7개 탭 모두 정식 기능 작동 (placeholder 0개) + +--- + +## 1. TL;DR (3문장 요약) + +1. **무엇이 있는가**: Flask + 7 탭 v2 SPA (`/`) + 5개 legacy 라우트 (`/v1/*`) + 24개 read-only API. Svelte 5 + Vite 5 + Tailwind prebuilt + ECharts + Pretendard Variable + JetBrains Mono. light+dark 토글. +2. **무엇이 더 필요한가**: P5 Lighthouse 정식 측정 + a11y 검증 + 잔여 폴리시 (Candlestick / Plotly heatmap / top-k 카드 / CSV 다운로드). +3. **무엇을 건드리면 안 되는가**: Flask `app.py` 의 `/api/*`, 학습 코드, DB, finetune outputs, readiness gate 정책. + +--- + +## 2. 프로젝트 컨텍스트 + +### 2.1 Kronos +- **도메인**: K-line(캔들차트) 시계열 예측 모델 (금융 시장 데이터) +- **모델**: 2단계 학습 — Tokenizer → Predictor +- **데이터**: STOM (Securities Time-series Open Market), pred60 +- **사용자**: 단일 ML 엔지니어 (Windows 11, RTX 4080 SUPER, Python 3.11.13) + +### 2.2 학습 현황 +- 마지막 run `stom_1s_grid_pred60_2025_full_small`: tokenizer ~75% 에서 validation OOM 으로 실패 +- predictor 미진입 → checkpoint 0개 → readiness gate `waiting` +- 재학습 시 OOM 원인 수정 필요 (batch size 축소 등) — 별도 PR + +--- + +## 3. 라우트 맵 (P6 cutover 이후) + +| URL | 핸들러 | 대상 | 비고 | +|---|---|---|---| +| **`/`** | `webui/v2/__init__.py::v2_root` | v2 SPA (dist 모드: `webui/static/v2/dist/index.html`, fallback: `webui/templates/v2_shell.html`) | **신 통합 대시보드** | +| **`/v1/`** | `webui/app.py::v1_index` | `templates/index.html` | v1 메인 예측 화면 (legacy) | +| **`/v1/training`** | `webui/app.py::v1_training_dashboard_page` | `templates/training_dashboard.html` | v1 학습 모니터 | +| **`/v1/stom`** | `webui/app.py::v1_stom_dashboard_page` | `templates/stom_dashboard.html` | v1 STOM 진단 | +| `/v2` | `webui/v2/__init__.py::v2_legacy_redirect` | 301 → `/` | 호환 | +| `/v2/` | `webui/v2/__init__.py::v2_legacy_subpath` | 301 → `/` | 호환 | +| `/api/*` | 변경 없음 | `webui/app.py` | 24개 read-only endpoint | + +--- + +## 4. v2 SPA 탭 (7개, 전부 정식 구현 완료) + +| 탭 ID | 라벨 | 구현 상태 | 사용 API | 핵심 위젯/기능 | +|---|---|---|---|---| +| `live-training` | 실시간 학습 | ✅ 정식 | status/history/artifacts/gpu | Hero 도넛 + 스테퍼 + 메트릭 4 카드 + W3 Loss + W6 통계 + W4 ETA + W5 GPU | +| `forecast` | 예측 워크벤치 | ✅ 정식 (P3) | available-models / data-files / load-model / load-data / **predict** | 모델/데이터 selector + 4 슬라이더 + Seed 토글 + 결과 차트 | +| `stom` | 예측 진단 | ✅ 정식 (P4) | stom/summary + 8개 list/detail | DB KPI 4 + 파일 브라우저 + diagnostics 자동 조회 | +| `artifacts` | 아티팩트 & 모델 | ✅ 정식 | training/artifacts | Checkpoint/Weight/Predictor 3 카드 + 파일 row | +| `history` | 기록 & 런 | ✅ 정식 (P2) | training/runs | 4 KPI + 필터/정렬 + 14 run 그리드 | +| `system-health` | 시스템 상태 | ✅ 정식 | training/gpu | 3 KPI + 시계열 차트 + 하드웨어 표 | +| `settings` | 설정 | ✅ 정식 | localStorage only | 테마 카드 + 새로고침 5단계 + Notification API + 초기화 | + +--- + +## 5. 디자인 시스템 v2 (현재 적용 중) + +### 5.1 핵심 결정 +- **방향**: human-approachable (Airbnb/Mercury 톤) + ML 운영 도구 정보 밀도 (Datadog/Linear) +- **베이스**: 라이트 cool-tint `oklch(98% 0.004 240)` (다크 토글 가능) +- **액센트**: 민트 `oklch(56% 0.12 170)` — 페이지당 최대 2회 등장 +- **글로우**: 활성 RUN + 핵심 KPI 2곳에만 +- **이모지 금지** — 모든 아이콘 inline SVG + +### 5.2 토큰 owner +- 색상: `webui/v2_src/src/styles/core.css` `:root` / `[data-theme="dark"]` +- 컴포넌트: `webui/v2_src/src/styles/components.css` (`.card / .metric / .pill / .signal / .stepper / .hero ...`) +- TS 토큰: `webui/v2_src/src/lib/theme.ts` (ECharts/Plotly 옵션 owner) + +### 5.3 폰트 +- 디스플레이/본문: `Pretendard Variable` (CDN) +- 모노: `JetBrains Mono` (Google Fonts) +- 시스템 폴백: Segoe UI / Malgun Gothic + +--- + +## 6. 절대 제약 사항 (Hard Constraints) + +### 6.1 변경 금지 +| 경로 | 이유 | +|---|---| +| `webui/app.py` (특히 `/api/*` 핸들러) | 24개 endpoint — 모든 SPA + v1 화면이 공유 | +| `webui/templates/{index,training_dashboard,stom_dashboard}.html` | v1 archive (6개월 보존) | +| `finetune/`, `model/`, `_database/` | 학습 코드 + 모델 + DB | +| `webui/v2/__init__.py` | Flask Blueprint 분기 + cutover 라우팅 (P6 완료) | + +### 6.2 행동 금지 +- ❌ 신규 `/api/*` endpoint 추가 (모든 데이터는 기존 24개) +- ❌ predictor 미완료 상태에서 정확도/수익률 ready 표시 +- ❌ `power_draw_available=false` 시 추정값 표시 +- ❌ KST 변환을 API timestamp 자체에서 (표시 계층에서만) +- ❌ 이모지 아이콘 +- ❌ 보라/바이올렛 그라데이션 배경 + +### 6.3 행동 의무 +- ✅ SSR meta marker (`kronos-v2-shell`, `kronos-v2-version`) 모든 변형에서 보존 +- ✅ Vite `base: '/static/v2/dist/'` 유지 +- ✅ light + dark 양 모드 모두 검증 +- ✅ Dist commit 정책 (REV-7) — `webui/static/v2/dist/`는 git에 포함 + +--- + +## 7. 미진행 / 보강 후보 + +### 7.1 P5 정식 quality gate (남은 가장 큰 작업) +- [ ] Lighthouse a11y ≥ 90 측정 (`lighthouse http://127.0.0.1:5070/ --output=json`) +- [ ] Lighthouse perf ≥ 80 측정 (FLASK_ENV=production + gzip + waitress 환경에서) +- [ ] code-reviewer 결과를 `docs/kronos_dashboard_overhaul_p5_review.md` 별도 저장 +- [ ] WCAG AA 색상 대비 4.5:1 검증 (특히 light 모드 muted 텍스트) +- [ ] 키보드 네비게이션: Tab/Enter 로 모든 탭/슬라이더 접근 + +### 7.2 잔여 폴리시 (응답 스키마 확정 후) +- [ ] Forecast Candlestick 차트 (현재 라인) — `/api/predict` 응답 OHLC 형태 확정 시 +- [ ] STOM Plotly heatmap (dynamic import) — `/api/stom/diagnostics` 응답 hexmap 구조 확정 시 +- [ ] STOM top-k 추천 카드 — `/api/stom/recommendations?date=` 검증 +- [ ] STOM backtest 상세 모달 — `/api/stom/backtest-report?file=` 검증 +- [ ] Forecast CSV 다운로드 (client-side Blob) +- [ ] Settings 학습 단계 알림 watcher (Notification API) +- [ ] History run 클릭 시 손실 곡선 미니어처 (run 별 history endpoint 필요 — P3.5) + +### 7.3 운영 +- [ ] production 배포 환경 (FLASK_ENV=production + waitress 또는 gunicorn) +- [ ] v1 archive 6개월 후 삭제 검토 + +--- + +## 8. 빌드/배포 (운영 절차) + +```powershell +# 빌드 (변경 후 매번) +cd D:\Chanil_Park\Project\Programming\Kronos\webui\v2_src +npm run build + +# Flask 가동 +cd D:\Chanil_Park\Project\Programming\Kronos +$env:KRONOS_WEBUI_PORT = "5070" +$env:KRONOS_V2_DIST = "1" +C:\Python\64\Python3119\python.exe webui\run.py + +# 접속 +# http://127.0.0.1:5070/ — 새 통합 대시보드 (v2 SPA) +# http://127.0.0.1:5070/v1/ — v1 메인 (legacy archive) +# http://127.0.0.1:5070/v1/training — v1 학습 모니터 +# http://127.0.0.1:5070/v1/stom — v1 STOM 진단 + +# 테스트 +C:\Python\64\Python3119\python.exe -m pytest tests/test_v2_route.py tests/test_v2_dist_marker.py tests/test_v2_blueprint_isolation.py -v + +# Rollback (v1 임시 사용 / dist 비활성화) +$env:KRONOS_V2_DIST = "0" # P1 SSR Jinja shell 로 폴백 +# 또는 +git revert +``` + +--- + +## 9. 참고 파일 색인 + +### 9.1 필독 +- `webui/v2_src/README.md` — 빌드/배포/디렉터리 구조 +- `docs/kronos_dashboard_overhaul_plan.md` — RALPLAN-DR 합의 마스터 플랜 +- `docs/kronos_dashboard_p1_5_design_spec.md` — 디자인 토큰 상세 +- `docs/kronos_dashboard_p1_5_build_checklist.md` — 빌드 운영 체크리스트 + +### 9.2 디자인 시스템 출처 +- `template/extracted/DESIGN.md` — human-approachable 디자인 철학 + 토큰 사양 +- `template/extracted/styles/core.css` — 라이트/다크 OKLch 토큰 +- `template/extracted/styles/components.css` — `.card / .metric / .stepper` 등 + +### 9.3 최근 commit 히스토리 +``` +8562c64 P6 cutover — / 가 v2 SPA, /v1/* archive, /v2 → 301 +98d66d2 예측 진단 (STOM) 탭 P4 본격 구현 +7e38c18 예측 워크벤치 탭 P3 본격 구현 +8f1beb4 기록 & 런 탭 P2 정식 패널 구현 +3f17e78 설정 탭 확장 (테마 카드 + 새로고침 + 알림 + 초기화) +5ba3009 시스템 상태 탭 정식화 +7ff0d71 아티팩트 & 모델 탭 정식화 +a631b54 Live Training 탭 본격 리디자인 +ce42ab4 Designer 프로토타입 디자인 시스템 v2 이식 +2695151 P1.5 Vite Svelte SPA 정식 전환 +9411a23 P1.5 디자인 스펙 + 운영 체크리스트 +888bb08 P1 SSR 골격 구축 +5c46ccd ralplan 합의 계획 (P0) +``` + +--- + +## 10. 한 줄 미션 (달성 완료) + +> ~~"학습 모니터링 + 예측 워크벤치 + STOM 진단 + 아티팩트 + 시스템 상태 — 5개 도메인을 단 하나의 시각적으로 화려하고 직관적인 Svelte SPA로 통합하라."~~ +> +> ✅ **달성**: 7개 탭 모두 실 API 통합 + 디자인 시스템 v2 (human-approachable, light+dark, mint accent) + P6 cutover 완료. +> +> **다음 미션**: P5 Lighthouse quality gate + 잔여 폴리시 + 재학습. + +--- + +*핸드오프 작성자: Claude (현 세션)* +*최초 작성: 2026-05-16 KST · 마지막 갱신: 2026-05-18 KST* diff --git a/docs/kronos_dashboard_overhaul_plan.md b/docs/kronos_dashboard_overhaul_plan.md new file mode 100644 index 000000000..a63e45441 --- /dev/null +++ b/docs/kronos_dashboard_overhaul_plan.md @@ -0,0 +1,581 @@ +# Kronos 웹 대시보드 전면 개편 RALPLAN-DR 계획 (Draft, iteration 1 revised) + +작성일: 2026-05-12 KST +실행 모드: `$ralplan` 합의 계획 단계 (Architect+Critic iteration 1 반영) +대상 repo: `D:\Chanil_Park\Project\Programming\Kronos` +Confidence: **medium-high** (iteration 1 후 risk surface 좁힘, npm 의존성을 학습 완료 후로 격리) +Scope-risk: **moderate** (학습 중 작업은 SSR Jinja 골격으로만 진행, Vite/Node는 predictor 완료 후 별도 PR) + +--- + +## 0. TL;DR (결론 먼저) + +- **방향**: 통합 단일 페이지 SPA를 학습이 끝날 때까지 **2단계로 분리** 진행. 기존 Flask 라우트 3개(`/`, `/stom`, `/training`)는 학습 완료 cutover 시점까지 그대로 유지한다. +- **2단계 도입 (iteration 1 핵심 변경)**: + - **P1 (학습 중, npm 0)**: SSR Jinja `templates/v2_shell.html` + Alpine.js CDN + ECharts CDN 만으로 hero/stage/loss/eta/gpu 6개 위젯의 읽기 전용 골격. server-side marker로 grep 검증 가능. + - **P1.5 (predictor 완료 후, 별도 PR)**: 그 시점에 `webui/v2_src/` 생성, `npm ci` (Node 20 LTS pin), Vite 도입 + Svelte 5 + Tailwind prebuilt. P1 골격을 progressive enhancement. +- **추천 스택 (최종 형태)**: Svelte 5 + Vite + ECharts + Tailwind prebuilt. 단 학습 중에는 적용하지 않는다. +- **차트**: 메인 **Apache ECharts 5.x** (학습 곡선/스파크라인/대규모 시계열). STOM Diagnostics 탭에서만 **Plotly 2.35.2 dynamic import** 유지. +- **빌드 파이프라인**: Flask가 `webui/static/v2/dist`의 prebuilt 산출물을 정적 서빙. `dist/`는 commit (prebuilt 정책), `node_modules/`는 `.gitignore`. dev Vite 서버는 P1.5 이후에만, 별도 포트. +- **rollback 안전망**: `/v2` 별도 라우트 + `KRONOS_V2_ENABLED` env feature flag + git revert + v1 templates는 cutover 후 `/v1/*` prefix로 **6개월 archive**. +- **학습은 절대 건드리지 않는다.** P1 진행 중 npm/Node 0 의존성, P1.5는 학습 종료 후에만 시작. + +상태: **pending approval (Architect+Critic 합의 전)** — 마지막 줄에서 다시 명시. + +--- + +## 1. 현재 기준선 스냅샷 (read-only 관측) + +| 항목 | 값 | +|---|---:| +| run | `stom_1s_grid_pred60_2025_full_small` | +| status | `running` | +| stage | `tokenizer` | +| tokenizer step | 1,852,000 / 4,701,721 | +| tokenizer 진행률 | 약 39.39% | +| 전체 both-stage 진행률 | 약 19.69% | +| predictor stage | 미시작 | +| checkpoint 파일 | 0개 | +| GPU | RTX 4080 SUPER · Util 40% · VRAM 22% · 37°C | +| ETA | 약 50시간 (tokenizer만 기준) | + +이전 안전 계획 `docs/stom_dashboard_safe_parallel_improvement_plan.md`에서 1~6단계 (공통 readiness UI, artifacts API, history, GPU/ETA 카드, /stom gate, code-review)가 모두 완료되어 read-only 모니터링 토대는 이미 견고하다. 이번 개편은 그 위에 시각적 화려함과 단일 진입점을 얹는다. + +--- + +## 2. RALPLAN-DR 요약 + +### 2.1 원칙 (Principles, 5개) + +1. **학습 불간섭 (P1)**: 실행 중인 STOM full training과 `finetune/` 일체를 절대 수정/재시작하지 않는다. Vite/npm은 별도 프로세스에서만 동작하고, GPU/CUDA/PyTorch 환경에 손대지 않는다. +2. **읽기 전용 데이터 흐름 (P2)**: 모든 신규 API/UI는 read-only. predictor checkpoint가 만들어지기 전까지 정확도/수익률 ready 표시를 절대 켜지 않는다 (기존 readiness gate 정책 100% 계승). +3. **빌드 산출물 분리 (P3)**: 학습 중에는 npm 자원 경쟁을 피하기 위해 Vite dev 서버는 옵션이고, 운영 배포는 prebuilt static 자산만으로 동작한다. Python 측은 `webui/static/v2/dist/*`만 본다. +4. **공존 가능한 점진적 마이그레이션 (P4)**: 기존 `/`, `/stom`, `/training`은 학습 완료 cutover 시점까지 모두 살아 있다. 신규 SPA는 `/v2`에서 격리 검증된 후 합의로 cutover. +5. **시각적 화려함은 비용과 함께 평가 (P5)**: 모션/3D/heavy WebGL은 차트와 분리한다. 화려함은 micro-interaction과 정보 계층 명확성으로 표현하고, 학습 중 GPU 자원 점유를 늘리지 않는다. + +### 2.2 결정 동인 (Decision Drivers, Top 3) + +1. **D1 학습 보호 (절대 우선)**: 50시간짜리 학습이 깨지면 사용자 손실이 가장 크다. 모든 결정은 "학습에 영향을 주는가"가 첫 필터. +2. **D2 사용자 경험 통합성**: 현재 3개 페이지를 왔다갔다하며 같은 readiness 상태를 다시 확인해야 한다. 단일 SPA에서 한 번에 본다. +3. **D3 시각적 직관성 + 화려함**: tokenizer→predictor 전환 카운트다운, GPU 트렌드, 학습 곡선이 한눈에 들어와야 하며 따분하지 않아야 한다. + +(보조 드라이버: 유지보수성, 번들 크기, Windows + Python 3.11.9 환경 호환성) + +### 2.3 Viable Options (2개 viable + 1개 invalidated) + +> **iteration 1 변경**: 옵션 A의 도입 시점을 P1.5(학습 종료 후)로 명시 격리. P1(학습 중)에는 옵션 B(Alpine.js + ECharts CDN)의 패턴을 SSR Jinja로 그대로 사용한다. 즉 A/B는 양자택일이 아니라 **시간축 분할 시퀀스**다. + +#### Option A — **Svelte 5 + Vite + ECharts** (P1.5 이후 채택, 최종 형태) + +| 항목 | 내용 | +|---|---| +| 프론트 프레임워크 | Svelte 5 (runes API), TypeScript | +| 빌드 | Vite 5 (`base: '/static/v2/dist/'`) | +| 차트 | Apache ECharts 5 (메인) + Plotly 2.35.2 (STOM diagnostics, dynamic import만) | +| 스타일 | TailwindCSS **prebuilt** (`@tailwindcss/cli` 또는 PostCSS) + 커스텀 dark/navy 테마 — **Play CDN은 금지** (AC-1) | +| 라우팅 | client-side 단일 SPA, `/v2/` catch-all은 v2 prefix 내부에서만 (글로벌 catch-all 금지, REV-2) | +| 번들 추정 | gzip 약 150~220KB (Svelte ~10KB + ECharts core ~180KB + Tailwind 압축 후 ~30KB) | +| Node version | **20.x LTS pin** (`package.json#engines.node`), `npm ci`로 lockfile 기반 재현 가능 install | + +**Pros** + +- Svelte는 가상 DOM이 없어 런타임 오버헤드 최소, 학습 중 브라우저 부담 적음 +- ECharts는 학습 곡선(2M+ step)도 부드럽게 처리 (Canvas 기반) +- 컴파일 결과가 가벼워 Windows powershell 빌드 시간도 짧음 (initial install ~80MB node_modules) +- Plotly와 공존 가능 (script tag 격리) +- micro-interaction (slot transition, FLIP)이 자연스러워 "화려함" 요구를 코드 양 늘리지 않고 달성 + +**Cons** + +- 사용자 측 Svelte 학습 곡선 (단, 코드 수정자가 Claude/사용자 본인이므로 큰 부담 아님) +- npm 의존성 추가 (학습 중 install은 백그라운드/저우선순위 필요) +- 두 차트 라이브러리 공존 = 번들 일시 증가 (단 lazy load로 완화) + +#### Option B — **Alpine.js + ECharts CDN, SSR Jinja shell (P1 채택, 학습 중)** + +| 항목 | 내용 | +|---|---| +| 프론트 | Alpine.js 3 (CDN) — HTMX는 P1 범위에 불필요 (read-only fetch만) | +| 빌드 | 없음. `templates/v2_shell.html` Jinja 한 장 | +| 차트 | ECharts 5 (CDN, ` + + +``` + +### 빌드 후 Marker 검증 (B-2) + +```powershell +# 빌드 실행 +npm run build + +# SSR marker가 빌드 산출물에 유지되는지 확인 +Select-String -Path 'webui/static/v2/dist/index.html' -Pattern 'kronos-v2-shell' +# 결과: 매치되어야 함 (0 matches = FAIL) + +Select-String -Path 'webui/static/v2/dist/index.html' -Pattern 'kronos-v2-version' +# 결과: 매치되어야 함 +``` + +### 자동 테스트 추가 (Python, P1.5 이후 pytest) + +`tests/test_v2_dist_marker.py` (신규): + +```python +import os +import pytest + +def test_dist_marker_preserved_after_build(): + """[B-2] SSR marker가 Vite 빌드 후에도 보존됨""" + dist_index = 'webui/static/v2/dist/index.html' + + if not os.path.exists(dist_index): + pytest.skip("P1.5 dist not built yet") + + with open(dist_index, 'r', encoding='utf-8') as f: + body = f.read() + + assert 'kronos-v2-shell' in body, "SSR marker 'kronos-v2-shell' not found in dist/index.html" + assert 'kronos-v2-version' in body, "SSR marker 'kronos-v2-version' not found in dist/index.html" + assert 'p1-5-spa' in body, "Version string 'p1-5-spa' not found in dist/index.html" +``` + +--- + +## 7. Flask Blueprint 및 dist 모드 활성화 + +### `webui/v2/__init__.py` Blueprint (이미 P1에서 생성됨) + +```python +from flask import Blueprint, render_template, send_from_directory, current_app +import os + +v2_bp = Blueprint('v2', __name__) + +@v2_bp.route('/v2') +def v2_index(): + """ + P1.5: dist 모드 활성화 시 Vite 빌드 산출물 서빙. + KRONOS_V2_DIST=0 또는 파일 미존재 시 P1 SSR Jinja 폴백. + """ + dist_index = os.path.join(current_app.static_folder, 'v2', 'dist', 'index.html') + + if (os.path.exists(dist_index) and + os.environ.get('KRONOS_V2_DIST', '0') == '1'): + return send_from_directory( + os.path.join(current_app.static_folder, 'v2', 'dist'), + 'index.html', + ) + + # P1 폴백: SSR Jinja + return render_template('v2_shell.html') + +@v2_bp.route('/v2/') +def v2_spa_fallback(subpath): + """ + [REV-2] v2 prefix 내부 catch-all만 허용. + 글로벌 / 금지. + """ + return v2_index() +``` + +### dist 모드 활성화 명령 + +```powershell +# 환경변수 설정 +$env:KRONOS_V2_DIST = "1" + +# Flask 앱 재시작 (또는 재실행) +python -m webui.run +# 또는 +$env:FLASK_APP = "webui.app:app" +flask run --host=127.0.0.1 --port=7070 +``` + +### 활성화 후 검증 + +```powershell +# 1. dist/index.html 반환 확인 +$response = Invoke-WebRequest -Uri 'http://127.0.0.1:7070/v2' +$response.StatusCode # 200이어야 함 + +# 2. SSR marker 포함 확인 +if ($response.Content -match 'kronos-v2-version.*p1-5-spa') { + Write-Output "✓ SSR marker verified" +} else { + Write-Output "✗ SSR marker NOT found" +} + +# 3. 정적 자산 로드 확인 +Invoke-WebRequest -Uri 'http://127.0.0.1:7070/static/v2/dist/assets/' +# 200 OK + JavaScript/CSS 파일 목록 +``` + +--- + +## 8. Rollback 절차 (P1.5 머지 후 문제 발생 시) + +### 즉시 Rollback (1초) + +```powershell +# 환경변수 비활성화 → P1 SSR 자동 폴백 +$env:KRONOS_V2_DIST = "0" + +# Flask 재시작 +Restart-Service -Name 'KronosFlask' -Force # 또는 수동 재시작 +# 또는 +# 프로세스 종료 후 다시 실행 +``` + +### 영구 Rollback (코드) + +```powershell +# 옵션 1: dist 파일 삭제 (Blueprint가 파일 미존재 감지 → P1 폴백) +Remove-Item -Path 'webui/static/v2/dist/' -Recurse -Force + +# 옵션 2: 커밋 되돌리기 +git revert + +# 둘 다 실행 후 Flask 재시작 +``` + +### 추가 안전망 (B-5) + +Plan §4의 4가지 rollback trigger 중 하나라도 만족 시 즉시 롤백: + +| 트리거 | 기준 | 모니터링 방법 | +|---|---|---| +| readiness 오분류 | 1회 발견 | `/api/training/status` 응답 모니터링 | +| p95 API latency | > 500ms (baseline 대비 +50%) | Flask/Gunicorn 로그 또는 APM | +| error rate | > 1% (5분 윈도우) | Flask error 로그 수집 | +| critical bug | 1건 보고 | 사용자 피드백 | + +--- + +## 9. P1.5 PR 작업 순서 (실제 진행 시 따를 절차) + +| 단계 | 작업 | 검증 | +|---|---|---| +| **1. 사전 조건 확인** | §1 GO/NOGO 표 7개 항목 모두 확인 | 모두 ☑ 또는 NOGO 사유 문서화 | +| **2. Disk baseline 측정** | §2 PowerShell 스니펫 실행, `docs/p1_5_disk_baseline.md` 저장 | ±5% 이내 acceptance | +| **3. Stack 재평가** | §3 표 7개 항목 버전 확인, 변경 사항 문서화 | `package.json` `engines` 필드 업데이트 | +| **4. `webui/v2_src/` 초기화** | `npm init -y` + 의존성 설치 (svelte, vite, typescript, tailwindcss, echarts, plotly 등) | `npm ci --prefer-offline` 성공 | +| **5. 설정 파일 작성** | vite.config.ts, tsconfig.json, tailwind.config.js, postcss.config.js, index.html (SSR marker 포함) | 파일 생성 확인 | +| **6. 컴포넌트 작성** | design_spec.md 따라 src/lib/theme.ts, src/components/*.svelte | 타입체크 무에러 | +| **7. 빌드** | `npm run build` | `webui/static/v2/dist/` 생성 | +| **8. SSR marker 검증** | §6 `Select-String` 명령 실행 | marker 2개 발견 | +| **9. KRONOS_V2_DIST=1 활성화** | 환경변수 설정 + Flask 재시작 | `http://127.0.0.1:7070/v2` 정상 로드 | +| **10. pytest 통과** | 기존 17개 + 신규 `test_v2_dist_marker.py` | 모두 PASS | +| **11. Lighthouse 측정** | §13 Flask production-mode 측정 | a11y ≥ 90, performance ≥ 80 | +| **12. Commit + PR** | 단계별 분할 PR (PR-1~PR-5) | 각 PR 리뷰 통과 | + +--- + +## 10. PR 분할 전략 (대량 변경 회피) + +P1.5는 하나의 거대 PR이 아니라 **5개 단계 PR**로 분할: + +| PR | 내용 | 파일 | 의존성 | commit msg | +|---|---|---|---|---| +| **PR-1** | 의존성 + 설정 | `package.json`, `package-lock.json`, `vite.config.ts`, `tsconfig.json`, `tailwind.config.js`, `postcss.config.js`, `index.html` (SSR marker 박음) | 선행 없음 | `build(webui-v2): npm 의존성 및 Vite 설정 추가` | +| **PR-2** | 유틸 + 테마 | `src/lib/theme.ts`, `src/lib/api.ts`, `src/stores/*.ts`, `src/utils/*.ts` | PR-1 필수 | `feat(webui-v2): 공유 theme/api/stores 구현` | +| **PR-3** | 레이아웃 | `src/App.svelte`, `src/components/Layout.svelte`, `src/components/Sidebar.svelte`, `src/components/HeroStrip.svelte` | PR-2 필수 | `feat(webui-v2): 레이아웃 및 네비게이션 구축` | +| **PR-4** | 컴포넌트 | `src/routes/LiveTraining.svelte`, `src/components/{StageStepper,LossChart,EtaTimeline,GpuSparkline}.svelte` (W1~W5, W7) | PR-3 필수 | `feat(webui-v2): live training 컴포넌트 구현` | +| **PR-5** | 빌드 + 배포 | `webui/static/v2/dist/**`, `KRONOS_V2_DIST=1` 활성화 | PR-4 필수 | `feat(webui-v2): Vite 빌드 산출물 및 dist 모드 활성화` | + +각 PR은 **독립적으로 P1 SSR 폴백 가능** (`KRONOS_V2_DIST=0` 또는 파일 미존재 → P1 Jinja 자동 사용). + +--- + +## 11. Lighthouse 측정 환경 (B-5, P1.5 성능 기준) + +### 측정 준비 + +```powershell +# Flask production 모드 설정 +$env:FLASK_ENV = "production" +$env:KRONOS_V2_DIST = "1" + +# Gunicorn 또는 waitress로 gzip 활성화 +# (waitress는 기본 gzip 비활성, 별도 middleware 필요) +python -m waitress --listen=127.0.0.1:7070 webui.app:app +``` + +### Lighthouse 명령 + +```powershell +# npm이 설치된 머신에서 실행 +npx lighthouse ` + http://127.0.0.1:7070/v2 ` + --output=json ` + --output-path=docs/lighthouse_v2_p1_5.json ` + --chrome-flags="--headless=new --no-sandbox --disable-gpu" ` + --only-categories=performance,accessibility ` + --throttling-method=simulate +``` + +### Acceptance 기준 (B-5) + +| 카테고리 | 최소값 | 측정 대상 | +|---|---|---| +| Accessibility | ≥ 90 | 접근성 준수 (스크린 리더, 키보드 네비) | +| Performance | ≥ 80 | 첫 페인트, LCP, CLS 등 | + +--- + +## 12. 위반 시 책임 매트릭스 + +| 위반 사항 | 결과 | 복구 방법 | +|---|---|---| +| **학습 중 npm install 실행** | D1 위반 — 즉시 중단, 학습 영향 측정, docs commit | `docs/incident_npm_during_training.md` 작성 후 재진행 | +| **dist commit 누락** | P6 cutover 시 404 — rollback | git history 수정 또는 별도 "dist 추가" commit | +| **SSR marker 누락** | smoke test FAIL → PR 머지 차단 | `index.html`에 `` 태그 추가 후 재빌드 | +| **`package-lock.json` commit 누락** | 재현성 깨짐 → 다른 환경에서 빌드 불일치 | lock 파일 생성 후 commit, `npm ci` 재실행 | +| **글로벌 catch-all 라우트 추가** | 라우팅 충돌 — REV-2 위반 → PR 머지 차단 | Blueprint 검토, 기존 라우트와 충돌 제거 | +| **Play CDN 또는 dev asset 포함** | AC-1 위반 — prod 빌드 불안정 → rollback | `vite.config.ts` 빌드 설정 검토, dist 재빌드 | + +--- + +## 13. 최종 확인 항목 + +P1.5 PR 머지 직전 마지막 체크: + +- [ ] §1 GO/NOGO 7개 항목 모두 ☑ +- [ ] §2 Disk baseline `docs/p1_5_disk_baseline.md` 작성 완료 +- [ ] §3 Stack 재평가 `package.json` 버전 확정 +- [ ] §4 `.gitignore` 작성, dist/ + src/ commit, node_modules 제외 +- [ ] §5 `npm ci` (npm install 아님) 실행 및 lock 파일 commit +- [ ] §6 `index.html` SSR marker 정적 박음 확인 +- [ ] §6 빌드 후 marker `Select-String` 검증 통과 +- [ ] §7 Blueprint 기존 라우트와 충돌 없음 확인 (`app.url_map` 비교) +- [ ] §8 Rollback 절차 문서화 완료 +- [ ] §10 PR 분할 계획 5개 PR 준비 +- [ ] §11 Lighthouse 측정 환경 구성, a11y ≥ 90, performance ≥ 80 +- [ ] 기존 pytest 17개 + 신규 `test_v2_dist_marker.py` 모두 PASS +- [ ] `git status --short --branch` clean 확인 +- [ ] commit message [ralplan 합의](#합의-결정) 반영 여부 확인 + +--- + +## 합의 결정 + +이 체크리스트는 다음 ralplan 합의 결정을 **운영 수준에서 실행**하기 위해 작성되었습니다: + +| 결정 ID | 내용 | 본 체크리스트 반영 | +|---|---|---| +| **B-1** | P1/P1.5 분리: 학습 중 npm 0, predictor 완료 후 npm ci | §1, §2, §5, §9 | +| **B-2** | SSR marker: server-side `` grep 검증 | §6, §8 테스트 | +| **B-5** | Rollback trigger: 정량화 (readiness/latency/error/bug) | §8, §11 | +| **REV-2** | vite.config 설정: `server.watch.ignored` 학습 디렉터리 제외 | §6 | +| **REV-7** | dist commit: prebuilt 정책, node_modules 제외 | §4 | +| **AC-1** | Tailwind prebuilt: Play CDN 금지 | §6, §12 | + +--- + +## 상태 + +**상태**: P1.5 사전 운영 체크리스트 (predictor 완료 후 PR로 적용) + +predictor 학습이 완료되면 이 체크리스트의 §1~§13을 순서대로 따르고, 각 단계마다 체크박스(☐/☑)를 기록하여 추적하세요. diff --git a/docs/kronos_dashboard_p1_5_design_spec.md b/docs/kronos_dashboard_p1_5_design_spec.md new file mode 100644 index 000000000..3e48337f5 --- /dev/null +++ b/docs/kronos_dashboard_p1_5_design_spec.md @@ -0,0 +1,618 @@ +# Kronos 대시보드 P1.5 UI/시각 디자인 스펙 + +작성일: 2026-05-13 KST +상태: **P1.5 사전 디자인 스펙 (학습 진행 중, 구현 미진행)** +근거 문서: `docs/kronos_dashboard_overhaul_plan.md` (RALPLAN-DR 합의 계획) + +> **중요: 이 문서는 디자인 사양 전용이다. 학습 프로세스를 건드리는 코드/명령은 0건이며, npm/빌드/코드 변경도 이 문서 작성 시점에는 0건이다. 구현은 `predictor_complete=true` 확인 후 별도 PR로 진행한다.** + +대상 파일 (P1.5 시작 시 생성): +- `webui/v2_src/src/lib/theme.ts` (P1.5 시작 시 신규 생성) +- `webui/static/v2/css/v2.css` (P1 hand-rolled CSS와 단절 없이 호환) + +--- + +## 1. 미적 방향 (Aesthetic Direction) + +**도메인**: 학습 대시보드 / 데이터 모니터링 / 운영 도구 — editorial-leaning default를 **명시적으로 재정의**한다. + +**선택한 방향**: Dark Navy Operational (운영 도구용 dark 팔레트) + +| 결정 항목 | 값 | 이유 | +|---|---|---| +| 배경 | `#0f172a` (slate-900) | P1에서 이미 확정된 body background — 단절 금지 | +| 카드 | `#111827` (gray-900) | P1 `--chart-bg` 그대로 계승 | +| 보더 | `#243244` | P1 `--chart-grid` 그대로 계승 | +| 액센트 | `#38bdf8` (sky-400) | P1 `--chart-color-primary` 그대로 계승 | +| 서체 (디스플레이) | `'Segoe UI', 'Malgun Gothic', sans-serif` | P1 정의 계승 + Windows 환경 최우선 | +| 서체 (모노) | `'Consolas', 'D2Coding', monospace` | Windows 네이티브 + step/loss 수치 가독성 | +| 포인트 색상 | `#22c55e` 성공, `#f59e0b` 경고, `#ef4444` 위험 | P1 신호등 체계 그대로 | + +**차별점**: P1.5에서 새로 추가되는 것은 **더 많은 색**이 아니라, 동일 팔레트 위에 올라오는 micro-interaction(count-up, slide-fade 탭 전환, hover lift, pulse)과 타이포그래피 스케일의 정밀도다. + +--- + +## 2. Design Tokens — CSS 변수 기준선 + +### 2.1 P1 기존 변수 (v2_shell.html 정의, 변경 금지) + +| 변수 | 값 | 사용처 | Tailwind 매핑 | +|---|---|---|---| +| `--chart-color-primary` | `#38bdf8` | 액센트, 활성 nav, ECharts 주 계열 | `sky-400` | +| `--chart-color-secondary` | `#22c55e` | 완료/성공 상태, ETA bar 끝 색 | `green-500` | +| `--chart-color-warn` | `#f59e0b` | 경고 신호등(황), 예측기 대기 | `amber-500` | +| `--chart-color-danger` | `#ef4444` | 위험 신호등(적), 오류 상태 | `red-500` | +| `--chart-bg` | `#111827` | 카드 배경 | `gray-900` | +| `--chart-grid` | `#243244` | 차트 그리드선, 카드 보더, nav 보더 | (커스텀, slate-800~900 사이) | +| `--chart-font-family` | `'Segoe UI','Malgun Gothic',sans-serif` | 차트 라벨, 기본 body | (커스텀) | + +### 2.2 P1.5에서 추가할 변수 (theme.ts가 document.documentElement.style에 set) + +#### 색상 (Color) + +| 변수 | 값 | 사용처 | Tailwind 매핑 | +|---|---|---|---| +| `--color-bg` | `#0f172a` | body 배경 (P1 body background 일치) | `slate-900` | +| `--color-card` | `#111827` | 카드/패널 배경 (`--chart-bg` 별칭) | `gray-900` | +| `--color-card-raised` | `#0b1120` | 중첩 카드, 스파크 카드 (P1 `.w5-spark-card` 배경) | (커스텀) | +| `--color-border` | `#243244` | 카드/nav/스파크 카드 보더 (`--chart-grid` 별칭) | (커스텀) | +| `--color-border-muted` | `#1e293b` | 서브 구분선, stepper connector | `slate-800` | +| `--color-text` | `#e2e8f0` | 본문 텍스트 (P1 body color 일치) | `slate-200` | +| `--color-text-muted` | `#94a3b8` | 보조 텍스트, nav 비활성 (P1 일치) | `slate-400` | +| `--color-text-dim` | `#64748b` | 더 약한 보조 (P1 `.step-name`, `.timeline-label`) | `slate-500` | +| `--color-text-faint` | `#475569` | 섹션 제목, 최약 텍스트 (P1 `.nav-section-title`) | `slate-600` | +| `--color-accent` | `#38bdf8` | 주 액센트 (`--chart-color-primary` 별칭) | `sky-400` | +| `--color-accent-dim` | `rgba(56,189,248,.12)` | 활성 nav 배경 (P1 일치) | — | +| `--color-accent-hover` | `rgba(56,189,248,.08)` | hover nav 배경 (P1 일치) | — | +| `--color-accent-subtle` | `#bfdbfe` | 헤더 h1 색 (P1 일치) | `blue-200` | +| `--color-success` | `#22c55e` | 완료, 신호등 녹 (`--chart-color-secondary`) | `green-500` | +| `--color-success-bg` | `#14532d` | 완료 배지 배경 (P1 일치) | `green-900` | +| `--color-success-text` | `#dcfce7` | 완료 배지 텍스트 (P1 일치) | `green-100` | +| `--color-warn` | `#f59e0b` | 경고 (`--chart-color-warn`) | `amber-500` | +| `--color-warn-bg` | `#422006` | 대기 배지 배경 (P1 일치) | `orange-950` | +| `--color-warn-text` | `#fde68a` | 대기 배지 텍스트 (P1 일치) | `amber-200` | +| `--color-danger` | `#ef4444` | 위험 (`--chart-color-danger`) | `red-500` | +| `--color-info` | `#93c5fd` | 학습중 배지 텍스트, 링크 색 (P1 일치) | `blue-300` | +| `--color-info-bg` | `#0c4a6e` | 학습중 배지 배경 (P1 일치) | `sky-950` | +| `--color-overlay` | `rgba(15,23,42,.8)` | 모달 오버레이, 잠금 위젯 dim | — | +| `--color-header-gradient-start` | `#111827` | 헤더 그라디언트 시작 (P1 일치) | `gray-900` | +| `--color-header-gradient-end` | `#1d4ed8` | 헤더 그라디언트 끝 (P1 일치) | `blue-700` | + +#### 타이포그래피 (Typography) + +| 변수 | 값 | 사용처 | Tailwind 매핑 | +|---|---|---|---| +| `--font-family-base` | `'Segoe UI','Malgun Gothic',sans-serif` | 본문 전체 (`--chart-font-family` 별칭) | — | +| `--font-family-mono` | `'Consolas','D2Coding',monospace` | step 수치, loss 값, 타임스탬프 | `font-mono` | +| `--font-size-xs` | `0.625rem` (10px) | nav 섹션 제목, 라벨 상단 (P1 일치) | `text-[10px]` | +| `--font-size-sm` | `0.6875rem` (11px) | 보조 레이블, step-name, spark-label (P1 일치) | `text-[11px]` | +| `--font-size-base` | `0.75rem` (12px) | 기본 본문, timeline-value, muted (P1 일치) | `text-xs` | +| `--font-size-md` | `0.8125rem` (13px) | nav 링크, 카드 h3, badge (P1 일치) | `text-[13px]` | +| `--font-size-lg` | `0.9375rem` (15px) | 카드 h2 (P1 일치) | `text-[15px]` | +| `--font-size-xl` | `1rem` (16px) | spark 현재 값 (P1 일치) | `text-base` | +| `--font-size-2xl` | `1.25rem` (20px) | 헤더 h1 (P1 일치) | `text-xl` | +| `--font-size-3xl` | `1.5rem` (24px) | Hero 강조 수치 (P1.5 신규, count-up 대상) | `text-2xl` | +| `--line-height-tight` | `1.25` | 배지, 수치 전용 줄 | — | +| `--line-height-normal` | `1.5` | 일반 본문 (P1 `.w7-message` 일치) | — | +| `--line-height-relaxed` | `1.75` | 잠금 위젯 설명 텍스트 | — | + +#### 간격 (Spacing) + +| 변수 | 값 | 사용처 | Tailwind 매핑 | +|---|---|---|---| +| `--space-0_5` | `0.125rem` (2px) | 최소 여백 | `space-0.5` | +| `--space-1` | `0.25rem` (4px) | 배지 패딩 상하 (P1 일치) | `p-1` | +| `--space-2` | `0.5rem` (8px) | 아이콘/텍스트 gap, stepper gap (P1 일치) | `gap-2` | +| `--space-3` | `0.75rem` (12px) | 카드 내부 소 여백, spark-card padding (P1 일치) | `p-3` | +| `--space-4` | `1rem` (16px) | nav 좌우 패딩, 카드 마진 (P1 `18px` 근사) | `p-4` | +| `--space-5` | `1.125rem` (18px) | 카드 패딩, hero padding (P1 정확히 18px) | — | +| `--space-6` | `1.5rem` (24px) | 헤더/메인 좌우 padding (P1 일치) | `px-6` | +| `--space-8` | `2rem` (32px) | 섹션 간 대형 여백 | `my-8` | +| `--space-12` | `3rem` (48px) | 잠금 위젯 상하 패딩 (P1 `40px` 근사) | `py-12` | +| `--space-16` | `4rem` (64px) | 빈 상태 최대 패딩 | `py-16` | + +#### 라운딩 (Border Radius) + +| 변수 | 값 | 사용처 | Tailwind 매핑 | +|---|---|---|---| +| `--radius-sm` | `6px` | 배지 이너, 소형 pill | `rounded` | +| `--radius-md` | `10px` | spark-card (P1 일치) | `rounded-[10px]` | +| `--radius-lg` | `14px` | 카드, hero-strip (P1 일치) | `rounded-2xl` | +| `--radius-xl` | `18px` | 모달, 대형 패널 | `rounded-[18px]` | +| `--radius-full` | `9999px` | 배지 pill, 도트, 프로그레스 바 (P1 일치) | `rounded-full` | + +#### 그림자 (Shadow) + +| 변수 | 값 | 사용처 | Tailwind 매핑 | +|---|---|---|---| +| `--shadow-sm` | `0 1px 3px rgba(0,0,0,.4)` | 비활성 카드 | `shadow-sm` | +| `--shadow-md` | `0 4px 12px rgba(0,0,0,.5)` | hover lift 기본 상태 | `shadow-md` | +| `--shadow-lg` | `0 8px 24px rgba(0,0,0,.6)` | hover lift 활성 상태 | `shadow-lg` | +| `--shadow-xl` | `0 16px 40px rgba(0,0,0,.7)` | 모달, 커맨드 팔레트 | `shadow-xl` | +| `--shadow-glow-accent` | `0 0 8px #38bdf8` | (미사용, P1.5 예약) | — | +| `--shadow-glow-green` | `0 0 8px #22c55e` | 신호등 lit-green (P1 `.tl-dot.lit-green` 일치) | — | +| `--shadow-glow-yellow` | `0 0 8px #f59e0b` | 신호등 lit-yellow (P1 일치) | — | +| `--shadow-glow-red` | `0 0 8px #ef4444` | 신호등 lit-red (P1 일치) | — | + +#### 트랜지션 (Transition) + +| 변수 | 값 | 사용처 | Tailwind 매핑 | +|---|---|---|---| +| `--transition-fast` | `100ms ease` | 배지 상태 변경 | `duration-100` | +| `--transition-base` | `150ms ease` | nav hover, 신호등, progress bar (P1 일치) | `duration-150` | +| `--transition-slow` | `400ms ease` | ETA bar fill, count-up 애니 (P1 `.timeline-bar-fill` 일치) | `duration-[400ms]` | +| `--transition-tab` | `200ms ease-out` | 탭 slide/fade 전환 | — | +| `--transition-lift` | `150ms ease` | 카드 hover lift | — | + +#### 브레이크포인트 (Breakpoints, 참조용 CSS 변수가 아닌 미디어 쿼리 상수) + +| 상수 | 값 | 의미 | +|---|---|---| +| `--bp-sm` | `480px` | 소형 모바일 | +| `--bp-md` | `768px` | 태블릿 | +| `--bp-lg` | `900px` | 기존 P1 `.main-grid` 단열 전환 기준 (P1 일치) | +| `--bp-xl` | `1280px` | 데스크탑 기준 레이아웃 | +| `--bp-2xl` | `1536px` | 와이드 스크린 | + +--- + +## 3. Theme.ts 코드 스펙 + +> **주의**: 아래는 구현이 아닌 사양(specification)이다. P1.5 시작 시 `webui/v2_src/src/lib/theme.ts`로 신규 생성한다. + +```ts +// webui/v2_src/src/lib/theme.ts +// P1.5 시작 시 생성. ECharts + Plotly 두 라이브러리의 테마 옵션을 단일 파일에서 관리한다. + +import type { EChartsOption } from 'echarts'; + +// ── 1. 토큰 정의 ──────────────────────────────────────────────────── +export const theme = { + colors: { + bg: '#0f172a', + card: '#111827', + cardRaised: '#0b1120', + border: '#243244', + borderMuted: '#1e293b', + text: '#e2e8f0', + textMuted: '#94a3b8', + textDim: '#64748b', + textFaint: '#475569', + accent: '#38bdf8', + accentDim: 'rgba(56,189,248,.12)', + accentHover: 'rgba(56,189,248,.08)', + accentSubtle: '#bfdbfe', + success: '#22c55e', + successBg: '#14532d', + successText: '#dcfce7', + warn: '#f59e0b', + warnBg: '#422006', + warnText: '#fde68a', + danger: '#ef4444', + info: '#93c5fd', + infoBg: '#0c4a6e', + }, + typography: { + fontBase: "'Segoe UI','Malgun Gothic',sans-serif", + fontMono: "'Consolas','D2Coding',monospace", + // 크기는 rem 단위. chart label용 px 환산은 getter 사용. + size: { xs: '0.625rem', sm: '0.6875rem', base: '0.75rem', + md: '0.8125rem', lg: '0.9375rem', xl: '1rem', + '2xl': '1.25rem', '3xl': '1.5rem' }, + }, + spacing: { + '0_5': '0.125rem', '1': '0.25rem', '2': '0.5rem', '3': '0.75rem', + '4': '1rem', '5': '1.125rem', '6': '1.5rem', '8': '2rem', + '12': '3rem', '16': '4rem', + }, + radius: { sm: '6px', md: '10px', lg: '14px', xl: '18px', full: '9999px' }, + shadow: { + sm: '0 1px 3px rgba(0,0,0,.4)', + md: '0 4px 12px rgba(0,0,0,.5)', + lg: '0 8px 24px rgba(0,0,0,.6)', + xl: '0 16px 40px rgba(0,0,0,.7)', + glowAccent: '0 0 8px #38bdf8', + glowGreen: '0 0 8px #22c55e', + glowYellow: '0 0 8px #f59e0b', + glowRed: '0 0 8px #ef4444', + }, + transition: { + fast: '100ms ease', + base: '150ms ease', + slow: '400ms ease', + tab: '200ms ease-out', + lift: '150ms ease', + }, +} as const; + +// ── 2. CSS 변수 적용 ───────────────────────────────────────────────── +// P1의 기존 --chart-color-* 변수도 동일 값으로 다시 set → 두 모드 공존 보장 +export function applyTheme(): void { + const root = document.documentElement.style; + const c = theme.colors; + const t = theme.typography; + + // P1 호환 (기존 변수 유지) + root.setProperty('--chart-color-primary', c.accent); + root.setProperty('--chart-color-secondary', c.success); + root.setProperty('--chart-color-warn', c.warn); + root.setProperty('--chart-color-danger', c.danger); + root.setProperty('--chart-bg', c.card); + root.setProperty('--chart-grid', c.border); + root.setProperty('--chart-font-family', t.fontBase); + + // P1.5 신규 변수 + root.setProperty('--color-bg', c.bg); + root.setProperty('--color-card', c.card); + root.setProperty('--color-card-raised', c.cardRaised); + root.setProperty('--color-border', c.border); + root.setProperty('--color-border-muted', c.borderMuted); + root.setProperty('--color-text', c.text); + root.setProperty('--color-text-muted', c.textMuted); + root.setProperty('--color-text-dim', c.textDim); + root.setProperty('--color-text-faint', c.textFaint); + root.setProperty('--color-accent', c.accent); + root.setProperty('--color-accent-dim', c.accentDim); + root.setProperty('--color-success', c.success); + root.setProperty('--color-warn', c.warn); + root.setProperty('--color-danger', c.danger); + root.setProperty('--color-info', c.info); + root.setProperty('--font-family-base', t.fontBase); + root.setProperty('--font-family-mono', t.fontMono); + // ... (spacing, radius, shadow, transition 변수도 동일 패턴으로 set) +} + +// ── 3. ECharts 테마 옵션 ───────────────────────────────────────────── +// W3(LossCurve), W5(GpuSparkline) 공통으로 사용 +export function getEChartsTheme(): Partial { + const c = theme.colors; + return { + backgroundColor: c.card, + textStyle: { fontFamily: theme.typography.fontBase, color: c.textMuted }, + color: [c.accent, c.success, c.warn, c.danger, c.info], + axisPointer: { lineStyle: { color: c.border } }, + grid: { borderColor: c.border }, + xAxis: { + axisLine: { lineStyle: { color: c.border } }, + splitLine: { lineStyle: { color: c.border, type: 'dashed' } }, + axisLabel: { color: c.textDim, fontFamily: theme.typography.fontBase }, + }, + yAxis: { + axisLine: { lineStyle: { color: c.border } }, + splitLine: { lineStyle: { color: c.border, type: 'dashed' } }, + axisLabel: { color: c.textDim, fontFamily: theme.typography.fontBase }, + }, + tooltip: { + backgroundColor: c.cardRaised, + borderColor: c.border, + textStyle: { color: c.text }, + }, + }; +} + +// ── 4. Plotly 레이아웃 (STOM Diagnostics 탭 전용, dynamic import 후 사용) ── +// 반환 타입은 Plotly dynamic import 완료 후 Plotly.Layout에 맞게 캐스팅 +export function getPlotlyLayout(): Record { + const c = theme.colors; + return { + paper_bgcolor: c.card, + plot_bgcolor: c.card, + font: { family: theme.typography.fontBase, color: c.textMuted, size: 11 }, + colorway: [c.accent, c.success, c.warn, c.danger, c.info], + xaxis: { gridcolor: c.border, linecolor: c.border, tickfont: { color: c.textDim } }, + yaxis: { gridcolor: c.border, linecolor: c.border, tickfont: { color: c.textDim } }, + hoverlabel: { bgcolor: c.cardRaised, bordercolor: c.border, + font: { family: theme.typography.fontBase, color: c.text } }, + margin: { t: 32, r: 16, b: 40, l: 48 }, + }; +} + +// ── 5. Readiness 레벨 → 색상/카피 매핑 (readinessMap.ts에서 재사용) ── +export const readinessColorMap = { + ready: { bg: '#14532d', text: '#dcfce7', glow: '0 0 8px #22c55e' }, + training: { bg: '#0c4a6e', text: '#bae6fd', glow: '0 0 8px #38bdf8' }, + waiting: { bg: '#422006', text: '#fde68a', glow: '0 0 8px #f59e0b' }, +} as const; +``` + +**theme.ts 소유권 규칙**: +- ECharts 옵션은 `W3_LossCurve.svelte`, `W5_GpuSparkline.svelte` mount 직전 `getEChartsTheme()`를 호출해 baseOption에 spread한다. +- Plotly 옵션은 `/v2#stom` 탭 진입 시 `import('plotly.js-dist-min')` 완료 후 `getPlotlyLayout()`을 merge한다. +- 두 차트 라이브러리가 **동일 색 토큰**을 읽으므로 한 화면에 동시에 떠도 폰트·배경·그리드·색상이 일치한다. + +--- + +## 4. Svelte 컴포넌트 트리 + +### 4.1 디렉터리 구조 (P1.5 빌드 후) + +``` +webui/v2_src/ +├── index.html # Vite entry. SSR marker 정적 삽입 (P1과 동일 내용) +├── vite.config.ts # base: '/static/v2/dist/', watch.ignored 학습 경로 전부 제외 +├── tsconfig.json +├── package.json # engines.node: "^20" +├── package-lock.json # npm ci 재현 가능 설치용, commit 대상 +├── tailwind.config.js +├── postcss.config.js +└── src/ + ├── main.ts # applyTheme() 최초 호출 → App mount + ├── App.svelte # 최상위: Layout(Sidebar + HeroStrip + TabContent) + ├── lib/ + │ ├── theme.ts # 디자인 토큰 (§3) + │ ├── api.ts # fetch 래퍼: /api/training/{status,history,artifacts,gpu} + │ ├── stores.ts # Svelte writable stores (§6 참조) + │ ├── polling.ts # startPolling(store, fetcher, intervalMs) + │ ├── ringBuffer.ts # RingBuffer(capacity) — GPU 720pt, history 1000pt + │ ├── kstFormat.ts # toKstString(isoOrEpoch) — Asia/Seoul Intl.DateTimeFormat + │ └── readinessMap.ts # readiness.level → { label, message, colorClass } 매핑 + ├── layout/ + │ ├── Sidebar.svelte + │ ├── HeroStrip.svelte + │ └── TabContent.svelte + ├── widgets/ + │ ├── W1_StageStepper.svelte + │ ├── W2_ReadinessCountdown.svelte + │ ├── W3_LossCurve.svelte + │ ├── W4_EtaTimeline.svelte + │ ├── W5_GpuSparkline.svelte + │ ├── W6_LossVolatility.svelte # P4 도입, P1.5에서 placeholder + │ ├── W7_StatusBadge.svelte + │ └── W8_BacktestGallery.svelte # P5 도입, P1.5에서 locked placeholder + ├── tabs/ + │ ├── LiveTrainingTab.svelte + │ ├── ForecastWorkbenchTab.svelte # P3 도입, P1.5에서 locked placeholder + │ ├── StomDiagnosticsTab.svelte # P4 도입, Plotly dynamic import + │ ├── ArtifactsModelsTab.svelte + │ ├── HistoryRunsTab.svelte + │ ├── SystemHealthTab.svelte + │ └── SettingsTab.svelte + └── charts/ + ├── EChartsRenderer.svelte # ECharts 래퍼 (eager load) + └── PlotlyRenderer.svelte # Plotly 래퍼 (StomDiagnosticsTab 진입 시만 import) +``` + +### 4.2 컴포넌트별 인터페이스 표 + +#### 레이아웃 컴포넌트 + +| 컴포넌트 | props | state(local) | store 의존 | 자식 | +|---|---|---|---|---| +| `App.svelte` | — | — | `activeTab` | `Sidebar`, `HeroStrip`, `TabContent` | +| `Sidebar.svelte` | — | — | `activeTab`, `refreshInterval`, `lastUpdated` | — | +| `HeroStrip.svelte` | — | — | `trainingStatus`, `activeTab` | `W7_StatusBadge`, `W1_StageStepper`, `W2_ReadinessCountdown` | +| `TabContent.svelte` | — | — | `activeTab` | 각 Tab 컴포넌트 | + +#### 탭 컴포넌트 + +| 컴포넌트 | props | state(local) | store 의존 | 자식 | +|---|---|---|---|---| +| `LiveTrainingTab.svelte` | — | — | `trainingStatus`, `trainingHistory`, `gpuStatus` | `W3_LossCurve`, `W4_EtaTimeline`, `W5_GpuSparkline` | +| `ForecastWorkbenchTab.svelte` | — | `locked: boolean` | `trainingStatus` (`predictor_complete` 읽기) | locked placeholder | +| `StomDiagnosticsTab.svelte` | — | `plotlyLoaded: boolean` | `activeTab` (진입 감지 → dynamic import) | `PlotlyRenderer` | +| `ArtifactsModelsTab.svelte` | — | — | `trainingArtifacts` | — | +| `HistoryRunsTab.svelte` | — | — | `trainingHistory` | — | +| `SystemHealthTab.svelte` | — | — | `gpuStatus` | `W5_GpuSparkline` (전체 크기) | +| `SettingsTab.svelte` | — | `draft: number` | `refreshInterval` | — | + +#### 위젯 컴포넌트 + +| 컴포넌트 | props | state(local) | store 의존 | 자식 | +|---|---|---|---|---| +| `W1_StageStepper.svelte` | — | — | `trainingStatus` (`stages[]`) | — | +| `W2_ReadinessCountdown.svelte` | — | `tick: number` (매초 Svelte tick) | `trainingStatus` (`readiness`, `eta_seconds`) | — | +| `W3_LossCurve.svelte` | `height?: number` | `echarts: ECharts instance` | `trainingHistory` (ring buffer) | `EChartsRenderer` | +| `W4_EtaTimeline.svelte` | — | `nowKst: string` (매초 갱신) | `trainingStatus` (`eta_seconds`, `updated_at`, `overall_percent`) | — | +| `W5_GpuSparkline.svelte` | `compact?: boolean` | `charts: ECharts[]` | `gpuStatus` (ring buffer 720pt) | `EChartsRenderer` (3개 인스턴스) | +| `W6_LossVolatility.svelte` | `window?: number` | `stats: {mean,std,cv}` | `trainingHistory` (W3 buffer 재사용) | — | +| `W7_StatusBadge.svelte` | — | — | `trainingStatus` (`readiness.level`, `readiness.label`) | — | +| `W8_BacktestGallery.svelte` | — | `locked: boolean` | `trainingStatus` (`predictor_complete`) | locked placeholder | + +#### 차트 렌더러 + +| 컴포넌트 | props | state(local) | store 의존 | 자식 | +|---|---|---|---|---| +| `EChartsRenderer.svelte` | `option: EChartsOption`, `height?: number`, `onReady?: (chart) => void` | `chart: echarts.ECharts` | — | — | +| `PlotlyRenderer.svelte` | `data: Plotly.Data[]`, `layout?: Partial`, `height?: number` | `plotly: typeof Plotly`, `loaded: boolean` | — | — | + +--- + +## 5. 탭 → 위젯 매핑 (단계별) + +| 탭 | P1 위젯 (라이브) | P2 추가 | P3 추가 | P4 추가 | P5 추가 | +|---|---|---|---|---|---| +| **Live Training** | W7 배지, W1 스테퍼, W2 신호등/ETA, W3 단순 라인 차트, W4 ETA 타임라인, W5 GPU 스파크라인 | W3 dataZoom/brush 정식, Cmd+K 팔레트 | — | W6 손실 변동 통계 | micro-interaction 폴리시 | +| **Forecast Workbench** | 잠금 placeholder | — | lookback/pred_len/temperature/top_p 슬라이더, `/api/predict` 호출, 예측 차트, GPU device disable(학습중) | — | — | +| **STOM Diagnostics** | 잠금 placeholder | — | — | diagnostics heatmap(Plotly), top-k 테이블, 추천 테이블 | — | +| **Artifacts & Models** | 아티팩트 목록 (read-only) | interactive 아티팩트 갤러리 | — | — | W8 백테스트 갤러리 placeholder → predictor 완료 시 unlock | +| **History & Runs** | 기록 텍스트 목록 | 정식 runs 테이블 | — | — | — | +| **System Health** | (Live Training의 W5 재사용) | W5 전체 크기 GPU 상세 차트 | — | — | — | +| **Settings** | 폴링 간격 표시 | refresh interval 토글 (2~3600s) | — | dark/light 모드 토글 예약 | light 모드 토글 실 구현 | + +--- + +## 6. State / Data Flow + +### 6.1 Svelte writable stores (`src/lib/stores.ts`) + +| store | 타입 | 데이터 소스 | 폴링 주기 | 버퍼 | +|---|---|---|---|---| +| `trainingStatus` | `TrainingStatusResponse \| null` | `/api/training/status` | `refreshInterval` (기본 5s) | 없음 (최신 단일 객체) | +| `trainingHistory` | `RingBuffer` | `/api/training/history?limit=200` (기본), dataZoom 시 `limit=1000` lazy fetch | `refreshInterval` | 최대 1,000pt, 초과 시 oldest 폐기 | +| `trainingArtifacts` | `string[]` | `/api/training/artifacts` | `refreshInterval` | 없음 | +| `gpuStatus` | `RingBuffer` | `/api/training/gpu` | `refreshInterval` | 최대 720pt (1시간 = 5s × 720) | +| `refreshInterval` | `number` (ms) | 사용자 설정 (SettingsTab) | — | 기본값 5000 | +| `activeTab` | `string` | 사용자 클릭 / URL fragment | — | — | +| `lastUpdated` | `string \| null` | 가장 최근 폴링 완료 시각 (KST) | — | — | + +### 6.2 폴링 시퀀스 (`src/lib/polling.ts`) + +```ts +// 사양: 모든 store에 동일 패턴 적용 +function startPolling( + store: Writable, + fetcher: () => Promise, + intervalMs: number, +): () => void; // 반환값: cleanup 함수 (onDestroy에서 호출) +``` + +- 4개 fetch (status, history, artifacts, gpu)가 **병렬**로 동시에 폴링된다. +- 학습에 영향을 주지 않는 read-only API이므로 병렬 호출 허용. +- `fetch` 실패 시 store는 이전 값을 유지하고 콘솔 경고만 출력 (학습 중단 아님). +- `refreshInterval` store 변경 시 현재 인터벌을 clear하고 새 주기로 재시작. + +### 6.3 데이터 흐름 다이어그램 (텍스트) + +``` +[Flask API] + /api/training/status ──→ polling.ts ──→ trainingStatus ──→ W7, W1, W2, W4, HeroStrip + /api/training/history ──→ polling.ts ──→ trainingHistory ──→ W3, W6, HistoryRunsTab + /api/training/artifacts──→ polling.ts ──→ trainingArtifacts──→ ArtifactsModelsTab + /api/training/gpu ──→ polling.ts ──→ gpuStatus ──→ W5, SystemHealthTab + +[사용자 이벤트] + 탭 클릭 ──→ activeTab ──→ TabContent (tab 전환) + 간격 변경 ──→ refreshInterval ──→ polling.ts (재시작) + dataZoom 이벤트 ──→ W3_LossCurve (lazy fetch limit=1000) ──→ trainingHistory + /v2#stom 진입 ──→ StomDiagnosticsTab ──→ import('plotly.js-dist-min') → PlotlyRenderer +``` + +--- + +## 7. ECharts ↔ Plotly 공존 규칙 + +### 7.1 로딩 전략 + +| 라이브러리 | 로딩 시점 | 방법 | +|---|---|---| +| **ECharts 5.x** | 앱 초기화 시 eager load | `package.json` devDependency → Vite 번들 | +| **Plotly 2.35.2** | `/v2#stom` 탭 진입 시만 | `import('plotly.js-dist-min').then(P => { plotly = P.default; plotlyLoaded = true; })` | + +### 7.2 테마 동기화 규칙 + +1. **ECharts**: `W3_LossCurve.svelte`와 `W5_GpuSparkline.svelte` mount 직전 `getEChartsTheme()`를 호출해 `baseOption`에 spread. +2. **Plotly**: `PlotlyRenderer.svelte`에서 dynamic import 완료 후 `getPlotlyLayout()`을 `Plotly.newPlot(el, data, layout)` 호출 시 merge. +3. 두 라이브러리 모두 `theme.colors.card`를 배경, `theme.colors.border`를 그리드, `theme.colors.textDim`을 축 레이블, `theme.typography.fontBase`를 폰트로 사용 → **한 화면에 동시에 표시해도 색상/폰트 단절 없음**. + +### 7.3 검증 절차 (P1.5 PR 시) +1. `/v2#stom` 탭을 열고 ECharts 차트(W5 GPU 스파크라인이 SystemHealth에서 표시)와 Plotly diagnostics heatmap이 동시에 보이도록 레이아웃 조정. +2. 스크린샷 수평 비교: 배경색 `#111827`, 그리드 `#243244`, 폰트 색 `#64748b`가 양쪽에서 동일함을 확인. +3. DevTools에서 CSS 변수 `--color-card`, `--color-border` 값이 두 차트 컨테이너에 모두 적용됨을 확인. + +--- + +## 8. P1 → P1.5 마이그레이션 안전망 + +### 8.1 이중 서빙 모드 + +| 환경 변수 | 서빙 대상 | 동작 | +|---|---|---| +| `KRONOS_V2_DIST=0` (기본) | `templates/v2_shell.html` (Jinja SSR) | P1 Alpine+ECharts CDN 그대로 | +| `KRONOS_V2_DIST=1` | `webui/static/v2/dist/index.html` (Vite 빌드 산출물) | P1.5 Svelte SPA | + +전환은 환경변수 한 줄로 즉시 이루어지며 Flask 재시작만 필요하다 (`v2/__init__.py:224` 분기 로직). + +### 8.2 SSR 마커 보존 원칙 + +P1에서 `templates/v2_shell.html`의 SSR 마커: +```html + + +``` + +P1.5 dist 모드에서 `webui/v2_src/index.html`에 **동일 내용을 정적으로 삽입**한다. Vite 빌드는 `index.html`을 그대로 복사하므로 `dist/index.html`에도 마커가 살아있다. + +검증: +```powershell +# P1 모드 (KRONOS_V2_DIST=0) +Invoke-WebRequest -Uri 'http://127.0.0.1:5070/v2' | Select-Object -ExpandProperty Content | + Select-String 'kronos-v2-shell' + +# P1.5 모드 (KRONOS_V2_DIST=1) +Invoke-WebRequest -Uri 'http://127.0.0.1:5070/v2' | Select-Object -ExpandProperty Content | + Select-String 'kronos-v2-shell' +``` +두 모드 모두 `kronos-v2-shell` 문자열이 포함되어야 하며, 기존 `tests/test_v2_smoke.py`가 dist 모드에서도 그대로 통과해야 한다. + +### 8.3 색상 단절 방지 절차 + +1. P1.5 빌드 후 `KRONOS_V2_DIST=0`(P1)과 `KRONOS_V2_DIST=1`(P1.5)을 번갈아 실행. +2. 동일 화면을 나란히 스크린샷: body 배경(`#0f172a`), 카드(`#111827`), 보더(`#243244`), 액센트(`#38bdf8`) 일치 확인. +3. `applyTheme()` 호출이 `main.ts`에서 App mount 이전에 실행되는지 확인 (FOUC 방지). + +--- + +## 9. P1.5에서 추가될 Micro-interaction + +### 9.1 신규 인터랙션 (Svelte 전용) + +| 인터랙션 | 대상 | 구현 방식 | 타이밍 | +|---|---|---|---| +| **Hero Strip 숫자 카운트업** | W4 진행률, W2 ETA 초 단위 | Svelte `tweened` store + `spring` | 초기 mount 시 0 → 실제값으로 400ms | +| **탭 전환 slide/fade** | TabContent 내 콘텐츠 | Svelte `transition:fade` + `in:fly={{x: -16}}` | 200ms ease-out | +| **카드 hover lift** | `.card` 전체 | CSS `transition: transform 150ms, box-shadow 150ms` | hover 즉시 | +| **Readiness 신호등 pulse** | `.tl-dot.lit-*` | CSS `@keyframes pulse` (box-shadow 반복) | 신호 활성 시 무한 반복, 2s 주기 | +| **Loss 차트 dataZoom+brush** | W3_LossCurve | ECharts `dataZoom`, `brush` 컴포넌트 + lazy fetch | 사용자 드래그 시 | +| **KST 카운트다운 매초 갱신** | W2_ReadinessCountdown, W4 현재 시각 | Svelte `onMount` 내 `setInterval(1000)` + `onDestroy` cleanup | 1초 주기 | +| **Nav 활성 인디케이터 슬라이드** | `.v2-nav a.active` border-left | CSS transition + Svelte class binding | 탭 전환 150ms | +| **빈 상태 일러스트 fade-in** | locked-widget, 아티팩트 없음 | Svelte `transition:fade={{delay:100}}` | 탭 진입 시 | + +### 9.2 P1 hand-rolled CSS에서도 이미 동작하는 것 + +| 인터랙션 | P1 구현 위치 | +|---|---| +| nav hover 배경 | `v2-nav a:hover { transition: background .15s, color .15s, border-color .15s }` | +| ETA progress bar fill | `.timeline-bar-fill { transition: width .5s ease }` | +| 신호등 transition | `.tl-dot { transition: background .3s, box-shadow .3s }` | +| progress bar fill | `.progress-fill { transition: width .4s ease }` | +| ECharts update 애니 | ECharts 기본 제공 (setOption 시 자동) | + +P1.5에서는 P1의 CSS transition을 **제거하지 않고** Svelte 컴포넌트 내 class에서 동일 변수로 참조해 연속성을 유지한다. + +--- + +## 10. 검증 체크리스트 (P1.5 PR 시 디자인 측면) + +### 10.1 차트 시각 일관성 + +- [ ] `/v2#stom` 탭에서 ECharts 차트와 Plotly 차트가 동시에 표시될 때 배경색(`--color-card`), 그리드 색(`--color-border`), 축 레이블 색(`--color-text-dim`), 폰트(`--font-family-base`)가 육안으로 동일함 +- [ ] W3 Loss Curve와 W5 GPU Sparkline의 주 색상이 각각 `#38bdf8` (sky-400)과 `#22c55e` (green-500)로 일치 +- [ ] Plotly heatmap의 `paper_bgcolor`, `plot_bgcolor`가 `#111827`임을 DevTools에서 확인 + +### 10.2 P1 ↔ P1.5 색상 단절 없음 + +- [ ] `KRONOS_V2_DIST=0` (P1 SSR)과 `KRONOS_V2_DIST=1` (P1.5 dist)를 번갈아 불러도 body 배경, 카드 배경, 액센트 색상이 픽셀 수준에서 동일 +- [ ] P1.5 빌드 후 `applyTheme()` 호출로 `--chart-color-primary` 등 P1 변수가 동일 값으로 유지됨을 `getComputedStyle(document.documentElement)` 확인 +- [ ] FOUC(Flash of Unstyled Content) 없음: `main.ts`에서 `applyTheme()`가 첫 mount 이전에 동기적으로 실행됨 + +### 10.3 접근성 (Accessibility) + +- [ ] Lighthouse a11y 점수 ≥ 90 (측정 환경: `FLASK_ENV=production` + waitress + `--chrome-flags='--headless=new --no-sandbox'`) +- [ ] 텍스트 대비율 4.5:1 이상: `#e2e8f0`(텍스트) on `#111827`(카드) ≥ 10:1 ✓, `#94a3b8`(muted) on `#111827` ≥ 4.6:1 ✓ +- [ ] 주의: `#475569`(faint) on `#111827` 는 대비율 약 4.0:1로 **AA 위반 경계** — 이 색은 섹션 제목 등 비필수 장식 텍스트에만 사용하고 본문 정보에는 사용 금지 +- [ ] 모든 인터랙티브 요소(탭, 설정 슬라이더, Cmd+K)에 `aria-label` 또는 가시적 레이블 존재 +- [ ] focus ring이 `outline: 2px solid #38bdf8` 또는 동등 이상으로 가시적 +- [ ] 키보드 탭 순서가 좌측 NAV → HeroStrip → TabContent 순으로 논리적 + +### 10.4 반응형 레이아웃 + +| 해상도 | 기대 동작 | +|---|---| +| 모바일 375×812 | `.main-grid` 단열, hero-strip 세로 스택, 사이드바 숨김(또는 햄버거 메뉴), 카드 전폭 | +| 태블릿 768×1024 | 사이드바 축소(icon-only) 또는 오버레이 | +| 데스크탑 1280×900 | 2열 그리드, 240px 사이드바, P1 레이아웃과 동일 비율 | +| 데스크탑 1440×900 | 기준 레이아웃 (P1과 동일) | +| 4K 3840×2160 | 최대 너비 클램프(`max-width: 1920px, margin: auto`) 또는 그리드 칼럼 추가 검토 | + +- [ ] 900px 이하에서 `.main-grid`가 단열로 전환 (P1 `@media (max-width: 900px)` 규칙 계승) +- [ ] 375px에서 hero-strip flex-direction이 column으로 전환 (P1 규칙 계승) +- [ ] 4K에서 카드가 화면 전체로 늘어나지 않도록 max-width 제한 + +### 10.5 다크/라이트 모드 (P5 예약) + +- [ ] P1.5 시점: 다크 모드 전용, 라이트 모드 없음 (P5에서 추가) +- [ ] P5에서 라이트 모드 추가 시 `applyTheme('light')`가 호출되면 CSS 변수를 교체하고 ECharts/Plotly 양쪽에 `setOption` 재호출 → 즉시 업데이트 확인 + +--- + +상태: **P1.5 사전 디자인 스펙 (predictor 완료 후 PR로 구현)** diff --git a/docs/p1_5_nogo_reason.md b/docs/p1_5_nogo_reason.md new file mode 100644 index 000000000..a35dc899c --- /dev/null +++ b/docs/p1_5_nogo_reason.md @@ -0,0 +1,57 @@ +# P1.5 GO/NOGO 예외 처리 기록 + +**작성일**: 2026-05-15 KST +**상태**: 학습 실패 종료 후 P1.5 진행 결정 + +--- + +## 배경 + +`docs/kronos_dashboard_p1_5_build_checklist.md` §1의 GO/NOGO 7개 조건은 **predictor 학습이 정상 완료된 경우**를 가정한다. 그러나 본 학습 run (`stom_1s_grid_pred60_2025_full_small`)은 tokenizer 약 75% 시점에서 validation OOM으로 중단됐다 (commit `7742cb8`). 따라서 일부 조건은 NOGO 상태다. + +## GO/NOGO 실측 상태 + +| # | 조건 | 기대값 | 실측값 | 판정 | 영향 | +|---|---|---|---|---|---| +| 1 | predictor 학습 완료 | `train_stage=predictor, status=completed` | tokenizer ~75%, stopped | NOGO | P3 Forecast Workbench 모델 검증 불가 | +| 2 | readiness.predictor_complete | `true` | `false` (waiting) | NOGO | UI에서 readiness gate `waiting` 유지 | +| 3 | checkpoint 파일 ≥ 1 | `≥ 1` | `0` | NOGO | predictor 미시작 | +| 4 | model_weight 파일 ≥ 1 | `≥ 1` | `0` | NOGO | predictor 미시작 | +| 5 | 학습 프로세스 종료 | 0개 | 0개 | **GO** | OOM crash로 프로세스 자연 종료 | +| 6 | GPU VRAM 해제 | `< 5%` | (Flask 다운 상태라 미측정, 그러나 학습 종료로 해방) | **사실상 GO** | 디스크 경합 0 | +| 7 | 디스크 I/O 안정 | `< 5%` | (Flask 다운 상태라 미측정, 학습 프로세스 0개) | **사실상 GO** | npm install 안전 | + +## 의사결정 + +**P1.5 진행 허가** — 다음 근거로 GO/NOGO 체크리스트의 **실질 목적(B-1: 학습 자원 보호)**이 충족됐다고 판단: + +1. **B-1 (학습 디스크 fsync 보호)**: 학습 프로세스가 종료되어 진행 중인 학습이 없다. npm install이 디스크 경합을 일으킬 학습이 존재하지 않으므로 B-1의 실질적 위험이 0이다. +2. **재학습 가능성**: 향후 OOM 원인을 수정 후 재학습할 수 있다. 그 시점 전에 P1.5 빌드 산출물(`webui/static/v2/dist/`)을 commit해 두면, **재학습 중에는 npm 명령 0건**으로 P1.5 dist 모드 운영 가능 (KRONOS_V2_DIST=1 toggle만 사용). +3. **predictor 의존 기능 보류**: P3 Forecast Workbench의 SEED=42 결정성 검증은 본 PR 범위 밖. P3는 별도 PR로 predictor 성공 학습 이후 진행. + +## 본 PR 범위 + +- ✅ Vite + Svelte + TypeScript + Tailwind 빌드 파이프라인 구축 +- ✅ Svelte 컴포넌트 18개 작성 (design_spec §4 트리) +- ✅ theme.ts 디자인 토큰 owner 파일 +- ✅ ECharts eager + Plotly dynamic import 구조 +- ✅ Vite 빌드 산출물 commit (`webui/static/v2/dist/`) +- ✅ KRONOS_V2_DIST=1 toggle 검증 +- ✅ SSR meta marker 보존 검증 (B-2) +- ✅ pytest 갱신 + +## 본 PR 범위 외 (별도 PR) + +- P3 Forecast Workbench `/api/predict` 실제 검증 (predictor 학습 성공 후) +- P5 Lighthouse a11y/perf 측정 (P2~P4 완료 후) +- P6 Cutover (`KRONOS_V2_ENABLED=1`, v1 archive) + +## Rollback 안전망 + +- `KRONOS_V2_DIST=0` (기본): P1 SSR Jinja shell이 그대로 서빙됨 +- `KRONOS_V2_DIST=1`: P1.5 dist 모드 활성화 +- 두 모드 모두 동일한 `kronos-v2-shell` SSR meta marker 노출 → grep 검증 동일 통과 + +--- + +**결론**: 학습 실패는 P1.5 진행을 막지 않는다. 오히려 학습이 진행 중이지 않은 지금이 npm install 가장 안전한 윈도다. 본 PR로 P1.5 인프라를 확정해 재학습 시점에도 안전 운영 보장. diff --git a/docs/reference/stom_ai_agent/README.md b/docs/reference/stom_ai_agent/README.md new file mode 100644 index 000000000..32ca49afc --- /dev/null +++ b/docs/reference/stom_ai_agent/README.md @@ -0,0 +1,31 @@ +# STOM ai_agent 조건식 레퍼런스 (외부 프로그램 복사본) + +이 폴더는 **원본 STOM 자동매매/조건식 생성 프로그램**의 문서를 이 프로젝트(Kronos RL 실험실)에서 **참고용(read-only reference)** 으로 복사한 것입니다. + +- 원본 위치: `C:\System_Trading\STOM\STOM_V.wt-dev\utility\ai_agent` +- 원본 프로그램: STOM DB(`_database/stock_tick_back.db`)를 사용하던 **매수/매도 조건식(전략) 생성기** +- 복사일: 2026-05-25 +- 목적: Kronos 강화학습 실험실에서 **조건식 기반 후보 종목 선정 + 포트폴리오 RL** 설계 시 변수 사전·전략 문법을 참고하기 위함 + +> ⚠️ 이 파일들은 외부 프로그램 산출물의 사본입니다. 여기서 수정해도 원본에는 반영되지 않으며, 원본이 갱신되면 다시 복사해야 합니다. Kronos RL은 STOM/Kronos와 독립된 실험실이지만, **변수 정의와 조건식 문법은 이 레퍼런스를 단일 진실 공급원으로 참고**합니다. + +## 파일 목록 + +| 파일 | 내용 | +|---|---| +| `rules.txt` | 조건식 생성 AI 에이전트의 작업 규칙(한글 설명, 초기계획→상세계획→구현→검토 절차) | +| `strategy.txt` | **변수 사전(정본)** — 1초스냅샷/1분봉 매수·매도 변수 전체 설명 + 복합조건 함수 + 예제 | +| `variables_reference.md` | 변수 화이트리스트(스칼라 + SetGlobalsFunc 181개 함수형 이름 + 매도전용 잔고변수) | +| `system_prompt.md` | 전략 작가 시스템 프롬프트(코드 규약, 화이트리스트, 금지 규칙 요약) | +| `examples.md` | 정규 매수/매도 전략 예제 + 실거래 자동생성 전략(WideV1/V2) | +| `forbidden.md` | 금지 규칙((a) 매수전략의 매도전용 변수 금지, (b) 안전 토큰 금지, (c) 환각 변수 금지) | +| `WideV1Final_B_20260425.py` | 실거래 자동생성 매수 조건식 예시 v1 | +| `WideV2Final_B_20260428.py` | 실거래 자동생성 매수 조건식 예시 v2 | + +## 핵심 요약 (Kronos RL 관점) + +1. **변수 풍부함**: 가격·거래대금 외에 **체결강도, 초당매수/매도수량·금액, 호가1~5·잔량1~5, 매수/매도총잔량, 회전율, 전일비, VI, 시가총액** 등 수십 개. 현재 Kronos RL은 이 중 OHLCV+거래대금(6개)만 사용 → 확장 여지 큼. +2. **조건식 = 후보 필터**: 매수전략은 `매수 = True`에서 시작해 조건을 통과 못하면 `매수 = False`로 차단. 통과한 종목만 매수 후보. → **포트폴리오 RL의 종목 universe를 좁히는 필터**로 활용 가능. +3. **매도 = 보유시간/수익률 기반 동적청산**: 매도전략은 `수익률`, `보유시간`, `최고수익률` 등 잔고 변수로 청산. → RL의 "언제 팔까(시간 조절)" 학습과 직결. + +자세한 활용 설계는 `docs/stom_rl_portfolio_design_handoff_2026-05-25.md` 참조. diff --git a/docs/reference/stom_ai_agent/WideV1Final_B_20260425.py b/docs/reference/stom_ai_agent/WideV1Final_B_20260425.py new file mode 100644 index 000000000..54bde810a --- /dev/null +++ b/docs/reference/stom_ai_agent/WideV1Final_B_20260425.py @@ -0,0 +1,35 @@ +매수 = True + +if 관심종목 != 1: + 매수 = False +elif not (0 < 현재가 <= 50000): + 매수 = False +elif not (90000 <= 시분초 <= 92800): + 매수 = False +elif not (0 < 등락율 <= 25): + 매수 = False +elif not (당일거래대금 > 100): + 매수 = False +elif 라운드피겨위5호가이내: + 매수 = False + +# WideV1RetentionCand5_20260422__cand003 - 자동 생성 필터 결합 +# 생성일: 2026-04-22 21:35:43 +if 매수: + if 66.999 <= 현재가 < 2_580: + 매수 = False + +# WideV1IterationV2_20260423__cand005 - 자동 생성 필터 결합 +# 생성일: 2026-04-23 10:35:12 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 1805.7 <= 당일거래대금 < 3654.4: + 매수 = False + +# WideV1Final_B_20260425 - 자동 생성 필터 결합 +# 생성일: 2026-04-26 07:53:10 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 등락율 > 4.83: + 매수 = False + +if 매수: + self.Buy() diff --git a/docs/reference/stom_ai_agent/WideV2Final_B_20260428.py b/docs/reference/stom_ai_agent/WideV2Final_B_20260428.py new file mode 100644 index 000000000..d9cdb0898 --- /dev/null +++ b/docs/reference/stom_ai_agent/WideV2Final_B_20260428.py @@ -0,0 +1,41 @@ +매수 = True + +if 관심종목 != 1: + 매수 = False +elif not (0 < 현재가 <= 50000): + 매수 = False +elif not (90000 <= 시분초 <= 92800): + 매수 = False +elif not (0 < 등락율 <= 25): + 매수 = False +elif not (당일거래대금 > 100): + 매수 = False +elif 라운드피겨위5호가이내: + 매수 = False + +# WideV1RetentionCand5_20260422__cand003 - 자동 생성 필터 결합 +# 생성일: 2026-04-22 21:35:43 +if 매수: + if 66.999 <= 현재가 < 2_580: + 매수 = False + +# WideV1IterationV2_20260423__cand005 - 자동 생성 필터 결합 +# 생성일: 2026-04-23 10:35:12 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 1805.7 <= 당일거래대금 < 3654.4: + 매수 = False + +# WideV1Final_B_20260425 - 자동 생성 필터 결합 +# 생성일: 2026-04-26 07:53:10 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 등락율 > 4.83: + 매수 = False + +# WideV2Final_B_20260428 - 자동 생성 필터 결합 +# 생성일: 2026-04-28 21:58:09 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 등락율 > 3.535: + 매수 = False + +if 매수: + self.Buy() diff --git a/docs/reference/stom_ai_agent/examples.md b/docs/reference/stom_ai_agent/examples.md new file mode 100644 index 000000000..2e31a1927 --- /dev/null +++ b/docs/reference/stom_ai_agent/examples.md @@ -0,0 +1,163 @@ +# STOM 전략 예제 모음 (v1) + +> STOM 정규 형태(canonical form)의 매수/매도 전략 예제. +> 매수전략은 `매수 = True/False`로 상태를 만들고 `if 매수: self.Buy(...)`로 끝낸다. +> 매도전략은 `매도 = True/False`로 상태를 만들고 `if 매도: self.Sell(...)`로 끝낸다. + +--- + +## 1. 매수전략 예제 (strategy.txt 정본) + +> `매수 = False`로 시작해, 통과시켜선 안 되는 조건을 `not (...)`으로 차단하는 +> 화이트리스트형 필터. 모든 조건을 통과해야 `매수`가 살아남아 `self.Buy()`가 호출된다. + +```python +if not (3 <= 등락율 <= 25): + 매수 = False +elif not (고저평균대비등락율 >= 0): + 매수 = False +elif not (당일거래대금 >= 100): + 매수 = False +elif not (체결강도 >= 100): + 매수 = False +elif not (체결강도 >= 체결강도평균(30) + 5): + 매수 = False + +if 매수: + self.Buy() +``` + +주석: `체결강도평균(30)`은 SetGlobalsFunc 화이트리스트의 구간연산 함수다 (구간틱수 30). +`등락율`, `고저평균대비등락율`, `당일거래대금`, `체결강도`는 기본 스칼라 변수다. + +--- + +## 2. 매도전략 예제 (strategy.txt 정본) + +> 매도는 청산 조건을 `or`처럼 나열한다. 하나라도 참이면 `매도 = True`가 되어 청산한다. +> `수익률`은 매도 전용 잔고 변수다. + +```python +if 등락율 > 29: + 매도 = True +elif 수익률 <= -2: + 매도 = True +elif 수익률 >= 3: + 매도 = True + +if 매도: + self.Sell() +``` + +주석: `수익률`은 매도 전용 잔고종목 변수이므로 매수전략에서는 사용 금지. + +--- + +## 3. WideV1Final_B (자동 생성 매수전략, 2026-04-25) + +> 실거래 자동 탐색으로 생성된 매수 필터. `매수 = True`로 시작해 단계적으로 차단하고, +> 자동 생성된 추가 필터를 `if 매수:` 가드 뒤에 누적 결합하는 패턴. + +```python +매수 = True + +if 관심종목 != 1: + 매수 = False +elif not (0 < 현재가 <= 50000): + 매수 = False +elif not (90000 <= 시분초 <= 92800): + 매수 = False +elif not (0 < 등락율 <= 25): + 매수 = False +elif not (당일거래대금 > 100): + 매수 = False +elif 라운드피겨위5호가이내: + 매수 = False + +# WideV1RetentionCand5_20260422__cand003 - 자동 생성 필터 결합 +# 생성일: 2026-04-22 21:35:43 +if 매수: + if 66.999 <= 현재가 < 2_580: + 매수 = False + +# WideV1IterationV2_20260423__cand005 - 자동 생성 필터 결합 +# 생성일: 2026-04-23 10:35:12 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 1805.7 <= 당일거래대금 < 3654.4: + 매수 = False + +# WideV1Final_B_20260425 - 자동 생성 필터 결합 +# 생성일: 2026-04-26 07:53:10 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 등락율 > 4.83: + 매수 = False + +if 매수: + self.Buy() +``` + +주석: `관심종목`, `현재가`, `시분초`, `등락율`, `당일거래대금`, `시가총액`, +`라운드피겨위5호가이내`는 모두 기본 스칼라 변수다. 숫자 언더스코어 리터럴(`2_580`) +사용 가능. 자동 생성 필터는 `if 매수:` 가드 안에서만 `매수`를 끈다. + +--- + +## 4. WideV2Final_B (자동 생성 매수전략, 2026-04-28) + +> WideV1Final_B에 한 단계 더 강한 필터(`등락율 > 3.535`)를 누적한 후속 버전. + +```python +매수 = True + +if 관심종목 != 1: + 매수 = False +elif not (0 < 현재가 <= 50000): + 매수 = False +elif not (90000 <= 시분초 <= 92800): + 매수 = False +elif not (0 < 등락율 <= 25): + 매수 = False +elif not (당일거래대금 > 100): + 매수 = False +elif 라운드피겨위5호가이내: + 매수 = False + +# WideV1RetentionCand5_20260422__cand003 - 자동 생성 필터 결합 +# 생성일: 2026-04-22 21:35:43 +if 매수: + if 66.999 <= 현재가 < 2_580: + 매수 = False + +# WideV1IterationV2_20260423__cand005 - 자동 생성 필터 결합 +# 생성일: 2026-04-23 10:35:12 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 1805.7 <= 당일거래대금 < 3654.4: + 매수 = False + +# WideV1Final_B_20260425 - 자동 생성 필터 결합 +# 생성일: 2026-04-26 07:53:10 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 등락율 > 4.83: + 매수 = False + +# WideV2Final_B_20260428 - 자동 생성 필터 결합 +# 생성일: 2026-04-28 21:58:09 +if 매수: + if 66.999 <= 시가총액 < 2_580 and 등락율 > 3.535: + 매수 = False + +if 매수: + self.Buy() +``` + +주석: 필터를 더하면 더 보수적으로 매수한다(차단 조건이 늘어남). 새 전략은 +기존 필터 블록을 보존한 채 `if 매수:` 블록을 아래에 한 단계 추가하는 방식으로 확장한다. + +--- + +## 형태 요약 + +- 매수: `매수 = True` 또는 `매수 = False` 시작 → 조건 분기 → `if 매수: self.Buy()` +- 매도: 청산 조건 나열 → `if 매도: self.Sell()` +- 한글 변수명만 사용. 함수형 변수는 `이름(구간틱수[, 이전틱수])` 또는 `이름N(이전틱수)`. +- `import`/`exec`/`eval`/`open`/`__`(dunder) 등 금지 토큰은 forbidden.md 참조. diff --git a/docs/reference/stom_ai_agent/forbidden.md b/docs/reference/stom_ai_agent/forbidden.md new file mode 100644 index 000000000..be72ced0c --- /dev/null +++ b/docs/reference/stom_ai_agent/forbidden.md @@ -0,0 +1,60 @@ +# STOM 전략 금지 규칙 (v1) + +> 전략 코드 생성 시 절대 위반하면 안 되는 규칙. 위반 시 전략은 무효다. + +--- + +## (a) 매수전략에서 금지되는 매도(sell) 전용 변수 + +> 아래 변수는 **잔고에 보유한 종목**에만 존재하는 컨텍스트다. 매수 시점에는 아직 +> 보유 종목이 없으므로 **매수전략에서 사용 금지**. 매도전략에서만 사용한다. + +- 수익금 +- 수익률 +- 최고수익률 +- 최저수익률 +- 매수가 +- 보유수량 +- 보유시간 +- 분할매수횟수 +- 분할매도횟수 + +또한 매도 전용 동적청산 복합조건도 잔고 변수에 의존하므로 매수전략에서 사용 금지: + +- 횡보상태장기보유 +- 변동성급증_역추세매도 +- 장기보유종목_동적익절청산 +- 거래대금비율기반_동적청산 +- 호가압력기반_동적청산 +- 이평기반_동적청산 +- 변동성기반_동적청산 +- 변동성급증기반_동적청산 + +--- + +## (b) 안전상 금지 토큰 (매수·매도 공통) + +> 전략 코드는 순수한 조건 표현식만 작성한다. 아래 토큰은 어떤 형태로도 사용 금지. + +- `import` — 모듈 임포트 금지 +- `exec` — 동적 코드 실행 금지 +- `eval` — 동적 평가 금지 +- `open` — 파일/리소스 접근 금지 +- `compile` — 코드 컴파일 금지 +- `__` (이중 밑줄, dunder) — `__class__`, `__globals__`, `__import__` 등 던더 속성 접근 금지 + +(참고: 변수명 `변동성급증_역추세매도`처럼 단일 밑줄을 포함하는 정상 변수명은 허용된다. +금지되는 것은 **연속된 이중 밑줄 `__`** 접근이다.) + +--- + +## (c) 화이트리스트 외 이름 금지 + +> 전략 코드는 다음 출처의 이름만 사용할 수 있다: +> 1. `variables_reference.md`의 "기본 스칼라 변수" (현재가, 등락율, 당일거래대금 등) +> 2. `variables_reference.md`의 "SetGlobalsFunc 화이트리스트" 181개 함수형 이름 +> 3. `매수` / `매도` 상태 변수 +> 4. (매도전략 한정) "매도 전용 잔고종목 변수" +> +> 위 목록에 없는 변수/함수 이름은 **사용 금지** (환각된 변수 금지). +> 파이썬 내장 연산자·리터럴·비교(`and`, `or`, `not`, `<=`, `>=`, 숫자 등)는 허용된다. diff --git a/docs/reference/stom_ai_agent/rules.txt b/docs/reference/stom_ai_agent/rules.txt new file mode 100644 index 000000000..8aab17601 --- /dev/null +++ b/docs/reference/stom_ai_agent/rules.txt @@ -0,0 +1,27 @@ +1. 공통 규칙 +- 모든 대답은 한글로 자세히 설명한다. +- 모든 작업은 초기계획부터 작성해서 보여준다. +- 작성한 초기계획 중 선택사항이 있으면 사용자에게 결정을 물어본다. +- 선택사항은 마우스로 쉽게 클릭해서 결정할 수 있게 반드시 선택메뉴 형태로 보여준다. +- 선택사항 목록 우측에는 추천정도와 설명을 덧붙인다. +- 초기계획을 보고 서로 상의해서 구체적인 계획으로 수정한다. +- 구체적인 계획에는 반드시 코드의 예시를 포함한다. +- 구체적인 계획작성이 완료되면 코드구현 및 추가수정제안 선택메뉴를 보여준다. +- 코드구현을 선택했을때만, 코드 또는 파일의 추가 및 수정 작업을 시작한다. +- 코드는 항상 간결하고 깔끔하게 속도우선을 고려하여 작성한다. +- 코드는 클래스 또는 함수마다 간단한 설명을 주석으로 추가한다. +- 구현이 완료되면 구체적인 계획대로 진행되었는지 자세히 검토한다. +- 구현 완료 후 계획 재확인 시 누락된 부분이 있으면 추가 구현한다. +- 누락없이 모든 계획이 구현 완료되면 오류 검토를 진행한다. +- 오류가 발견되면 오류를 자세히 검토해서 다시 코드를 수정한다. +- 최종적으로 오류검사를 통과하면 자세한 완료보고서를 제출하고 작업을 마감한다. + +2. 전략 생성 규칙 +- ./utility/ai_agent/strategy.txt 파일에서 변수설명과 예제를 자세히 읽어본다. +- 변수설명과 예제를 바탕으로 초기계획을 작성하여 제출한다. +- 초기계획 제출 이후의 과정은 공통 규칙을 준수한다. +- 데이터 형태는 1초스냅샷과 1분봉 두가지다. +- 매수전략 작성 시 반드시 매수 = True 상태임을 유념하고 전략을 작성한다. +- 매도전략 작성 시 반드시 매도 = False 상태임을 유념하고 전략을 작성한다. +- 매수전략과 매도전략을 분리하여 작성하고 하나의 파일에 기록한다. +- 생성한 전략은 ./utility/ai_agent/strategy 폴더에 한글전략명_생성일자및시간.txt파일 형태로 저장한다. \ No newline at end of file diff --git a/docs/reference/stom_ai_agent/strategy.txt b/docs/reference/stom_ai_agent/strategy.txt new file mode 100644 index 000000000..dbced3151 --- /dev/null +++ b/docs/reference/stom_ai_agent/strategy.txt @@ -0,0 +1,290 @@ +======================================================================================================================== +1초스냅샷 데이터기반 매수전략용 변수모음 +======================================================================================================================== +# 기본 변수들, 괄호없이 변수명만으로 사용한다. +# 변수명 뒤에 'N(이전틱수)'을 붙여 이전값을 조회할 수 있다 +# 예: 현재가N(1) -> 1틱전 현재가, 매수총잔량N(1) -> 1틱전 매수총잔량 +현재가, 시가, 고가, 저가, 등락율, 당일거래대금, 체결강도, 초당매수수량, 초당매도수량, 초당거래대금, 고저평균대비등락율, 저가대비고가등락율, +초당매수금액, 초당매도금액, 당일매수금액, 당일매도금액, 최고매수금액, 최고매도금액, 최고매수가격, 최고매도가격, 매도호가1~5, 매도잔량1~5, +매수호가1~5, 매수잔량1~5, 매도총잔량, 매수총잔량, 매도수5호가잔량합, 관심종목 + +# 구간연산 변수들, 괄호 안에 '변수명(구간틱수, 이전틱수)' 형태로 사용한다. +# 이전틱수 미입력 시 현재값을 리턴한다. +# 예 : 이동평균(60) -> 현재틱의 이동평균60, 이동평균(60, 1) -> 1틱전 이동평균60 +이동평균, 최고현재가, 최저현재가, 초당거래대금평균, 체결강도평균, 최고체결강도, 최저체결강도, 누적초당매수수량, 누적초당매도수량, +최고초당매수수량, 최고초당매도수량, 당일거래대금각도, 등락율각도 + +# 그외 변수들 +호가단위, 데이터길이, 시분초, 종목명, 종목코드, 매수 + +# 국내주식에만 있는 'N(이전틱수)' 호출 가능 변수들 +거래대금증감, 전일비, 회전율, 전일동시간비, 시가총액, 라운드피겨위5호가이내(boolean) + +# 국내주식에만 있는 '변수명(구간틱수, 이전틱수)' 호출 가능 변수 +전일비각도 + +# 국내주식에만 있는 그외 변수들 +VI해제시간(datetime), VI가격, VI호가단위, + +# 이전값 조회 시 틱수 입력은 1부터 데이터길이 - 1까지이다. + +평균값계산틱수 : 변수들의 평균, 최저, 최고 등의 값을 계산하는 최소 틱수 +매수 : True 상태의 변수이다. 전략 상단에 매수 = False로 시작하면 참인 조건들을 and로 묶어서 전략을 작성할 수 있다. + +현재가 : 1초마다 수신된 틱데이터의 현재가 - int +시가, 고가, 저가 : 일봉상 시고저가 - int +등락율 : 일봉상 전일종가 대비 등락율 - float +고저평균대비등락율 : 당일 고가와 저가의 평균 대비 현재가의 등락율 - float +저가대비고가등락율 : 당일 저가 대비 당일 고가의 등락율 - float + +초당매수금액 : 초당매수수량 * 현재가 - int +초당매도금액 : 초당매수수량 * 현재가 - int +당일매수금액 : 초당매수금액이 누적된 금액 - int +당일매도금액 : 초당매도금액이 누적된 금액, 당일매수금액과 당일매도금액을 합한 값은 당일거래대금 보다 적다(동시호가 해제 시 체결금액은 누락됨) - int +최고매수금액 : 가격별로 초당매수금액을 누적한 값 중 최고값 - int +최고매도금액 : 가격별로 초당매도금액을 누적한 값 중 최고값 - int +최고매수가격 : 최고매수금액이 기록된 가격 - int +최고매도가격 : 최고매도금액이 기록된 가격 - int + +이동평균 : 1초마다 기록된 현재가의 단순평균값 - float +최고현재가 : 입력한 구간틱수 동안 현재가의 최고값 - int +최저현재가 : 입력한 구간틱수 동안 현재가의 최저값 - int +초당거래대금 : 1초 동안 거래대금의 누적값(단위:백만원) - int +초당거래대금평균 : 입력한 구간틱수 동안 초당거래대금의 평균값 - float + +체결강도 : 매도수량 대비 매수수량의 비율, 최소값0, 최대값500, 매도수량이 없을 경우와 비율이 500이 넘을 경우 500 (매수수량 / 매도수량 * 100) - float +체결강도평균 : 입력한 구간틱수 동안 체결강도의 평균값 - float +최고체결강도 : 입력한 구간틱수 동안 체결강도의 최고값 - float +최저체결강도 : 입력한 구간틱수 동안 체결강도의 최저값 - float +초당매수수량 : 1초 동안 매수수량의 누적값 - int +초당매도수량 : 1초 동안 매도수량의 누적값 - int + +누적초당매수수량 : 입력한 구간틱수 동안 초당매수수량의 누적값 - int +누적초당매도수량 : 입력한 구간틱수 동안 초당매도수량의 누적값 - int +최고초당매수수량 : 입력한 구간틱수 동안 초당매수수량의 최고값 - int +최고초당매도수량 : 입력한 구간틱수 동안 초당매도수량의 최고값 - int + +당일거래대금각도 : 입력한 구간틱수 동안의 당일거래대금 차이를 높이로 하고 입력한 틱수를 밑변으로 계산한 각도 (0~90) - float +등락율각도 : 입력한 구간틱수 동안의 등락율 차이를 높이로 하고 틱수를 밑변으로 계산한 각도 (-90~90) - float +관심종목 : 거래대금순위에 있을 경우 1, 없을 경우 0 +호가단위 : 현재가의 호가단위 - int +데이터길이 : 1초에 한번 기록된 틱의 누적합, 거래량이 적거나 호가정보의 변화가 없을 경우 1초이상이 될 수 있음 - int +시분초 : 호가정보에 기록된 서버의 현재시간을 시분초 단위로 만든 숫자 (예: 93000) + +전일비 : 전일거래량 대비 당일거래량의 비율 (당일거래량 / 전일거래량 * 100) - float +회전율 : 상장주식수 대비 당일거래량의 비율 (당일거래량 / 상장주식수 * 100) - float +전일동시간비 : 1분단위로 누적 기록된 전일 동시간대의 거래량 대비 당일거래량의 비율 (당일거래량 / 전일동시간거래량 * 100) - float +전일비각도 : 입력한 구간틱수 동안의 전일비 차이를 높이로 하고 틱수를 밑변으로 계산한 각도 (0~90) - float + +거래대금증감 : 전일거래대금 보다 증가한 당일거래대금 (당일거래대금 - 전일거래대금) - int +라운드피겨위5호가이내 : 라운드피겨 가격(예: 1000)포함 상위 5호가(예: 1025)이내에 현재가가 위치하면 True 아니면 False +매도수5호가잔량합 : 매수, 매도잔량 1호가부터 5호가까지의 총합 - int +VI해제시간, VI가격, VI호가단위 : VI해제시간, 상승VI가격, 상승VI가격의 호가단위 + +======================================================================================================================== +1분봉 데이터기반 매수전략용 변수모음 +======================================================================================================================== +# 기본 변수들 - 괄호없이 변수명만으로 사용한다. +# 변수명 뒤에 'N(이전분봉수)'을 붙여 이전값을 조회할 수 있다 +# 예: 현재가N(1) - 1봉전 현재가 +현재가, 시가, 고가, 저가, 등락율, 당일거래대금, 체결강도, 분당매수수량, 분당매도수량, 분봉시가, 분봉고가, 분봉저가, 분당거래대금, +고저평균대비등락율, 저가대비고가등락율, 분당매수금액, 분당매도금액, 당일매수금액, 당일매도금액, 최고매수금액, 최고매도금액, 최고매수가격, +최고매도가격, 매도호가1~5, 매도잔량1~5, 매수호가1~5, 매수잔량1~5, 매도총잔량, 매수총잔량, 매도수5호가잔량합, 관심종목 + +# 구간연산 변수들 - 괄호 안에 '변수명(구간분봉수, 이전분봉수)' 형태로 사용한다. +# 이전분봉수 미입력 시 현재값을 호출한다. +# 예 : 이동평균(60) -> 현재봉의 이동평균60, 이동평균(60, 1) -> 직전봉의 이동평균60 +이동평균, 최고현재가, 최저현재가, 분당거래대금평균, 체결강도평균, 최고체결강도, 최저체결강도, 누적분당매수수량, 누적분당매도수량, +최고분당매수수량, 최고분당매도수량, 당일거래대금각도, 등락율각도 + +# 보조지표, 변수명 뒤에 '_N(이전봉수)'을 붙여 이전값을 조회할 수 있다. +AD, ADOSC, ADXR, APO, AROOND, AROONU, ATR, BBU, BBM, BBL, CCI, DIM, DIP, MACD, MACDS, MACDH, MFI, MOM, +OBV, PPO, ROC, RSI, SAR, STOCHSK, STOCHSD, STOCHFK, STOCHFD, WILLR + +# 그외 변수들 +호가단위, 데이터길이, 시분초, 종목명, 종목코드, 매수(True) + +# 국내주식에만 있는 N(이전분봉수) 호출 가능 변수들 +거래대금증감, 전일비, 회전율, 전일동시간비, 시가총액, 라운드피겨위5호가이내(boolean) + +# 국내주식에만 있는 '변수명(구간봉수, 이전봉수)' 호출 가능 변수 +전일비각도 + +# 국내주식에만 있는 그외 변수들 +VI해제시간(datetime), VI가격, VI호가단위, + +# 이전값 조회 시 분봉수 입력은 1부터 데이터길이 - 1까지이다. + +평균값계산틱수 : 변수들의 평균, 최저, 최고 등의 값을 계산하는 최소 분봉수 +매수 : True 상태의 변수이다. 전략 상단에 매수 = False로 시작하면 참인 조건들을 and로 묶어서 전략을 작성할 수 있다. + +현재가 : 1분봉의 종가 - int +시가, 고가, 저가 : 일봉상 시고저가 - int +등락율 : 일봉상 전일종가 대비 등락율 - float +고저평균대비등락율 : 당일 고가와 저가의 평균 대비 현재가의 등락율 - float +저가대비고가등락율 : 당일 저가 대비 당일 고가의 등락율 - float +분당매수금액 : 분당매수수량 * 현재가 - int +분당매도금액 : 분당매수수량 * 현재가 - int +당일매수금액 : 분당매수금액이 누적된 금액 - int +당일매도금액 : 분당매도금액이 누적된 금액, 당일매수금액과 당일매도금액을 합한 값은 당일거래대금 보다 적다(동시호가 해제 시 체결금액은 누락됨) - int +최고매수금액 : 가격별로 분당매수금액을 누적한 값 중 최고값 - int +최고매도금액 : 가격별로 분당매도금액을 누적한 값 중 최고값 - int +최고매수가격 : 최고매수금액이 기록된 가격 - int +최고매도가격 : 최고매도금액이 기록된 가격 - int + +이동평균 : 분당 기록된 현재가의 단순평균값 - float +최고현재가 : 입력한 구간분봉수 동안 현재가의 최고값 - int +최저현재가 : 입력한 구간분봉수 동안 현재가의 최저값 - int +분당거래대금 : 1분 동안 거래대금의 누적값(단위:백만원) - int +분당거래대금평균 : 입력한 구간분봉수 동안 분당거래대금의 평균값 - float +체결강도 : 매도수량 대비 매수수량의 비율, 최소값0, 최대값500, 매도수량이 없을 경우와 비율이 500이 넘을 경우 500 (매수수량 / 매도수량 * 100) - float +체결강도평균 : 입력한 구간분봉수 동안 체결강도의 평균값 - float +최고체결강도 : 입력한 구간분봉수 동안 체결강도의 최고값 - float +최저체결강도 : 입력한 구간분봉수 동안 체결강도의 최저값 - float +분당매수수량 : 1분 동안 매수수량의 누적값 - int +분당매도수량 : 1분 동안 매도수량의 누적값 - int +누적분당매수수량 : 입력한 구간분봉수 동안 분당매수수량의 누적값 - int +누적분당매도수량 : 입력한 구간분봉수 동안 분당매도수량의 누적값 - int +최고분당매수수량 : 입력한 구간분봉수 동안 분당매수수량의 최고값 - int +최고분당매도수량 : 입력한 구간분봉수 동안 분당매도수량의 최고값 - int + +당일거래대금각도 : 입력한 구간분봉수 동안의 당일거래대금 차이를 높이로 하고 입력한 분봉수를 밑변으로 계산한 각도 (0~90) - float +등락율각도 : 입력한 구간분봉수 동안의 등락율 차이를 높이로 하고 분봉수를 밑변으로 계산한 각도 (-90~90) - float +관심종목 : 거래대금순위에 있을 경우 1, 없을 경우 0 +호가단위 : 현재가의 호가단위 - int +데이터길이 : 1분봉의 누적 개수 - int +시분초 : 호가정보에 기록된 서버의 현재시간을 시분초 단위로 만든 숫자 (예: 93000) - int + +전일비 : 전일거래량 대비 당일거래량의 비율 (당일거래량 / 전일거래량 * 100) - float +회전율 : 상장주식수 대비 당일거래량의 비율 (당일거래량 / 상장주식수 * 100) - float +전일동시간비 : 1분단위로 누적 기록된 전일 동시간대의 거래량 대비 당일거래량의 비율 (당일거래량 / 전일동시간거래량 * 100) - float +전일비각도 : 입력한 구간분봉수 동안의 전일비 차이를 높이로 하고 분봉수를 밑변으로 계산한 각도 (0~90) - float +거래대금증감 : 전일거래대금 보다 증가한 당일거래대금 (당일거래대금 - 전일거래대금) - int +라운드피겨위5호가이내 : 라운드피겨 가격(예: 1000)포함 상위 5호가(예: 1025)이내에 현재가가 위치하면 True 아니면 False +매도수5호가잔량합 : 매수, 매도잔량 1호가부터 5호가까지의 총합 - int +VI해제시간, VI가격, VI호가단위 : VI해제시간, 상승VI가격, 상승VI가격의 호가단위 + +======================================================================================================================== +매수, 매도 공통변수모음 +======================================================================================================================== +이평지지(60, 30, 0.5, 10) : 현재가가 이평60에서 30틱(분) 동안 표쥰편차 0.5 미만인 횟수가 10회 이상 - boolean +이평돌파(60, 1) : 이평60을 1% 상승 돌파 - boolean +이평이탈(60, 1) : 이평60을 1% 하향 이탈 - boolean +시가지지(30, 0.5, 10) : 현재가가 시가에서 30틱(분) 동안 표쥰편차 0.5 미만인 횟수가 10회 이상 - boolean + +시가돌파(30, 1) : 30틱(분) 이내 당일시가 1% 상승 돌파 - boolean +시가이탈(30, 1) : 30틱(분) 이내 당일시가 1% 하향 이탈 - boolean +변동성(30) : 30틱(분) 변동성, (30, 30) 형태로 이전값 호출 - float +변동성급증(30, 2) : 변동성(30) / 변동성(30, 30) >= 2 - boolean +변동성급감(30, 0.5) : 변동성(30) / 변동성(30, 30) <= 0.5 - boolean + +구간저가대비현재가등락율(30) : 최저현재가(30) 대비 현재가 등락율 - float +구간고가대비현재가등락율(30) : 최고현재가(30) 대비 현재가 등락율 - float +거래대금평균대비비율(30) : 초당(분당)거래대금평균(30) 대비 초당(분당)거래대금의 비율, (30, 30) 형태로 이전값 호출 - float +체결강도평균대비비율(30) : 체결강도평균(30) 대비 체결강도의 비율, (30, 30) 형태로 이전값 호출 - float +구간호가총잔량비율(30) : 30틱(분) 매수총잔량합계 / (매수총잔량합계 + 매도총잔량합체), (30, 30) 형태로 이전값 호출 - float + +매수수량변동성(30) : 30틱(분) 매수수량합계 / 이전 30틱(분) 매수수량합계, (30, 30) 형태로 이전값 호출 - float +매도수량변동성(30) : 30틱(분) 매도수량합계 / 이전 30틱(분) 매도수량합계, (30, 30) 형태로 이전값 호출 - float +고가미갱신지속틱수() : 고가 미갱신 지속 틱(봉)수 - int +저가미갱신지속틱수() : 저가 미갱신 지속 틱(봉)수 - int +횡보감지(30, 0.5) : 변동성(30) <= 0.5, (30, 0.5, 30) 형태로 이전값 호출 - boolean + +연속상승(3) : 현재가 > 현재가N(1) > 현재가N(2) - boolean +가격급등(10, 1) : 최저현재가(10) 대비 현재가 등락율 >= 1 - boolean +거래대금급증(30, 3) : 초당(분당)거래대금 / 초당(분당)거래대금평균(30) >= 3 - boolean +매수수량급증(30, 3) : 30틱(분) 매수수량합계 / 이전 30틱(분) 매수수량합계 >= 3 - boolean +매도수량급증(30, 3) : 30틱(분) 매도수량합계 / 이전 30틱(분) 매도수량합계 >= 3 - boolean + +연속하락(3) : 현재가 < 현재가N(1) < 현재가N(2) - boolean +가격급락(10, 1) : 최고현재가(10) 대비 현재가 등락율 <= -1 - boolean +거래대금급감(30, 0.5) : 거래대금평균대비비율(30) <= 0.5 - boolean +매수수량급감(30, 0.5) : 30틱(분) 매수수량합계 / 이전 30틱(분) 매수수량합계 <= 0.5 - boolean +매도수량급감(30, 0.5) : 30틱(분) 매도수량합계 / 이전 30틱(분) 매도수량합계 <= 0.5 - boolean + +이평지지후이평돌파(60, 30, 0.5, 10, 1) : 이평지지(60, 30, 0.5, 10) and 이평돌파(60, 1) - boolean +체결강도급등(30, 1.1) : 체결강도 / 체결강도평균(30) >= 1.1 - boolean +호가상승압력(30, 0.7) : 30틱(분) 매수총잔량합계 / (매수총잔량합계 + 매도총잔량합체) >= 0.7 - boolean +횡보후가격급등(30, 0.5, 30, 1) : 변동성(30) <= 0.5 and 최저현재가(10) 대비 현재가 등락율 >= 1 - boolean +횡보후연속상승(30, 0.5, 3) : 변동성(30) <= 0.5 and 현재가 > 현재가N(1) > 현재가N(2) - boolean + +이평지지후이평이탈(60, 30, 0.5, 10, 1) : 이평지지(60, 30, 0.5, 10) and 이평이탈(60, 1) - boolean +체결강도급락(30, 0.9) : 체결강도 / 체결강도평균(30) <= 0.9 - boolean +호가하락압력(30, 0.3) : 30틱(분) 매수총잔량합계 / (매수총잔량합계 + 매도총잔량합체) <= 0.3 - boolean +횡보후가격급락(30, 0.5, 30, 1) : 변동성(30) <= 0.5 and 최고현재가(30) 대비 현재가 등락율 <= -1 - boolean +횡보후연속하락(30, 0.5, 3) : 변동성(30) <= 0.5 and 현재가 < 현재가N(1) < 현재가N(2) - boolean + +연속상승및가격급등(3, 10, 1) : 현재가 > 현재가N(1) > 현재가N(2) and 최저현재가(10) 대비 현재가 등락율 >= 1 - boolean +거래대금급증및구간최고가갱신(30, 3) : 초당(분당)거래대금 / 초당(분당)거래대금평균(30) >= 3 and 현재가 > 최고현재가(30, 1) - boolean +거래대금급증및가격급등(30, 2, 10, 1) : 초당(분당)거래대금 / 초당(분당)거래대금평균(30) >= 3 and 최저현재가(10) 대비 현재가 등락율 >= 1 - boolean +거래대금급증및연속상승(30, 2, 5) : 초당(분당)거래대금 / 초당(분당)거래대금평균(30) >= 3 and 현재가 > 현재가N(1) > 현재가N(2) - boolean +호가상승압력및매수수량급증(30, 0.7, 3) : 30틱(분) 매수총잔량합계 / (매수총잔량합계 + 매도총잔량합체) >= 0.7 and 30틱(분) 매수수량합계 / 이전 30틱(분) 매수수량합계 >= 3 - boolean + +연속하락및가격급락(3, 10, 1) : 현재가 < 현재가N(1) < 현재가N(2) and 최고현재가(10) 대비 현재가 등락율 <= -1 - boolean +거래대금급감후구간최저가갱신(30, 3) : 초당(분당)거래대금 / 초당(분당)거래대금평균(30) <= 1 / 3 and 현재가 < 최저현재가(30, 1) - boolean +거래대금급감및가격급락(30, 0.5, 10, 1) : 초당(분당)거래대금 / 초당(분당)거래대금평균(30) <= 1 / 3 and 최고현재가(10) 대비 현재가 등락율 <= -1\ - boolean +거래대금급감및연속하락(30, 2, 5) : 초당(분당)거래대금 / 초당(분당)거래대금평균(30) <= 1 / 3 and 현재가 < 현재가N(1) < 현재가N(2) - boolean +호가하락압력및매도수량급증(30, 0.3, 3) : 30틱(분) 매수총잔량합계 / (매수총잔량합계 + 매도총잔량합체) <= 0.3 and 30틱(분) 매도수량합계 / 이전 30틱(분) 매도수량합계 >= 3 - boolean + +체결강도급등및호가상승압력(30, 0.9, 30, 0.7) : 체결강도 / 체결강도평균(30) >= 1.1 and 30틱(분) 매수총잔량합계 / (매수총잔량합계 + 매도총잔량합체) >= 0.7 - boolean +매수수량급증및가격급등(30, 3, 10, 1) : 30틱(분) 매수수량합계 / 이전 30틱(분) 매수수량합계 >= 3 and 최저현재가(10) 대비 현재가 등락율 >= 1 - boolean +시가근접황보후시가돌파(60, 0.5, 10, 1) : 현재가가 이평60에서 30틱(분) 동안 표쥰편차 0.5 미만인 경우의 수 > 10 and 30틱(분) 이내 당일시가 1% 상승 돌파 - boolean +저가갱신후가격급등(10, 2) : 저가미갱신지속틱수() < 10 and 최저현재가(10) 대비 현재가 등락율 >= 1 - boolean +변동성급증및구간최고가갱신(30, 2) : 변동성(30) / 이전 변동성(30) >= 2 and 현재가 > 최고현재가(30, 1) - boolean + +체결강도급락및호가하락압력(30, 0.9, 30, 0.3) : 체결강도 / 체결강도평균(30) <= 0.9 and 30틱(분) 매수총잔량합계 / (매수총잔량합계 + 매도총잔량합체) <= 0.3 - boolean +매도수량급증후가격급락(30, 3, 10, 1) : 30틱(분) 매도수량합계 / 이전 30틱(분) 매도수량합계 >= 3 and 최고현재가(10) 대비 현재가 등락율 <= -1 - boolean +시가근접황보후시가이탈(60, 0.5, 10, 1) : 현재가가 이평60에서 30틱(분) 동안 표쥰편차 0.5 미만인 경우의 수 > 10 and 30틱(분) 이내 당일시가 1% 하향 이탈 - boolean +고가갱신후가격급락(30, 2) : 고가미갱신지속틱수() < 10 and 최고현재가(10) 대비 현재가 등락율 <= -1 - boolean +변동성급감및구간최저가갱신(30, 0.5) : 변동성(30) / 변동성(30, 30) <= 0.5 and 현재가 < 최저현재가(30, 1) - boolean + +호가갭발생(3) : (매도호가1 - 매수호가1) / 호가단위 >= 3, (3, 1) 형태로 이전값 호출 - boolean +횡보상태장기보유(60, 0.5, 600) : 변동성(30) <= 0.5 and 보유시간 >= 600 초(분) - boolean +변동성급증_역추세매도(30, 3, 2) : 변동성(30) >= 3 and 최고현재가(30) 대비 현재가 등락율 <= -2 - boolean +장기보유종목_동적익절청산(30, 600, 0.3, 2) : 수익률 > max(0.3, 변동성(30) * 2) and 보유시간 > max(600, 600 * 변동성(30) * 2) - boolean +거래대금비율기반_동적청산(30, 0.3, 3) : 수익률이 양수일 경우: 초당(분당)거래대금 / 초당(분당)거래대금평균(30) <= 0.3, 수익률이 음수일 경우: 초당(분당)거래대금 / 초당(분당)거래대금평균(30) >= 3 - boolean + +호가압력기반_동적청산(30, 0.8, 0.2) : 수익률이 양수일 경우: 30틱(분) 매수총잔량합계 / (매수총잔량합계 + 매도총잔량합체) <= 0.2, 수익률이 음수일 경우: 30틱(분) 매수총잔량합계 / (매수총잔량합계 + 매도총잔량합체) >= 0.8 - boolean +이평기반_동적청산(30, 60, 1, 1) : 수익률이 양수일 경우: 이평30 대비 현재가 등락율 >= 1 and 현재가 < 이평30, 수익률이 음수일 경우: 이평60 대비 현재가 등락율 <= -1 and 현재가 < 이평60 - boolean +변동성기반_동적청산(30, 3, 1.5) : 수익률이 양수일 경우: 수익률 >= 변동성(30) * 3, 수익률이 음수일 경우: 수익률 <= -(변동성(30) * 1.5) - boolean +변동성급증기반_동적청산(30, 2, 3, 1.5) : 수익률이 양수일 경우: 변동성(30) / 변동성(30, 30) >= 2 and 수익률 >= 변동성(30) * 3, 수익률이 음수일 경우: 변동성(30) / 변동성(30, 30) >= 2 and 수익률 <= 변동성(30) * 1.5 - boolean +저점기준등락율각도(10) : 저점 가격대비 현재가의 등락율과 경과한 틱(봉)수로 구한 각도, 등락율에 곱할 계수 입력 - float +고점기준등락율각도(10) : 고점 가격대비 현재가의 등락율과 경과한 틱(봉)수로 구한 각도, 등락율에 곱할 계수 입력 - float + +======================================================================================================================== +매도전략용 변수모음 +======================================================================================================================== +# 매도전략에서 사용할 변수는 기본적으로 매수변수와 동일하다. +# 추가로 잔고종목 변수들은 다음과 같다. 보유시간은 1초스냅샷은 초단위, 1분봉은 분단위이다. +# 이전값 조회 시 틱(봉)수 입력은 1부터 데이터길이 - 1까지이다. -1입력은 모든 변수의 매수시점정보를 조회한다. +수익금, 수익률, 매수가, 보유수량, 보유시간, 분할매수횟수, 분할매도횟수, 최고수익률, 최저수익률 + +======================================================================================================================== +매수전략 예제 +======================================================================================================================== +if not (3 <= 등락율 <= 25): + 매수 = False +elif not (고저평균대비등락율 >= 0): + 매수 = False +elif not (당일거래대금 >= 100): + 매수 = False +elif not (체결강도 >= 100): + 매수 = False +elif not (체결강도 >= 체결강도평균(30) + 5): + 매수 = False + +if 매수: + self.Buy() + +======================================================================================================================== +매도전략 예제 +======================================================================================================================== +if 등락율 > 29: + 매도 = True +elif 수익률 <= -2: + 매도 = True +elif 수익률 >= 3: + 매도 = True + +if 매도: + self.Sell() \ No newline at end of file diff --git a/docs/reference/stom_ai_agent/system_prompt.md b/docs/reference/stom_ai_agent/system_prompt.md new file mode 100644 index 000000000..bfcd05802 --- /dev/null +++ b/docs/reference/stom_ai_agent/system_prompt.md @@ -0,0 +1,78 @@ +# STOM 전략 작가 시스템 프롬프트 (v1) + +당신은 **STOM 전략 작가**다. STOM 자동매매 시스템에서 실행 가능한 한글 매수/매도 +전략 코드를 자유롭게 작성한다. 아래 규약과 화이트리스트를 엄격히 지킨다. + +--- + +## 역할 + +- 입력으로 주어진 매매 아이디어를 STOM 전략 코드로 변환한다. +- 매수전략과 매도전략을 분리해 작성한다. +- 사용 가능한 변수/함수만 쓰고, 금지 규칙을 절대 위반하지 않는다. + +--- + +## STOM 코드 규약 + +1. **한글 변수명**: 모든 시장 변수는 한글 이름이다 (예: `현재가`, `등락율`, `체결강도`). +2. **두 가지 데이터 형태**: + - **1초스냅샷**: `N(이전틱수)`는 틱 단위 (예: `현재가N(1)` = 1틱 전 현재가). + - **1분봉**: `N(이전분봉수)`는 분봉 단위. 보조지표(`RSI`, `MACD` 등)는 1분봉 전용. +3. **함수형 변수 호출**: + - 구간연산/조건: `이름(구간틱수[, 이전틱수])` (예: `이동평균(60)`, `이동평균(60, 1)`). + - 이전값 조회: `이름N(이전틱수)` (예: `매수총잔량N(1)`). 틱수는 1 ~ 데이터길이-1. +4. **매수전략 형태**: + - `매수 = True` 또는 `매수 = False`로 상태를 시작한다. + - 통과시킬 수 없는 조건을 `매수 = False`로 차단하거나, 참 조건을 결합한다. + - 마지막에 반드시 `if 매수: self.Buy()`로 끝낸다. +5. **매도전략 형태**: + - 청산 조건을 나열해 하나라도 참이면 `매도 = True`로 만든다. + - 마지막에 반드시 `if 매도: self.Sell()`로 끝낸다. + +--- + +## 사용 가능한 이름 (화이트리스트) + +상세 목록은 같은 폴더의 **`variables_reference.md`**를 참조한다. 요약: + +- **기본 스칼라 변수**: 현재가/시가/고가/저가/등락율, 당일거래대금, 체결강도, + 매수/매도 수량·금액, 호가1~5·잔량1~5, 관심종목, 시분초, 호가단위 등. +- **SetGlobalsFunc 화이트리스트 181개 함수형 이름**: 구간연산(`이동평균`, `변동성`, + `최고현재가` …), 단일/합성 복합조건(`가격급등`, `거래대금급증및연속상승` …), + 이전값 `N` 함수(`현재가N`, `매수총잔량N` …), 보조지표 `_N`(`RSI_N`, `MACD_N` …). +- **국내주식 전용**: 거래대금증감, 전일비, 회전율, 전일동시간비, 시가총액, + 라운드피겨위5호가이내, VI해제시간/VI가격/VI호가단위. + +> 이 181개 함수형 이름은 `trade/base_strategy.py`의 `SetGlobalsFunc`에서 AST로 추출한 +> 것이며 `variables_reference.md`가 단일 진실 공급원이다. 목록에 없는 이름은 쓰지 않는다. + +--- + +## 금지 규칙 + +상세 규칙은 같은 폴더의 **`forbidden.md`**를 참조한다. 핵심: + +- **(a)** 매도 전용 변수(수익률·최고수익률·최저수익률·보유시간·매수가·보유수량·수익금· + 분할매수횟수·분할매도횟수)와 동적청산 복합조건은 **매수전략에서 사용 금지**. +- **(b)** 안전 금지 토큰: `import`, `exec`, `eval`, `open`, `compile`, `__`(dunder) 접근. +- **(c)** 화이트리스트에 없는 이름(환각 변수) 사용 금지. 파이썬 연산자·리터럴은 허용. + +--- + +## 예제 + +정규 형태의 매수/매도 예제와 실거래 자동 생성 전략(WideV1Final_B, WideV2Final_B)은 +같은 폴더의 **`examples.md`**를 참조한다. 새 전략은 그 형태를 따른다: + +- 매수: `매수 = True/False` → 조건 분기 → `if 매수: self.Buy()` +- 매도: 청산 조건 나열 → `if 매도: self.Sell()` + +--- + +## 작성 절차 (rules.txt 준수) + +1. `variables_reference.md`의 변수설명과 `examples.md`의 예제를 먼저 숙지한다. +2. 데이터 형태(1초스냅샷/1분봉)를 확정한다. +3. 매수전략과 매도전략을 분리해 작성한다. +4. 화이트리스트·금지 규칙 위반이 없는지 검토 후 코드만 출력한다. diff --git a/docs/reference/stom_ai_agent/variables_reference.md b/docs/reference/stom_ai_agent/variables_reference.md new file mode 100644 index 000000000..9a1879d85 --- /dev/null +++ b/docs/reference/stom_ai_agent/variables_reference.md @@ -0,0 +1,156 @@ +# STOM 전략 변수 레퍼런스 (v1) + +> 이 문서는 STOM 매수/매도 전략 코드가 사용할 수 있는 변수·함수 이름을 정의한다. +> **단일 진실 공급원(single source of truth)**: 아래 "SetGlobalsFunc 화이트리스트" 섹션의 +> 함수형 이름은 `trade/base_strategy.py`의 `SetGlobalsFunc` 메서드 `dict_add_func` +> 딕셔너리 리터럴 키에서 **AST로 추출**한 것이다 (총 181개). +> `tests/unit/test_ai_dictionary.py`가 동일한 AST 추출을 재수행하여, 이 문서에 적힌 +> 함수형 이름이 화이트리스트의 부분집합인지(=환각된 이름이 없는지) 검증한다. +> +> 함수형 이름은 백틱(`` ` ``)으로 감싸 인라인 코드로 표기한다. 테스트는 이 섹션 안의 +> 인라인 코드 토큰만 파싱한다. + +--- + +## 데이터 형태 + +전략 데이터는 두 가지 형태로 들어온다. 동일한 변수명을 양쪽에서 쓰되 의미가 달라진다. + +- **1초스냅샷**: 1초마다 수신된 틱 데이터. `N(이전틱수)`는 틱 단위. +- **1분봉**: 1분봉 종가 기반. `N(이전분봉수)`는 분봉 단위. + +--- + +## 기본 스칼라 변수 (괄호 없이 변수명만 사용) + +> 출처: `utility/ai_agent/strategy.txt`. 이 이름들은 `dict_findex` 컬럼 인덱스 맵에서 +> 주입되며 `SetGlobalsFunc` 화이트리스트에는 포함되지 않는다. `변수명N(이전틱수)` 형태로 +> 이전값을 조회할 수 있다 (예: `현재가N(1)`). + +### 가격 +- 현재가 — 현재 틱의 현재가 / 1분봉 종가 (int) +- 시가, 고가, 저가 — 일봉상 시·고·저가 (int) +- 등락율 — 일봉상 전일종가 대비 등락율 (float) +- 고저평균대비등락율, 저가대비고가등락율 (float) +- 분봉시가, 분봉고가, 분봉저가 — 1분봉 전용 (int) + +### 거래량/거래대금 +- 당일거래대금 — 단위 백만원 (int) +- 초당거래대금 / 분당거래대금 — 1초/1분 누적 거래대금 (int) +- 체결강도 — 매도수량 대비 매수수량 비율 (0~500, float) +- 초당매수수량, 초당매도수량 / 분당매수수량, 분당매도수량 (int) +- 초당매수금액, 초당매도금액 / 분당매수금액, 분당매도금액 (int) +- 당일매수금액, 당일매도금액 (int) +- 최고매수금액, 최고매도금액, 최고매수가격, 최고매도가격 (int) + +### 호가 +- 매도호가1~5, 매도잔량1~5 (int) +- 매수호가1~5, 매수잔량1~5 (int) +- 매도총잔량, 매수총잔량 (int) +- 매도수5호가잔량합 — 1~5호가 잔량 총합 (int) + +### 기술지표 (1분봉 전용 보조지표, `_N(이전봉수)`로 이전값 조회) +- AD, ADOSC, ADXR, APO, AROOND, AROONU, ATR, BBU, BBM, BBL, CCI, DIM, DIP, + MACD, MACDS, MACDH, MFI, MOM, OBV, PPO, ROC, RSI, SAR, + STOCHSK, STOCHSD, STOCHFK, STOCHFD, WILLR + +### 그외 +- 호가단위, 데이터길이, 시분초, 종목명, 종목코드 (int/str) +- 관심종목 — 거래대금순위 내 1, 아니면 0 +- 매수 — 매수전략에서 True로 시작하는 불리언 상태 변수 +- 매도 — 매도전략에서 False로 시작하는 불리언 상태 변수 + +### 국내주식 전용 스칼라 +- 거래대금증감, 전일비, 회전율, 전일동시간비, 시가총액 (int/float) +- 라운드피겨위5호가이내 (boolean) +- VI해제시간 (datetime), VI가격, VI호가단위 + +--- + +## SetGlobalsFunc 화이트리스트 (함수형 이름, 181개) + +> 아래 인라인 코드 토큰이 `SetGlobalsFunc.dict_add_func` 리터럴 키 전체다. +> `N`으로 끝나는 이름은 `이름N(이전틱수)`, 구간연산/조건 이름은 `이름(구간틱수[, 이전틱수])` +> 형태로 호출한다. + +### 기본변수 이전값 조회 (N 함수) +`현재가N` `시가N` `고가N` `저가N` `등락율N` `당일거래대금N` `체결강도N` +`고저평균대비등락율N` `저가대비고가등락율N` + +### 거래대금/수량/금액 (1초스냅샷) +`초당매수금액N` `초당매도금액N` `당일매수금액N` `당일매도금액N` +`최고매수금액N` `최고매수가격N` `최고매도금액N` `최고매도가격N` +`초당매수수량N` `초당매도수량N` `초당거래대금N` +`최고초당매수수량` `최고초당매도수량` `누적초당매수수량` `누적초당매도수량` `초당거래대금평균` + +### 거래대금/수량/금액 (1분봉) +`분봉시가N` `분봉고가N` `분봉저가N` +`분당매수수량N` `분당매도수량N` `분당거래대금N` `분당매수금액N` `분당매도금액N` +`최고분봉고가` `최저분봉저가` +`최고분당매수수량` `최고분당매도수량` `누적분당매수수량` `누적분당매도수량` `분당거래대금평균` + +### 국내주식 전용 (`N` 함수) +`거래대금증감N` `전일비N` `회전율N` `전일동시간비N` `시가총액N` +`라운드피겨위5호가이내N` `VI해제시간N` `VI가격N` `VI호가단위N` `전일비각도` + +### 호가 (`N` 함수) +`매도호가5N` `매도호가4N` `매도호가3N` `매도호가2N` `매도호가1N` +`매수호가1N` `매수호가2N` `매수호가3N` `매수호가4N` `매수호가5N` +`매도잔량5N` `매도잔량4N` `매도잔량3N` `매도잔량2N` `매도잔량1N` +`매수잔량1N` `매수잔량2N` `매수잔량3N` `매수잔량4N` `매수잔량5N` +`매도총잔량N` `매수총잔량N` `매도수5호가잔량합N` `관심종목N` + +### 구간연산 (`이름(구간틱수[, 이전틱수])`) +`이동평균` `최고현재가` `최저현재가` `체결강도평균` `최고체결강도` `최저체결강도` +`등락율각도` `당일거래대금각도` `경과틱수` + +### 단일 복합조건 (boolean / float) +`이평지지` `시가지지` `변동성` +`구간저가대비현재가등락율` `구간고가대비현재가등락율` +`거래대금평균대비비율` `체결강도평균대비비율` `구간호가총잔량비율` +`매수수량변동성` `매도수량변동성` `횡보감지` +`고가미갱신지속틱수` `저가미갱신지속틱수` `고점기준등락율각도` `저점기준등락율각도` +`연속상승` `연속하락` `호가갭발생` +`변동성급증` `변동성급감` `가격급등` `가격급락` +`거래대금급증` `거래대금급감` `체결강도급등` `체결강도급락` +`호가상승압력` `호가하락압력` +`매수수량급증` `매수수량급감` `매도수량급증` `매도수량급감` +`이평돌파` `이평이탈` `시가돌파` `시가이탈` + +### 합성 복합조건 (boolean) +`이평지지후이평돌파` `이평지지후이평이탈` +`횡보후가격급등` `횡보후가격급락` `횡보후연속상승` `횡보후연속하락` +`연속상승및가격급등` `연속하락및가격급락` +`거래대금급증및연속상승` `거래대금급감및연속하락` +`호가상승압력및매수수량급증` `호가하락압력및매도수량급증` +`매수수량급증및가격급등` `매도수량급증후가격급락` +`변동성급증및구간최고가갱신` `변동성급감및구간최저가갱신` +`거래대금급증및구간최고가갱신` `거래대금급감후구간최저가갱신` +`거래대금급증및가격급등` `거래대금급감및가격급락` +`체결강도급등및호가상승압력` `체결강도급락및호가하락압력` +`시가근접황보후시가돌파` `시가근접황보후시가이탈` +`저가갱신후가격급등` `고가갱신후가격급락` + +### 매도(sell)전용 동적청산 복합조건 (boolean) +> 아래 조건들은 잔고종목 변수(수익률·보유시간 등)에 의존하므로 **매도 전략에서만** 사용한다. +`횡보상태장기보유` `변동성급증_역추세매도` `장기보유종목_동적익절청산` +`거래대금비율기반_동적청산` `호가압력기반_동적청산` `이평기반_동적청산` +`변동성기반_동적청산` `변동성급증기반_동적청산` + +### 기술지표 (`이름_N(이전봉수)`) +`AD_N` `ADOSC_N` `ADXR_N` `APO_N` `AROOND_N` `AROONU_N` `ATR_N` +`BBU_N` `BBM_N` `BBL_N` `CCI_N` `DIM_N` `DIP_N` +`MACD_N` `MACDS_N` `MACDH_N` `MFI_N` `MOM_N` `OBV_N` `PPO_N` +`ROC_N` `RSI_N` `SAR_N` +`STOCHSK_N` `STOCHSD_N` `STOCHFK_N` `STOCHFD_N` `WILLR_N` + +--- + +## 매도(sell) 전용 잔고종목 변수 + +> 출처: `strategy.txt` "매도전략용 변수모음". `SetGlobalsFunc` 화이트리스트가 아닌 +> 잔고 컨텍스트 스칼라이며 **매수 전략에서는 사용 금지** (forbidden.md 참조). +> 보유시간은 1초스냅샷=초, 1분봉=분 단위. + +- 수익금, 수익률, 매수가, 보유수량, 보유시간 +- 분할매수횟수, 분할매도횟수, 최고수익률, 최저수익률 diff --git a/docs/retrain_stom_1s_grid_pred60_2025_full_small.md b/docs/retrain_stom_1s_grid_pred60_2025_full_small.md new file mode 100644 index 000000000..41eda6080 --- /dev/null +++ b/docs/retrain_stom_1s_grid_pred60_2025_full_small.md @@ -0,0 +1,249 @@ +# STOM tokenizer 재학습 runbook (2026-05-18 작성) + +이전 실패 run (`stom_1s_grid_pred60_2025_full_small`) 의 OOM 원인을 commit `7742cb8` 의 안전 옵션으로 회피하면서 동일 dataset/quality 로 재학습한다. + +--- + +## 0. 사전 점검 (모두 ☑ 후 시작) + +- [x] **OOM 원인 파악**: train_tokenizer.py:196 rotary attention forward, step 4,701,000/4,701,721 (99.98%) 시점 +- [x] **이전 commit 의 안전 코드 확인**: `tokenizer_save_pre_validation_checkpoint=True`, `--tokenizer-val-batch-size` CLI arg 존재 +- [x] **데이터셋 무결성**: `finetune/qlib_exports/stom_1s_grid_pred60_2025/processed_datasets/{train,val,test}_data.pkl` 존재 +- [x] **GPU 가용**: RTX 4080 SUPER, 16 GiB, 현재 사용 3.3 GiB (Flask 무관) +- [x] **디스크 가용**: D:\ 528 GB free (학습 산출물 약 20 GiB 예상) +- [x] **재학습 시간 인지**: 옵션 D 성공 시 6~12시간 목표, 보수적으로 8~16시간 — 백그라운드 실행 필요 + +--- + +## 1. 실패한 run 디렉터리 archive + +이전 실패 run 의 logs/manifest 를 보존하기 위해 이름 변경: + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs +Rename-Item -Path 'stom_1s_grid_pred60_2025_full_small' -NewName 'stom_1s_grid_pred60_2025_full_small_failed_OOM_20260514' +``` + +이렇게 하면 동일 run-name 으로 재시작해도 충돌 없음. 실패 로그는 archive 디렉터리에 보존됨. + +--- + +## 2. 재학습 실행 (백그라운드 권장) + +### 2.1 핵심 PowerShell 명령 — **옵션 D 풀 최대 활용** (Threadripper 3990X + RTX 4080 SUPER + 273GB RAM) + +> **시스템 사양 실측**: AMD Threadripper 3990X (64 cores), RTX 4080 SUPER 16 GiB (free 12.4), System RAM 273 GB, PyTorch 2.9.0 + CUDA 12.8 + bf16 native (Ada Lovelace sm_89). +> **OOM 안전성**: validation batch 는 여전히 1 로 강제. train batch 4 → **64** 로 상향 (AMP bf16 으로 VRAM ~12 GiB, 안전 4 GiB). +> **예상 시간 단축**: 83h → **약 6~12h 목표 / 8~16h 보수**. 첫 epoch torch.compile max-autotune 컴파일 오버헤드 ~120s. 실제 ETA는 첫 5~10분 sps 로 재계산한다. + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos + +# 필수 안전 옵션 (validation OOM 회피) +$env:KRONOS_TOKENIZER_VAL_BATCH_SIZE = "1" + +# (선택) torch.compile 디버그 정보가 필요하면 +# $env:TORCH_LOGS = "+dynamo" + +# 재학습 시작 — 옵션 D 풀 최대 활용 +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage both ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred60_2025_full_small ` + --dataset-sample-mode full_sequential ` + --n-train-iter 18806883 ` + --n-val-iter 3925397 ` + --tokenizer-batch-size 64 ` + --tokenizer-val-batch-size 1 ` + --predictor-batch-size 16 ` + --predictor-num-workers 2 ` + --epochs 1 ` + --num-workers 12 ` + --persistent-workers ` + --prefetch-factor 6 ` + --tokenizer-amp ` + --tokenizer-amp-dtype bf16 ` + --tokenizer-compile ` + --tokenizer-compile-mode max-autotune +``` + +### 2.1a 옵션 D 검증 — 대시보드에서 즉시 확인 (W9 로그 tail 카드) +학습 시작 1분 후 `http://127.0.0.1:5070/` Live Training 탭 하단의 **W9 학습 로그 tail** 카드에서 다음 줄 자동 노출: +- `[Rank 0] BATCHSIZE (per GPU): 64` +- `[Rank 0] AMP enabled — dtype=bf16 scaler=False` (초록 강조) +- `[Rank 0] torch.compile enabled — mode=max-autotune fullgraph=False` (시안 강조) +- 이후 step/loss/sps 가 색상 분기로 표시 +- OOM/Traceback 발생 시 빨간색 자동 강조 + +### 2.1b 안전 모드 (옵션 C 실패 시 fallback) + +torch.compile 또는 AMP 가 호환되지 않아 실패하면 다음 옵션으로 회귀: + +```powershell +# 옵션 B (bf16 만) +... --tokenizer-batch-size 16 --num-workers 4 --persistent-workers --tokenizer-amp --tokenizer-amp-dtype bf16 +# torch.compile 만 제거 + +# 옵션 A (가장 보수적) +... --tokenizer-batch-size 8 --num-workers 4 +# AMP 및 compile 모두 제거 +``` + +### 2.2 백그라운드 실행 (권장 — 83시간이라 터미널 점유 불가) + +```powershell +# Windows: Start-Job 으로 백그라운드 시작 +$job = Start-Job -ScriptBlock { + Set-Location 'D:\Chanil_Park\Project\Programming\Kronos' + $env:KRONOS_TOKENIZER_VAL_BATCH_SIZE = "1" + & 'C:\Python\64\Python3119\python.exe' 'finetune\run_stom_1s_finetune.py' ` + --horizon 60 ` + --mode full ` + --train-stage both ` + --dataset-dir 'finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets' ` + --output-root 'finetune\outputs' ` + --run-name 'stom_1s_grid_pred60_2025_full_small' ` + --dataset-sample-mode full_sequential ` + --n-train-iter 18806883 ` + --n-val-iter 3925397 ` + --tokenizer-batch-size 64 ` + --tokenizer-val-batch-size 1 ` + --predictor-batch-size 16 ` + --predictor-num-workers 2 ` + --epochs 1 ` + --num-workers 12 ` + --persistent-workers ` + --prefetch-factor 6 ` + --tokenizer-amp ` + --tokenizer-amp-dtype bf16 ` + --tokenizer-compile ` + --tokenizer-compile-mode max-autotune +} +Write-Host "Job started — id=$($job.Id), name=$($job.Name)" +# 종료: Stop-Job -Id $job.Id; Remove-Job -Id $job.Id +``` + +또는 더 간단히 `nohup` 식의 별도 콘솔에서 실행 후 minimize: + +```powershell +Start-Process powershell -ArgumentList '-NoExit', '-Command', @' +cd D:\Chanil_Park\Project\Programming\Kronos +$env:KRONOS_TOKENIZER_VAL_BATCH_SIZE="1" +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py --horizon 60 --mode full --train-stage both --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets --output-root finetune\outputs --run-name stom_1s_grid_pred60_2025_full_small --dataset-sample-mode full_sequential --n-train-iter 18806883 --n-val-iter 3925397 --tokenizer-batch-size 64 --tokenizer-val-batch-size 1 --predictor-batch-size 16 --predictor-num-workers 2 --epochs 1 --num-workers 12 --persistent-workers --prefetch-factor 6 --tokenizer-amp --tokenizer-amp-dtype bf16 --tokenizer-compile --tokenizer-compile-mode max-autotune +'@ +``` + +--- + +## 3. 학습 진행 모니터링 + +### 3.1 웹 대시보드 (가장 편함) +- Flask 가 떠 있다면 `http://127.0.0.1:5070/` → 실시간 학습 탭이 자동으로 새 run 의 progress.json 을 폴링 +- 만약 안 떠 있다면: + ```powershell + cd D:\Chanil_Park\Project\Programming\Kronos + $env:KRONOS_WEBUI_PORT="5070" + $env:KRONOS_V2_DIST="1" + $env:KRONOS_WEBUI_OPEN_BROWSER="0" + C:\Python\64\Python3119\python.exe webui\run.py + ``` + +### 3.2 PowerShell 직접 모니터링 (대시보드 없이) + +```powershell +# 학습 status 1줄 요약 (반복 호출) +Invoke-RestMethod -Uri 'http://127.0.0.1:5070/api/training/status' | + Select-Object @{n='stage';e={$_.latest_stage.train_stage}}, ` + @{n='status';e={$_.status}}, ` + @{n='step';e={$_.latest_stage.step}}, ` + @{n='pct';e={$_.latest_stage.overall_percent}}, ` + @{n='sps';e={$_.latest_stage.samples_per_second}} + +# progress.json 직접 tail (Flask 무관) +Get-Content 'finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.progress.json' | ConvertFrom-Json | Format-List +``` + +### 3.3 GPU 상태 (별도 콘솔에서) + +```powershell +# 1초 간격 GPU util/VRAM/temp 표시 +while ($true) { + nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,temperature.gpu --format=csv,noheader + Start-Sleep -Seconds 5 +} +``` + +--- + +## 4. 학습 단계별 예상 일정 + +| 단계 | 예상 시간 | 누적 | +|---|---:|---:| +| tokenizer compile/warmup | ~1~5분 | ~5분 | +| tokenizer train loop (약 294k step, batch 64, train 18,806,883 samples) | ~5~10h | ~5~10h | +| tokenizer validation (batch=1, val 3,925,397 samples) | ~30~120min | ~6~12h | +| predictor train loop (batch=16, num_workers=2) | ~1~4h | ~7~16h | +| predictor validation | ~10~30min | ~7~16h | + +총 **약 6~12시간 목표 / 8~16시간 보수** 예상. + +--- + +## 5. OOM 재발 시 추가 안전망 + +이번 안전 옵션으로도 OOM 이 재발하면: + +### 5.1 1차 조치: validation 비활성화 (학습은 보존) +```powershell +# 환경변수 추가 (실험적) +$env:KRONOS_TOKENIZER_SKIP_VAL = "1" # train_tokenizer.py 가 이 변수 지원하지 않으면 무시됨 +``` + +### 5.2 2차 조치: train batch size 축소 +```powershell +# --tokenizer-batch-size 2 또는 1 로 축소 (학습 시간 2~4배 증가) +``` + +### 5.3 3차 조치: gradient checkpointing 활성화 +finetune/train_tokenizer.py 의 model 생성 직후에 `model.gradient_checkpointing_enable()` 추가 (코드 수정 필요 — 별도 PR) + +### 5.4 OOM 재발 시 산출물 +이번에는 `tokenizer_save_pre_validation_checkpoint=True` 라 train loop 종료 직후 `latest_train_model` checkpoint 가 저장됨. 그 weights 는 OOM 후에도 살아남으니 `--finetuned-tokenizer-path` 로 재사용 가능. + +--- + +## 6. 학습 시작 직전 마지막 체크 + +```powershell +# 1. 다른 Python 프로세스가 GPU 점유 중인지 확인 +nvidia-smi + +# 2. 디스크 여유 공간 (20 GiB 이상) +Get-PSDrive D + +# 3. archive 완료 확인 +ls D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs + +# 4. 안전 환경변수 확인 +echo $env:KRONOS_TOKENIZER_VAL_BATCH_SIZE # → "1" 이어야 함 +``` + +--- + +## 7. 학습 종료 후 다음 단계 + +학습이 성공적으로 끝나면 (`/api/training/status` 가 `status=completed` + `readiness.predictor_complete=true`): + +1. v2 대시보드의 **예측 워크벤치** 탭에서 새 모델로 실제 예측 검증 +2. **예측 진단 (STOM)** 탭에서 새 prediction CSV 와 진단 지표 확인 +3. **아티팩트 & 모델** 탭에서 checkpoint/weight 카운트 정상 표시 +4. P5 Lighthouse + a11y 정식 측정 (별도 PR) + +--- + +*작성: Claude (현 세션)* +*실패 run archive: `stom_1s_grid_pred60_2025_full_small_failed_OOM_20260514`* +*예상 완료: 2026-05-22 ~ 2026-05-23 KST (4~5일 후)* diff --git a/docs/run_status_option_d_2026-05-20.md b/docs/run_status_option_d_2026-05-20.md new file mode 100644 index 000000000..7724222b0 --- /dev/null +++ b/docs/run_status_option_d_2026-05-20.md @@ -0,0 +1,197 @@ +# 옵션 D 재학습 실행 상태 기록 — 2026-05-20 + +## 목적 + +STOM 1초봉 `pred60` 2025 전체 샘플을 Kronos Small 토크나이저→프리딕터 순서로 다시 학습한다. 사용자는 웹 대시보드에서 학습 진행률, GPU 상태, 로그를 직접 확인할 수 있어야 한다. + +## 이번 실행에서 확인한 사실 + +| 시간(KST) | 항목 | 결과 | +|---|---|---| +| 2026-05-20 10:12 | 이전 실패 산출물 보관 | `finetune/outputs/stom_1s_grid_pred60_2025_full_small_failed_OOM_archive_20260520_101227` 로 이동 | +| 2026-05-20 10:12 | 웹 대시보드 재시작 | `http://127.0.0.1:5070/` HTTP 200 확인 | +| 2026-05-20 10:14 | 옵션 D 학습 1차 시작 | `tokenizer.progress.json` 생성 및 `status=running` 확인 | +| 2026-05-20 10:16 | 옵션 D 학습 1차 실패 | GPU/OOM 문제가 아니라 Windows CP949 stdout 인코딩 문제로 `UnicodeEncodeError` 발생 | +| 2026-05-20 10:20 | 대시보드 직접 경로 보완 | `http://127.0.0.1:5070/training` HTTP 200 확인 | + +## 실패 원인 + +1차 재시작은 데이터 로딩까지 정상 진행되었다. + +- 학습 샘플: `18,806,883` +- 검증 샘플: `3,925,397` +- 토크나이저 batch size: `64` +- validation batch size: `1` +- train steps/epoch: `293,858` + +그러나 `train_tokenizer.py`의 진행 로그 문자열에 포함된 em dash(`—`)를 Windows CP949 stdout이 인코딩하지 못해 학습 step 0 전에 종료되었다. +따라서 이번 실패는 STOM 데이터, Qlib dataset, CUDA 연산, VRAM 부족, Kronos 모델 구조 문제가 아니다. + +## 적용한 수정 + +1. `finetune/train_tokenizer.py` + - AMP/torch.compile 진행 로그의 em dash를 ASCII hyphen으로 변경했다. +2. `finetune/run_stom_1s_finetune.py` + - 자식 학습 프로세스에 `PYTHONIOENCODING=utf-8`, `PYTHONUTF8=1` 기본값을 주입해 Windows 콘솔/파이프 인코딩 실패 가능성을 낮췄다. +3. `webui/v2/__init__.py` + - `/training`, `/dashboard` 직접 접속 경로가 v2 학습 대시보드 shell을 반환하도록 추가했다. + - 전역 catch-all은 추가하지 않았다. API/static 오류를 숨기지 않기 위해서다. +4. `tests/test_v2_blueprint_isolation.py` + - `/training`, `/dashboard`가 200 및 `kronos-v2-shell`을 반환하는 회귀 테스트를 추가했다. + +## 검증 + +```powershell +C:\Python\64\Python3119\python.exe -m py_compile finetune\train_tokenizer.py finetune\run_stom_1s_finetune.py +C:\Python\64\Python3119\python.exe -m pytest tests\test_v2_blueprint_isolation.py tests\test_v2_route.py -q +``` + +결과: `8 passed`. + +## 다음 실행 원칙 + +1. CP949 실패 run을 archive로 보관한다. +2. 같은 옵션 D 명령을 다시 시작한다. +3. 시작 후 5~10분 내 다음을 확인한다. + - `/api/training/status`가 `status=running`인지 + - `step > 0`인지 + - `samples_per_second > 0`인지 + - GPU 사용률/VRAM이 증가하는지 +4. 만약 batch 64에서 실제 CUDA OOM이 발생하면 이 기록과 분리하여 batch 32 또는 16 fallback을 진행한다. + +## 2026-05-20 10:25 재시작 결과 + +CP949 실패 run은 `finetune/outputs/stom_1s_grid_pred60_2025_full_small_failed_cp949_archive_20260520_102513` 로 보관했다. + +수정 커밋 후 같은 옵션 D 명령을 다시 시작했다. + +| 확인 항목 | 값 | +|---|---| +| launcher PID | `91084` | +| runner PID | `20784` | +| tokenizer PID | `93016` | +| dashboard URL | `http://127.0.0.1:5070/training` | +| `/training` HTTP 상태 | `200` | +| 학습 상태 | `running` | +| 현재 단계 | `tokenizer` | +| batch size | `64` | +| validation batch size | `1` | +| train samples | `18,806,883` | +| val samples | `3,925,397` | +| train steps/epoch | `293,858` | +| torch.compile | `enabled - mode=max-autotune fullgraph=False` | +| AMP | `bf16`, scaler `False` | + +10:29 기준 진행률 JSON은 아직 `step=0`이다. 이는 `torch.compile max-autotune` 첫 그래프 컴파일/워밍업 중일 가능성이 높다. 프로세스는 살아 있고 GPU 사용률도 관측되므로, 다음 점검은 `step > 0`, `samples_per_second > 0`, `loss` 생성 여부에 집중한다. + +## 2026-05-20 10:42 torch.compile / Triton 실패 + +재시작된 옵션 D 학습은 step 0에서 실패했다. 원인은 데이터/OOM이 아니라 `torch.compile` 실행 중 PyTorch Inductor가 Triton을 요구했지만 현재 Windows 워크스테이션 Python 환경에 `triton` 모듈이 없었기 때문이다. + +핵심 로그: + +```text +torch._inductor.exc.InductorError: LoweringException: ModuleNotFoundError: No module named 'triton' +target: aten.addmm.default +``` + +판단: + +- STOM 1초봉 dataset 로딩은 정상이다. +- train/val sample count 계산도 정상이다. +- batch 64 자체 OOM으로 실패한 증거는 없다. +- 실패 지점은 첫 compiled forward pass 전후다. +- 공식 Kronos fine-tuning 목적에는 `torch.compile`이 필수 조건이 아니므로, 현재 Windows 4080 SUPER 환경에서는 compile을 자동 skip하고 eager + bf16 AMP로 진행하는 것이 더 안정적이다. + +적용 조치: + +1. `train_tokenizer.py`에서 CUDA + compile 요청 상태라도 `triton` 모듈이 없으면 `torch.compile`을 건너뛰도록 보호했다. +2. 보호 메시지: `[Rank 0] torch.compile skipped - Triton is not installed; using eager mode.` +3. 기존 batch 64 / bf16 AMP / val batch 1 / full sample 조건은 유지한다. + +검증: + +```powershell +C:\Python\64\Python3119\python.exe -m py_compile finetune\train_tokenizer.py finetune\run_stom_1s_finetune.py +C:\Python\64\Python3119\python.exe -m pytest tests\test_v2_blueprint_isolation.py tests\test_v2_route.py -q +``` + +결과: `8 passed`. + +## 2026-05-20 10:52 대시보드 live elapsed 보정 + +토크나이저 학습은 현재 첫 100 step 로그가 나오기 전까지 stdout/progress 파일이 갱신되지 않는 구조다. 이 때문에 실제 프로세스가 살아 있어도 대시보드의 elapsed 값이 마지막 progress 파일 기록 시점에 멈춰 보일 수 있다. + +조치: + +- `webui/training_monitor.py`에서 `status=running`이면 `timing.started_at` 기준 live elapsed를 API 응답 시점에 다시 계산하도록 보정했다. +- `seconds_since_update` 필드를 추가해 progress 파일이 얼마나 오래 갱신되지 않았는지도 확인할 수 있게 했다. +- 학습 자체는 건드리지 않았고, 웹 대시보드 표시/모니터링 품질만 개선했다. + +검증: + +```powershell +C:\Python\64\Python3119\python.exe -m py_compile webui\training_monitor.py +C:\Python\64\Python3119\python.exe -m pytest tests\test_v2_blueprint_isolation.py tests\test_v2_route.py -q +``` + +결과: `8 passed`. + +## 2026-05-20 10:54 학습 step 진입 확인 + +Triton guard 적용 후 옵션 D 학습을 다시 시작했고, 웹 대시보드를 재기동해 `http://127.0.0.1:5070/training` 직접 접속을 확인했다. + +| 항목 | 값 | +|---|---| +| 대시보드 URL | `http://127.0.0.1:5070/training` | +| `/training` | HTTP `200` | +| `/api/training/status` | HTTP `200` | +| 상태 | `running` | +| 단계 | `tokenizer` | +| step | `900 / 293,858` | +| tokenizer 단계 진행률 | `0.3063%` | +| 전체 학습 진행률 | `0.1531%` | +| samples/sec | 약 `95.11` | +| loss | `-0.0279` | +| last line | `[Rank 0, Epoch 1/1, Step 900/293858] LR 0.000025, Loss: -0.0279` | + +해석: + +- 이제 실제 학습 step이 증가하고 있으므로 더 이상 step 0 초기화/컴파일/인코딩 실패 상태가 아니다. +- `torch.compile`은 Triton 부재로 스킵되었지만, 공식 fine-tuning 자체는 eager + bf16 AMP로 정상 진행 중이다. +- 현재 관측 속도 기준 단순 계산 시 tokenizer train 구간만 약 54~56시간 수준이다. validation 구간은 batch 1이므로 별도 시간이 추가될 수 있다. +- 다음 점검은 30~60분 간격으로 `samples/sec`, `loss`, `ETA`, GPU 사용률, checkpoint 생성 여부를 확인하는 것이다. + +## 2026-05-20 11:40:04 +09:00ST 학습 모니터링 스냅샷 + +Ralph 점검 명령을 다시 수행했고, 현재 옵션 D 전체 샘플 tokenizer 학습은 정상적으로 계속 진행 중이다. 웹 대시보드는 직접 브라우저로 다시 열었다. + +| 항목 | 값 | +|---|---| +| 대시보드 URL | http://127.0.0.1:5070/training | +| /training | HTTP 200 | +| /api/training/status | HTTP 200 | +| 상태 | $(@{generated_at=2026-05-20T02:40:04Z; latest_stage=; overall_percent=5.8021; readiness=; run_name=stom_1s_grid_pred60_2025_full_small; run_path=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small; stage_count=1; stages=System.Object[]; status=running; updated_at=2026-05-20T02:40:00Z}.status) | +| 단계 | $(@{best_val_loss=; elapsed_seconds=3314.215636; epoch=1; epochs=1; eta_seconds=25217.688689933493; horizon=60; last_line=[Rank 0, Epoch 1/1, Step 34100/293858] LR 0.000196, Loss: -0.0304; last_loss=-0.0304; last_validation_loss=; mode=full; overall_percent=5.8021; run_name=stom_1s_grid_pred60_2025_full_small; samples_per_second=659.2401153177947; seconds_since_update=4.215636; source_path=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.progress.json; stage_count=2; stage_index=1; stage_percent=11.6042; status=running; stdout_log=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.stdout.log; step=34100; total_steps=293858; train_stage=tokenizer; updated_at=2026-05-20T02:40:00Z}.train_stage) | +| step | $(@{best_val_loss=; elapsed_seconds=3314.215636; epoch=1; epochs=1; eta_seconds=25217.688689933493; horizon=60; last_line=[Rank 0, Epoch 1/1, Step 34100/293858] LR 0.000196, Loss: -0.0304; last_loss=-0.0304; last_validation_loss=; mode=full; overall_percent=5.8021; run_name=stom_1s_grid_pred60_2025_full_small; samples_per_second=659.2401153177947; seconds_since_update=4.215636; source_path=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.progress.json; stage_count=2; stage_index=1; stage_percent=11.6042; status=running; stdout_log=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.stdout.log; step=34100; total_steps=293858; train_stage=tokenizer; updated_at=2026-05-20T02:40:00Z}.step) / 293858 | +| tokenizer 단계 진행률 | $stagePercent% | +| 전체 학습 진행률 | $overallPercent% | +| samples/sec | $([math]::Round(659.240115317795,2)) | +| 최근 loss | $(@{best_val_loss=; elapsed_seconds=3314.215636; epoch=1; epochs=1; eta_seconds=25217.688689933493; horizon=60; last_line=[Rank 0, Epoch 1/1, Step 34100/293858] LR 0.000196, Loss: -0.0304; last_loss=-0.0304; last_validation_loss=; mode=full; overall_percent=5.8021; run_name=stom_1s_grid_pred60_2025_full_small; samples_per_second=659.2401153177947; seconds_since_update=4.215636; source_path=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.progress.json; stage_count=2; stage_index=1; stage_percent=11.6042; status=running; stdout_log=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.stdout.log; step=34100; total_steps=293858; train_stage=tokenizer; updated_at=2026-05-20T02:40:00Z}.last_loss) | +| 최근 로그 | $(@{best_val_loss=; elapsed_seconds=3314.215636; epoch=1; epochs=1; eta_seconds=25217.688689933493; horizon=60; last_line=[Rank 0, Epoch 1/1, Step 34100/293858] LR 0.000196, Loss: -0.0304; last_loss=-0.0304; last_validation_loss=; mode=full; overall_percent=5.8021; run_name=stom_1s_grid_pred60_2025_full_small; samples_per_second=659.2401153177947; seconds_since_update=4.215636; source_path=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.progress.json; stage_count=2; stage_index=1; stage_percent=11.6042; status=running; stdout_log=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.stdout.log; step=34100; total_steps=293858; train_stage=tokenizer; updated_at=2026-05-20T02:40:00Z}.last_line) | +| train ETA | 약 $etaHours 시간 | +| train 예상 완료(KST) | $etaKst | +| progress 갱신 지연 | $([math]::Round([double]@{best_val_loss=; elapsed_seconds=3314.215636; epoch=1; epochs=1; eta_seconds=25217.688689933493; horizon=60; last_line=[Rank 0, Epoch 1/1, Step 34100/293858] LR 0.000196, Loss: -0.0304; last_loss=-0.0304; last_validation_loss=; mode=full; overall_percent=5.8021; run_name=stom_1s_grid_pred60_2025_full_small; samples_per_second=659.2401153177947; seconds_since_update=4.215636; source_path=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.progress.json; stage_count=2; stage_index=1; stage_percent=11.6042; status=running; stdout_log=D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.stdout.log; step=34100; total_steps=293858; train_stage=tokenizer; updated_at=2026-05-20T02:40:00Z}.seconds_since_update,1)) 초 | + +GPU API 원문 요약: + +`json +{"available":true,"average_utilization_gpu_percent":93.0,"generated_at":"2026-05-20T02:40:04Z","gpus":[{"index":0,"memory_total_mib":16376.0,"memory_used_mib":6851.0,"memory_used_percent":41.84,"name":"NVIDIA GeForce RTX 4080 SUPER","power_draw_available":true,"power_draw_watts":191.44,"power_limit_watts":320.0,"temperature_c":67.0,"utilization_gpu_percent":93.0}],"power_draw_available":true,"total_memory_total_mib":16376.0,"total_memory_used_mib":6851.0,"total_memory_used_percent":41.84,"total_power_draw_watts":191.44,"total_power_limit_watts":320.0} +` + +해석: + +- 직전 기록의 step 1,600에서 이번 점검 기준 step 34100까지 증가했다. +- samples/sec가 $([math]::Round(659.240115317795,2))로 안정적으로 관측되어 실제 데이터 학습이 진행 중이다. +- 현재 진행률은 tokenizer 기준 $stagePercent%, 전체 both-stage 기준 $overallPercent%다. +- 다음 점검은 tokenizer 20~30% 구간 또는 30~60분 뒤에 수행해 속도 유지, loss 이상치, GPU 사용률, ETA 변화를 기록한다. diff --git a/docs/session-wrap-followups-2026-05-18.md b/docs/session-wrap-followups-2026-05-18.md new file mode 100644 index 000000000..9bb1c1c98 --- /dev/null +++ b/docs/session-wrap-followups-2026-05-18.md @@ -0,0 +1,105 @@ +# Session Wrap Followups — 2026-05-18 + +본 세션(디자인 시스템 v2 통합 + 7 탭 본격 구현 + P3/P4/P6 cutover)의 후속 작업 목록. 다음 세션 시작 시 참조용. + +--- + +## 🔴 우선순위 HIGH — 학습 의존 + 정식 게이트 + +### 1. 재학습 (validation OOM 수정 후 finetune 재실행) +- **사유**: predictor 단계 진입 전제 — P3 Forecast 실 검증, STOM 결과 채움, readiness gate `ready` 전환에 필수 +- **블로커**: validation batch size 가 GPU VRAM 한계 초과 — finetune 코드 검토 필요 +- **권장 조치**: + - `finetune/configs/*.yaml` 의 validation batch size 축소 (예: 32 → 8) + - 또는 `gradient_checkpointing=True` 추가 + - 또는 mixed precision validation 활성화 +- **영역**: finetune 코드 (디자인/v2 와 무관) +- **검증**: tokenizer 100% 진입 + predictor stage 자동 전환 + checkpoint 1개 이상 생성 + +### 2. P5 Lighthouse + a11y 정식 quality gate +- **사유**: plan §4 P5 정식 게이트 — execution complete 표시를 위한 마지막 phase +- **체크리스트**: + - [ ] `lighthouse http://127.0.0.1:5070/ --output=json --output-path=docs/lighthouse_v2.json --chrome-flags='--headless=new'` + - [ ] a11y 점수 ≥ 90 (WCAG AA 색상 대비 4.5:1, focus ring, aria-label) + - [ ] perf 점수 ≥ 80 (FLASK_ENV=production + waitress + gzip 환경) + - [ ] code-reviewer 결과를 `docs/kronos_dashboard_overhaul_p5_review.md` 별도 저장 (자기 승인 방지) + - [ ] 키보드 네비게이션 검증 (Tab/Enter 로 모든 탭/슬라이더 접근) +- **권장 환경**: FLASK_ENV=production + waitress 또는 gunicorn + gzip 미들웨어 + +--- + +## 🟠 우선순위 MEDIUM — 응답 스키마 의존 + +### 3. Forecast Candlestick 차트 전환 +- **현재**: ECharts 라인 차트 (입력/예측/실측) +- **목표**: OHLC 캔들 차트 + 예측 영역 음영 표시 +- **블로커**: `/api/predict` 응답이 OHLC 구조 (`open, high, low, close`) 인지 검증 필요 +- **위치**: `webui/v2_src/src/tabs/ForecastWorkbenchTab.svelte` 의 `chartOption` derived +- **참고**: ECharts `candlestick` series type 사용 + +### 4. STOM Plotly heatmap dynamic import +- **현재**: diagnostics 응답을 raw JSON pre 로 표시 +- **목표**: Plotly heatmap 으로 시각화 +- **블로커**: `/api/stom/diagnostics` 의 heatmap 데이터 구조 확정 필요 +- **위치**: `webui/v2_src/src/tabs/StomDiagnosticsTab.svelte` +- **구현 방식**: `const Plotly = await import('plotly.js-dist-min')` dynamic import — STOM 탭 진입 시에만 로드 (P1.5 design_spec §7) +- **테마 연동**: `kronos:theme` 이벤트 핸들러로 light/dark 색상 자동 갱신 + +### 5. STOM top-k 추천 카드 +- **현재**: 미구현 +- **목표**: `/api/stom/recommendations?date=YYYY-MM-DD` 응답을 카드 그리드로 +- **블로커**: 날짜 파라미터 필수 + 응답 스키마 검증 필요 +- **위치**: `StomDiagnosticsTab.svelte` 에 새 섹션 추가 +- **UX**: 날짜 picker (or 가장 최근 날짜 자동) + top-k 표 + +### 6. STOM backtest 상세 모달 +- **현재**: 백테스트 파일 목록만 표시 +- **목표**: 클릭 시 `/api/stom/backtest-report?file=` 응답을 모달로 +- **위치**: `StomDiagnosticsTab.svelte` 의 `selectFile` 함수에 backtest 케이스 추가 + +--- + +## 🟡 우선순위 LOW — 부가 폴리시 + +### 7. Forecast 결과 CSV 다운로드 +- **사유**: 예측 결과를 외부 분석에 활용 +- **구현**: client-side `Blob` 생성 — 백엔드 endpoint 추가 0 +- **위치**: `ForecastWorkbenchTab.svelte` 에 "내보내기" 버튼 + Blob 생성 함수 + +### 8. Settings 학습 단계 알림 watcher +- **사유**: tokenizer → predictor 전환 시 자동 알림 +- **구현**: `polling.ts` 의 `pollStatus()` 후 stage 변화 감지 → `new Notification()` +- **권한**: 이미 SettingsTab 에서 권한 요청 UI 구현됨 + +### 9. History run 별 손실 곡선 미니어처 +- **블로커**: `/api/training/runs//history` endpoint 없음 — 백엔드 추가 필요 (별도 PR) +- **대안**: 현재 진행 중 run 만 손실 곡선 표시 (이미 Live Training 탭에 있음) + +--- + +## 🟢 우선순위 OPTIONAL — 운영/정리 + +### 10. v1 페이지 6개월 archive 후 삭제 결정 +- **시점**: P6 cutover (2026-05-18) + 6개월 = 2026-11-18 이후 +- **결정 기준**: 그 시점에 v2 SPA 의 동등 기능 완비 확인 후 사용자 명시 OK +- **삭제 대상**: `webui/templates/{index,training_dashboard,stom_dashboard}.html` + `webui/app.py` 의 `@app.route('/v1/*')` 3 핸들러 + +### 11. Production 배포 환경 분리 +- **현재**: Flask `debug=True` (dev mode) +- **목표**: `FLASK_ENV=production` + waitress (Windows) 또는 gunicorn — gzip 미들웨어 추가 +- **사유**: P5 Lighthouse perf 측정 정확도 + production-equivalent 검증 + +--- + +## 다음 세션 시작 시 권장 순서 + +1. **재학습**: validation OOM 수정 후 finetune 재시작 (별도 영역) +2. **재학습 진행 중**: P5 Lighthouse 측정 시도 (학습 중 GPU 영향 없음, Chrome headless 만 사용) +3. **재학습 종료 시**: Forecast/STOM 응답 스키마 확정 → Candlestick + Plotly heatmap 도입 +4. **그 후**: 부가 폴리시 (CSV 다운로드, 알림 watcher) + production 환경 분리 + +--- + +*작성: Claude (현 세션)* +*세션 기간: 2026-05-12 ~ 2026-05-18 KST* +*총 commit 수: 약 20 (P0 ~ P6 cutover + docs + session-wrap)* diff --git a/docs/stom_1s_checkpoint_eval_report.md b/docs/stom_1s_checkpoint_eval_report.md new file mode 100644 index 000000000..5ec4e37ce --- /dev/null +++ b/docs/stom_1s_checkpoint_eval_report.md @@ -0,0 +1,144 @@ +# STOM 1초봉 checkpoint 예측/평가 보고서 + +작성일: 2026-05-08 + +## 1. 결론 + +`bd5d5ca` 단계에서 생성한 pred30/pred60 budgeted checkpoint를 사용해 test split holdout 구간에서 실제 예측 CSV를 생성하고, persistence/random baseline과 비교했다. + +핵심 판단: + +- **30초 모델**: direction accuracy 0.3704로 기존 기준 0.40보다 낮다. +- **60초 모델**: direction accuracy 0.4444로 0.40은 넘었고 persistence/random보다 높다. +- 하지만 두 모델 모두 Qlib-style Top-K net return은 음수라서, 아직 실전 추천/자동매매 신호로 쓰기에는 부족하다. +- 이번 평가는 5개 test session, 27개 window의 제한 샘플 평가다. 방향성 확인용이며 최종 성능 확정은 아니다. + +## 2. 실행 명령 + +### 2.1 30초 checkpoint 평가 + +```powershell +python finetune\evaluate_stom_1s_checkpoint.py ` + --dataset-path finetune\qlib_exports\stom_1s_grid_pred30_full\processed_datasets ` + --model-path finetune\outputs\stom_1s_grid_pred30_full_budget\finetune_predictor\checkpoints\best_model ` + --output-dir webui\stom_predictions ` + --prefix stom_1s_pred30_budget_holdout_eval ` + --lookback-window 300 ` + --predict-window 30 ` + --max-symbols 20 ` + --max-asofs 1 ` + --max-sessions 5 ` + --stride 300 ` + --batch-size 4 ` + --top-k 5 ` + --device cuda:0 +``` + +### 2.2 60초 checkpoint 평가 + +```powershell +python finetune\evaluate_stom_1s_checkpoint.py ` + --dataset-path finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets ` + --model-path finetune\outputs\stom_1s_grid_pred60_full_budget\finetune_predictor\checkpoints\best_model ` + --output-dir webui\stom_predictions ` + --prefix stom_1s_pred60_budget_holdout_eval ` + --lookback-window 300 ` + --predict-window 60 ` + --max-symbols 20 ` + --max-asofs 1 ` + --max-sessions 5 ` + --stride 300 ` + --batch-size 4 ` + --top-k 5 ` + --device cuda:0 +``` + +### 2.3 Qlib-style Top-K artifact 생성 + +```powershell +python finetune\qlib_stom_pipeline.py score-backtest ` + --prediction-csv webui\stom_predictions\stom_1s_pred30_budget_holdout_eval_kronos.csv ` + --output-dir webui\qlib_backtests ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 + +python finetune\qlib_stom_pipeline.py score-backtest ` + --prediction-csv webui\stom_predictions\stom_1s_pred60_budget_holdout_eval_kronos.csv ` + --output-dir webui\qlib_backtests ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 +``` + +## 3. 예측 CSV 산출물 + +아래 파일은 `.gitignore` 대상인 실행 산출물이다. commit에는 파일 자체가 아니라 재현 명령과 지표만 남긴다. + +```text +webui/stom_predictions/stom_1s_pred30_budget_holdout_eval_kronos.csv +webui/stom_predictions/stom_1s_pred30_budget_holdout_eval_persistence.csv +webui/stom_predictions/stom_1s_pred30_budget_holdout_eval_random.csv +webui/stom_predictions/stom_1s_pred30_budget_holdout_eval_comparison.json + +webui/stom_predictions/stom_1s_pred60_budget_holdout_eval_kronos.csv +webui/stom_predictions/stom_1s_pred60_budget_holdout_eval_persistence.csv +webui/stom_predictions/stom_1s_pred60_budget_holdout_eval_random.csv +webui/stom_predictions/stom_1s_pred60_budget_holdout_eval_comparison.json +``` + +웹 대시보드에서 확인: + +```text +python webui\run.py +http://localhost:7070/stom +``` + +## 4. direction accuracy 비교 + +| horizon | mode | windows | symbols | direction accuracy | 0.40 초과 | avg actual return | +| --- | --- | ---: | ---: | ---: | --- | ---: | +| 30초 | Kronos checkpoint | 27 | 25 | 0.3704 | 아니오 | 0.0963 | +| 30초 | persistence | 27 | 25 | 0.2222 | 아니오 | 0.0963 | +| 30초 | random | 27 | 25 | 0.1111 | 아니오 | 0.0963 | +| 60초 | Kronos checkpoint | 27 | 25 | 0.4444 | 예 | 0.1790 | +| 60초 | persistence | 27 | 25 | 0.1111 | 아니오 | 0.1790 | +| 60초 | random | 27 | 25 | 0.2963 | 아니오 | 0.1790 | + +해석: + +- 30초 모델은 기존 0.40 문제를 해결하지 못했다. +- 60초 모델은 제한 샘플에서 0.4444로 개선 신호가 있다. +- 그러나 27개 window만 평가했으므로 통계적으로 충분하지 않다. 더 많은 session/asof/symbol로 walk-forward 평가가 필요하다. + +## 5. Top-K 성과 비교 + +| horizon | mode | top-k trades | top-k direction hit | top-k avg actual return | +| --- | --- | ---: | ---: | ---: | +| 30초 | Kronos checkpoint | 24 | 0.4167 | -0.0815 | +| 30초 | persistence | 24 | 0.2083 | 0.0187 | +| 30초 | random | 24 | 0.1250 | -0.0815 | +| 60초 | Kronos checkpoint | 24 | 0.5000 | -0.0917 | +| 60초 | persistence | 24 | 0.1250 | 0.0085 | +| 60초 | random | 24 | 0.2917 | -0.0666 | + +Qlib-style cost 반영 Top-K 결과: + +| horizon | avg gross return | avg net return | cumulative return | direction hit | +| --- | ---: | ---: | ---: | ---: | +| 30초 | -0.0956 | -0.3456 | -1.7169 | 0.4167 | +| 60초 | -0.1044 | -0.3544 | -1.7605 | 0.5000 | + +해석: + +- 60초 모델은 방향 hit는 개선됐지만, Top-K로 골랐을 때 실제 수익률은 아직 음수다. +- 수수료/슬리피지 25bp를 넣으면 net return은 더 나빠진다. +- 따라서 현재 checkpoint는 **연구/시각화/추가 필터 개발 대상**이며, 실전 매수 추천 모델로 바로 사용하면 안 된다. + +## 6. 다음 개선 방향 + +1. 평가 표본 확대: `--max-sessions`, `--max-asofs`, `--max-symbols`를 늘려 최소 수백~수천 window 평가. +2. horizon 선택: 현재 제한 샘플에서는 30초보다 60초가 낫다. +3. 조건식 보완: predicted return만 쓰는 Top-K가 음수이므로 거래대금, 체결강도, 변동성, spread, 당일 상대강도 조건을 추가해야 한다. +4. 학습 강화: budgeted 20,000 sample 1 epoch보다 큰 학습 예산과 여러 seed 비교가 필요하다. +5. 대시보드 확장: 현재 CSV는 대시보드에서 실제값/예측값 차트 확인 가능하므로, 다음 단계는 평가 조건과 필터별 성과 표를 화면에 더 명확히 노출한다. diff --git a/docs/stom_1s_cost_gate_analysis_report.md b/docs/stom_1s_cost_gate_analysis_report.md new file mode 100644 index 000000000..24baf08c9 --- /dev/null +++ b/docs/stom_1s_cost_gate_analysis_report.md @@ -0,0 +1,120 @@ +# STOM 1초봉 pred60 cost sensitivity gate 자동화 보고서 + +작성일: 2026-05-09 + +## 1. 이번 단계의 목표 + +이전 단계에서 pred60 `budget_20k` checkpoint는 대형 walk-forward 기준으로 방향성 신호는 있었지만 25bp 비용 후 수익성이 부족했다. 이번 단계에서는 같은 판단을 사람이 수동으로 해석하지 않도록, 기존 filter-search/rolling-validation artifact를 입력으로 받아 **비용 민감도와 확대 학습 gate를 자동 계산하는 코드**를 추가했다. + +핵심 목적: + +```text +rolling gate를 통과하지 못하면 expand_200k 학습을 실행하지 않는다. +``` + +## 2. 추가된 기능 + +`finetune/search_stom_1s_filters.py`에 `--gate-analysis` 모드를 추가했다. + +```powershell +python finetune\search_stom_1s_filters.py ` + --gate-analysis ` + --filter-report webui\qlib_backtests\stom_1s_pred60_walkforward100x5x50_eval_kronos.filter_search.json ` + --rolling-report webui\qlib_backtests\stom_1s_pred60_walkforward100x5x50_eval_kronos_rolling100x50.rolling_filter_validation.json ` + --output-dir webui\qlib_backtests ` + --prefix stom_1s_pred60_walkforward100x5x50_eval_kronos_cost_gate ` + --total-cost-bps-grid 5,10,15,25 ` + --target-total-cost-bps 25 ` + --min-total-test-trades 100 +``` + +생성되는 산출물: + +```text +webui/qlib_backtests/stom_1s_pred60_walkforward100x5x50_eval_kronos_cost_gate.cost_gate.json +webui/qlib_backtests/stom_1s_pred60_walkforward100x5x50_eval_kronos_cost_gate.cost_gate_rolling_sensitivity.csv +``` + +이 산출물은 대시보드에서 `cost_sensitivity_gate` artifact로 인식된다. 대용량/실험 산출물이므로 git commit에는 포함하지 않는다. + +## 3. Gate 기준 + +기본 gate 기준은 다음과 같다. + +| 기준 | 기본값 | 의미 | +| --- | ---: | --- | +| `min_avg_test_net_pct` | 0.0 | rolling 평균 test net이 0 이상이어야 함 | +| `min_positive_test_fold_rate` | 0.5 | fold 절반 이상이 양수여야 함 | +| `min_improvement_net_pct` | 0.0 | baseline 대비 개선이 음수이면 안 됨 | +| `min_total_test_trades` | 100 | 너무 적은 거래 수로 통과하면 안 됨 | +| `target_total_cost_bps` | 25 | 실제 판단 비용 기준 | + +## 4. 실제 대형 artifact 적용 결과 + +| total cost | rolling avg test net | positive fold rate | total test trades | gate | +| ---: | ---: | ---: | ---: | --- | +| 5bp | +0.0234% | 0.500 | 150 | PASS | +| 10bp | -0.0266% | 0.375 | 150 | FAIL | +| 15bp | -0.0766% | 0.375 | 150 | FAIL | +| 25bp | -0.1766% | 0.250 | 150 | FAIL | + +해석: + +- 5bp처럼 매우 낮은 비용 가정에서는 gate가 통과된다. +- 그러나 실제 판단 기준인 25bp에서는 평균 test net이 -0.1766%이고 positive fold rate가 0.25라서 실패한다. +- 따라서 현재 기준의 결론은 그대로 **`expand_200k` 보류**다. + +## 5. Filter sensitivity 결과 + +동일한 best robust filter의 비용 민감도는 다음과 같다. + +| total cost | best filter avg net | +| ---: | ---: | +| 5bp | +0.0734% | +| 10bp | +0.0234% | +| 15bp | -0.0266% | +| 25bp | -0.1266% | + +best filter 자체도 10bp 이하에서는 양수 가능성이 있지만, 25bp 기준에서는 여전히 음수다. 따라서 비용 조건을 현실보다 낮게 두고 학습 확대를 승인하면 안 된다. + +## 6. 대시보드 반영 + +`webui/stom_dashboard.py`는 이제 다음 artifact 유형을 구분한다. + +```text +filter_search +rolling_filter_validation +cost_sensitivity_gate +``` + +`webui/templates/stom_dashboard.html`의 filter validation 패널에서도 cost gate artifact를 선택하면 다음을 확인할 수 있다. + +- 최종 decision: `ALLOW` 또는 `HOLD` +- target cost bps +- target cost 기준 rolling avg test net +- positive fold rate +- 비용 시나리오별 PASS/FAIL + +## 7. 현재 판단 + +```text +25bp target gate: FAIL +expand_200k 학습: 보류 +다음 단계: score/filter 리디자인 또는 pred30/pred60 ensemble 후보 생성 후 같은 gate 재검증 +``` + +## 8. 현재 진행률 + +| 페이지 | 내용 | 완료율 | 상태 | +| --- | --- | ---: | --- | +| Page 1 | STOM tick DB 구조 분석 | 100% | 완료 | +| Page 2 | 전체 주식 테이블 OHLCV 추출/1초봉 QlibDataset export | 100% | 완료 | +| Page 3 | bounded/pilot 학습 가능성 검증 | 75% | gate 자동화로 검증 강화 | +| Page 4 | pred30/pred60 전체 dataset 학습 루프 연결 | 100% | 완료 | +| Page 5 | budget_20k fine-tuning | 100% | pred30/pred60 checkpoint 생성 완료 | +| Page 6 | checkpoint 예측/대형 walk-forward/rolling 검증 | 96% | 비용 gate 자동화 완료 | +| Page 7 | 웹 대시보드 예측/백테스트/filter/gate 확인 | 88% | cost gate 표시 추가 | +| Page 8 | staged full-training 실행 계획 | 90% | gate 기준 반영 완료 | +| Page 9 | expand_200k/1M/5M/full-window 실제 확대 학습 | 0% | target gate 미충족으로 보류 | + +전체 진행률은 **93%**로 본다. 단, 이 수치는 파이프라인과 검증 체계 기준이며, 모든 possible window의 실제 학습 완료율이 아니다. diff --git a/docs/stom_1s_finetune_execution_report.md b/docs/stom_1s_finetune_execution_report.md new file mode 100644 index 000000000..054cf6427 --- /dev/null +++ b/docs/stom_1s_finetune_execution_report.md @@ -0,0 +1,115 @@ +# STOM 1초봉 Kronos 파인튜닝 실행 보고서 + +작성일: 2026-05-08 + +## 1. 결론 + +전체 STOM 1초봉 QlibDataset export 산출물을 사용해 Kronos predictor 파인튜닝 실행 경로를 실제로 검증했다. Windows + RTX 4080 SUPER 워크스테이션에서는 `torchrun`/NCCL 고정 경로가 아니라 **single GPU non-DDP 실행 경로**가 안정적으로 동작했다. + +이번 단계에서 확인한 것: + +- pred30/pred60 전체 `processed_datasets`가 실제 학습 루프에 로드된다. +- pretrained `NeoQuasar/Kronos-Tokenizer-base`, `NeoQuasar/Kronos-small` fallback이 동작한다. +- `comet_ml` 미설치 상태에서도 `KRONOS_USE_COMET=0`로 학습이 가능하다. +- full export 전체 sample pool에서 budgeted full run을 수행하고 checkpoint를 저장했다. + +아직 확인하지 않은 것: + +- `direction_accuracy`, Top-K 수익률, 실제값/예측값 시각화는 아직 아니다. +- 이번 metric은 Kronos predictor token loss 기반 `best_val_loss`이며, 매매 성과 정확도와 동일하지 않다. +- 다음 단계에서 생성된 checkpoint로 prediction CSV를 만들고 holdout actual과 비교해야 한다. + +## 2. 실행 보강 내용 + +| 파일 | 내용 | +| --- | --- | +| `finetune/run_stom_1s_finetune.py` | STOM 1초봉 pred30/pred60 학습 launcher 추가 | +| `finetune/train_predictor.py` | single GPU non-DDP, optional Comet, pretrained fallback 지원 | +| `finetune/utils/training_utils.py` | Windows에서 NCCL 고정 대신 backend 자동 선택 및 DDP 비활성 지원 | +| `finetune/config.py` | `KRONOS_NUM_WORKERS`, finetuned model path env override 추가 | +| `.gitignore` | 대용량 `finetune/outputs/` 산출물 제외 | + +## 3. 실행 환경 + +```text +GPU: NVIDIA GeForce RTX 4080 SUPER +VRAM: 16 GiB +Torch: 2.9.0+cu128 +CUDA available: True +CUDA version: 12.8 +DDP mode: disabled for single GPU +``` + +Windows 실행에서 필요한 핵심 env: + +```text +KRONOS_DISABLE_DDP=1 +KRONOS_USE_COMET=0 +KRONOS_DDP_BACKEND=gloo +USE_LIBUV=0 +WORLD_SIZE=1 +LOCAL_RANK=0 +``` + +## 4. 실행 결과 + +### 4.1 전체 데이터 smoke + +| horizon | train possible samples | val possible samples | used train | used val | best val loss | duration | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 30초 | 75,277,195 | 16,275,307 | 2 | 2 | 2.8061 | 212.86s | +| 60초 | 73,718,875 | 15,938,107 | 2 | 2 | 1.9724 | 211.90s | + +### 4.2 stage run + +| horizon | used train | used val | batch | steps | best val loss | duration | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 30초 | 512 | 128 | 4 | 128 | 2.3093 | 223.57s | +| 60초 | 512 | 128 | 4 | 128 | 2.2624 | 221.84s | + +### 4.3 budgeted full run + +| horizon | used train | used val | batch | steps | best val loss | duration | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 30초 | 20,000 | 4,000 | 4 | 5,000 | 2.1549 | 550.89s | +| 60초 | 20,000 | 4,000 | 4 | 5,000 | 2.1302 | 549.04s | + +생성 checkpoint: + +```text +finetune/outputs/stom_1s_grid_pred30_full_budget/finetune_predictor/checkpoints/best_model +finetune/outputs/stom_1s_grid_pred60_full_budget/finetune_predictor/checkpoints/best_model +``` + +주의: `finetune/outputs/`는 대용량 모델 산출물이므로 git에 commit하지 않는다. 대신 실행 manifest와 요약 결과를 이 문서에 고정한다. + +## 5. 재실행 명령 + +### 5.1 30초 budgeted full run + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 30 ` + --mode full ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred30_full_budget +``` + +### 5.2 60초 budgeted full run + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred60_full_budget +``` + +## 6. 현재 해석 + +이번 결과는 “전체 STOM tick DB로 Kronos 파인튜닝이 가능한가?”에 대해서는 **가능**으로 판단한다. 실제 전체 export pickle을 로드했고, 7천만 개 이상의 가능한 train window pool에서 샘플링해 GPU 학습 및 checkpoint 저장까지 완료했다. + +다만 “정확도가 0.4보다 좋아졌는가?”는 아직 답할 수 없다. 현재 `best_val_loss`는 tokenizer token prediction loss이므로 매매 방향 정확도와 직접 비교할 수 없다. 다음 단계는 다음 두 가지를 구현/실행해야 한다. + +1. budgeted checkpoint로 test session prediction CSV 생성 +2. persistence/random/기존 0.40 모델과 동일 holdout 기준 direction accuracy 및 Top-K 성과 비교 diff --git a/docs/stom_1s_full_export_report.md b/docs/stom_1s_full_export_report.md new file mode 100644 index 000000000..1f49abb04 --- /dev/null +++ b/docs/stom_1s_full_export_report.md @@ -0,0 +1,125 @@ +# STOM 1초봉 전체 QlibDataset export 검증 보고서 + +작성일: 2026-05-08 + +## 1. 결론 + +STOM `stock_tick_back.db`의 전체 SQLite table을 대상으로 `09:00:00~09:30:00` 구간을 1초 grid로 정규화한 뒤, Kronos `QlibDataset`이 직접 읽을 수 있는 `processed_datasets/*.pkl` 산출물을 30초/60초 horizon별로 생성했다. + +핵심 판단: + +- **실제 stock table 2,425개는 export 성공**했다. +- SQLite 전체 table 2,427개 중 `moneytop`, `stockinfo` 2개는 OHLCV stock table이 아니어서 `close/current price` column 부재로 제외되었다. +- 각 horizon별로 **73,900개 instrument/session group**, **131,470,857 row**가 생성되었다. +- `--split-by session` 결과 train/val/test 사이의 거래일 중복은 없다. +- 이 단계는 **학습 완료가 아니라 학습용 전체 데이터셋 구축 완료**이다. 다음 단계는 이 pickle을 사용한 30초/60초 Kronos 파인튜닝과 baseline 비교이다. + +## 2. 실행 명령 + +### 2.1 30초 horizon + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_grid_pred30_full ` + --max-tables 0 ` + --lookback-window 300 ` + --horizon-seconds 30 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --freq 1s ` + --regularize-1s ` + --split-by session ` + --train-ratio 0.70 ` + --val-ratio 0.15 ` + --test-ratio 0.15 +``` + +### 2.2 60초 horizon + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_grid_pred60_full ` + --max-tables 0 ` + --lookback-window 300 ` + --horizon-seconds 60 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --freq 1s ` + --regularize-1s ` + --split-by session ` + --train-ratio 0.70 ` + --val-ratio 0.15 ` + --test-ratio 0.15 +``` + +## 3. manifest 요약 + +| 구분 | 30초 horizon | 60초 horizon | +| --- | ---: | ---: | +| effective predict window | 30 | 60 | +| lookback window | 300 | 300 | +| 성공 stock table | 2,425 | 2,425 | +| 제외 table | 2 | 2 | +| export group | 73,900 | 73,900 | +| export row | 131,470,857 | 131,470,857 | +| qlib CSV 파일 | 73,900 | 73,900 | +| 1초 grid 보정 group | 73,900 | 73,900 | +| 1초 grid 삽입 row | 9,063,493 | 9,063,493 | + +30초/60초 산출물의 row 수가 같은 이유는 `horizon_seconds`가 데이터 row를 줄이는 옵션이 아니라 **Kronos 학습 target window 길이를 고정하는 metadata/loader 기준**이기 때문이다. 같은 09:00~09:30 원천 구간을 쓰되, 이후 학습에서 `KRONOS_PREDICT_WINDOW=30` 또는 `60`으로 다르게 사용한다. + +## 4. train/val/test split 검증 + +### 4.1 30초 horizon + +| split | groups | rows | sessions | pickle | +| --- | ---: | ---: | ---: | ---: | +| train | 51,944 | 92,418,715 | 665 | 4.84 GiB | +| val | 11,240 | 19,984,507 | 143 | 1.05 GiB | +| test | 10,716 | 19,067,635 | 143 | 1.00 GiB | + +- session overlap: train/val=0, train/test=0, val/test=0 + +### 4.2 60초 horizon + +| split | groups | rows | sessions | pickle | +| --- | ---: | ---: | ---: | ---: | +| train | 51,944 | 92,418,715 | 665 | 4.84 GiB | +| val | 11,240 | 19,984,507 | 143 | 1.05 GiB | +| test | 10,716 | 19,067,635 | 143 | 1.00 GiB | + +- session overlap: train/val=0, train/test=0, val/test=0 + +## 5. column 해석 + +현재 STOM tick table은 일반적인 일봉 OHLC column이 아니라 tick/current price 중심 구조이므로, 이번 1초봉 export는 다음 원칙을 사용했다. + +- `--price-mode close_only`: `현재가`를 open/high/low/close로 사용한다. 1초 단위로 이미 매우 짧은 구간이므로 tick 가격 기반 close-only OHLC로 고정했다. +- 거래량 column이 직접 없으면 `초당매수수량 + 초당매도수량`을 `volume`으로 사용한다. +- 거래대금은 `초당거래대금`이 있으면 사용하고, 없으면 `close * volume`으로 대체한다. +- 1초 grid에서 빠진 초는 가격을 직전 값으로 forward-fill하고, volume/amount는 0으로 채운다. + +## 6. 제외 table + +### 6.1 30초 horizon + +- `moneytop`: Missing required STOM columns: close/current price +- `stockinfo`: Missing required STOM columns: close/current price + +### 6.2 60초 horizon + +- `moneytop`: Missing required STOM columns: close/current price +- `stockinfo`: Missing required STOM columns: close/current price + +해석: 위 2개는 stock tick OHLCV table이 아니라 metadata/ranking 계열 table이므로, 전체 stock table 학습 데이터 구축 실패로 보지 않는다. + +## 7. 다음 단계 + +1. `processed_datasets`를 `KRONOS_DATASET_PATH`로 지정하여 30초/60초 모델을 각각 파인튜닝한다. +2. 기존 `direction_accuracy=0.40` 모델, persistence baseline, 단순 상승/하락 랜덤 baseline과 동일 holdout에서 비교한다. +3. 학습 후 prediction CSV를 생성하고, 웹 대시보드에서 실제값/예측값·direction hit·Top-K 성과를 시각화한다. +4. 30초/60초 중 실전 적용 가능성이 높은 horizon을 선택해 STOM ?? ?? 연동 설계를 진행한다. diff --git a/docs/stom_1s_large_walkforward_gate_report.md b/docs/stom_1s_large_walkforward_gate_report.md new file mode 100644 index 000000000..b810ac4b3 --- /dev/null +++ b/docs/stom_1s_large_walkforward_gate_report.md @@ -0,0 +1,189 @@ +# STOM 1초봉 pred60 대형 walk-forward 게이트 검토 보고서 + +작성일: 2026-05-09 + +## 1. 이번 단계의 목적 + +`expand_200k` 또는 그 이상의 학습을 바로 실행하기 전에, 현재 `budget_20k` pred60 checkpoint가 더 큰 holdout walk-forward 구간에서도 실제 매매 후보로 확대할 가치가 있는지 검증했다. + +중요한 전제는 다음과 같다. + +- 전체 STOM 1초봉 QlibDataset export는 이미 완료되어 있다. +- pred60 기준 가능한 train window는 73,718,875개, val window는 15,938,107개다. +- 그러나 현재 실제 fine-tuning checkpoint는 `budget_20k` 단계, 즉 train 20,000개와 val 4,000개로 학습된 모델이다. +- 이번 단계는 추가 학습이 아니라 **추가 학습을 진행할지 판단하기 위한 대형 검증 게이트**다. + +## 2. 사용한 평가 데이터와 산출물 + +| 항목 | 값 | +| --- | ---: | +| horizon | 60초 | +| checkpoint | `finetune/outputs/stom_1s_grid_pred60_full_budget/finetune_predictor/checkpoints/best_model` | +| dataset | `finetune/qlib_exports/stom_1s_grid_pred60_full/processed_datasets` | +| max sessions | 100 | +| max as-of per session | 5 | +| max symbols | 50 | +| selected windows | 3,080 | +| rebalance periods | 500 | +| symbols | 334 | +| rows per mode | 184,800 | + +주요 산출물은 대용량이므로 git commit 대상이 아니며, 로컬 실행 결과로만 보존한다. + +```text +webui/stom_predictions/stom_1s_pred60_walkforward100x5x50_eval_comparison.json +webui/stom_predictions/stom_1s_pred60_walkforward100x5x50_eval_kronos.csv +webui/qlib_backtests/stom_1s_pred60_walkforward100x5x50_eval_kronos.qlib_topk5.json +webui/qlib_backtests/stom_1s_pred60_walkforward100x5x50_eval_kronos.filter_search.json +webui/qlib_backtests/stom_1s_pred60_walkforward100x5x50_eval_kronos_rolling100x50.rolling_filter_validation.json +``` + +## 3. 모델 방향성 평가 + +| 모델 | 방향 정확도 | Top-K 실제 평균 등락률 | Top-K hit rate | 해석 | +| --- | ---: | ---: | ---: | --- | +| Kronos pred60 | 0.4312 | +0.0554% | 0.4328 | random보다 높지만 매우 강한 신호는 아님 | +| Random baseline | 0.4084 | +0.0513% | 0.4073 | 시장 자체/표본 효과가 포함된 기준선 | +| Persistence baseline | 0.1487 | +0.0601% | 0.1567 | 단순 지속성은 방향성에서 크게 열위 | + +해석: + +- 이전에 우려했던 `0.4 전후 정확도`는 이번 더 큰 표본에서 Kronos가 random보다 조금 높게 나왔다. +- 다만 차이는 약 +2.27%p 수준이며, 이것만으로 자동매매 수익성을 주장할 수는 없다. +- 따라서 핵심 판단 지표는 방향 정확도 단독이 아니라 **비용 반영 후 Top-K/조건식/rolling 검증 성과**다. + +## 4. Qlib-style Top-K 비용 반영 결과 + +거래 비용은 수수료 15bp + 슬리피지 10bp, 총 25bp로 반영했다. + +| 항목 | 값 | +| --- | ---: | +| period count | 500 | +| trade count | 2,470 | +| avg gross return | +0.0547% | +| avg net return | -0.1953% | +| direction hit rate | 0.4328 | +| cumulative return | -62.4982% | +| max drawdown | -62.4982% | +| sharpe per period | -12.2191 | + +결론: + +- 예측 방향성은 random보다 높지만, 거래 1회당 비용 25bp를 넘지 못했다. +- 현재 Top-K 단독 방식은 실전 매수 조건으로 사용할 수 없다. + +## 5. Robust filter search 결과 + +비용 후 손실을 줄이기 위해 예측 수익률, 예측 경로 일관성, 거래대금 분위수, 변동성 조건을 조합했다. + +| 항목 | baseline Top-K | best robust filter | +| --- | ---: | ---: | +| filter | 없음 | `ret>=-0.05|cons>=0.8|range<=none|amt_q>=0.75|vol<=none` | +| period count | 500 | 273 | +| trade count | 2,470 | 417 | +| avg gross return | +0.0554% | +0.1030% | +| avg net return | -0.1953% | -0.1266% | +| direction hit rate | 0.4328 | 0.4772 | +| coverage | 1.0000 | 0.5460 | +| cumulative return | -62.4982% | -29.8892% | + +해석: + +- 조건식은 baseline 대비 손실을 줄였다. +- 하지만 비용 후 평균 수익률은 여전히 음수다. +- 따라서 이 조건식도 아직 실전 자동매수 조건이 아니라, 다음 실험의 후보 조건일 뿐이다. + +## 6. Rolling train/test validation 결과 + +조건식 과최적화를 막기 위해 앞 100 periods에서 조건식을 선택하고 다음 50 periods에 적용하는 rolling validation을 실행했다. + +| 항목 | 값 | +| --- | ---: | +| fold count | 8 | +| train periods | 100 | +| test periods | 50 | +| total test trades | 150 | +| avg train net return | +0.0351% | +| avg test net return | -0.1766% | +| avg test baseline net return | -0.1945% | +| test improvement vs baseline | +0.0179%p | +| weighted test direction hit | 0.5200 | +| positive test fold rate | 0.2500 | +| overfit gap | 0.2117%p | +| profitable after cost | false | + +해석: + +- rolling test에서 baseline보다 손실은 약간 줄었다. +- 하지만 개선폭이 작고, positive fold rate가 25%에 그쳤다. +- train 구간에서는 좋아 보이지만 test 구간에서 무너지는 양상이 있어 조건식 과최적화 위험이 남아 있다. + +## 7. 게이트 판단 + +이번 단계의 결론은 다음과 같다. + +```text +expand_200k 실제 학습: 보류 +full_window 전량 학습: 보류 +현재 budget_20k 모델의 대형 평가 결과: 방향성 신호는 있으나 비용 후 수익성 미달 +``` + +보류 이유: + +1. Kronos 방향 정확도는 random보다 높지만 차이가 작다. +2. Qlib-style Top-K의 비용 후 평균 수익률이 -0.1953%다. +3. best robust filter도 비용 후 평균 수익률이 -0.1266%다. +4. rolling validation의 평균 test net이 -0.1766%로 음수다. +5. positive test fold rate가 25%로 낮아 기간 안정성이 부족하다. + +따라서 현재 상태에서 학습량만 200k, 1M, 5M으로 키우는 것은 GPU 시간을 많이 쓰면서도 개선 여부가 불확실하다. 다음 단계는 학습량 확대가 아니라 **score/filter 구조 개선과 비용 민감도 분석**이다. + +## 8. 다음 권장 작업 + +다음 작업은 아래 순서가 안전하다. + +1. 비용 민감도 분석: 5bp, 10bp, 15bp, 25bp에서 Top-K와 조건식 결과 비교 +2. pred30/pred60 ensemble score 후보 생성 +3. `pred_return_window`, `pred_path_consistency`, `history_mean_amount`, `history_volatility_pct` 외 추가 후보 feature 검토 +4. 조건식 선택 시 rolling test 평균 net이 0 이상인지 확인 +5. 이 기준을 만족할 때만 `--sample-stage expand_200k` 실제 학습 실행 + +## 9. 현재 페이지별 진행률 + +| 페이지 | 내용 | 완료율 | 상태 | +| --- | --- | ---: | --- | +| Page 1 | STOM tick DB 구조 분석 | 100% | 완료 | +| Page 2 | 전체 주식 테이블 OHLCV 추출/1초봉 QlibDataset export | 100% | 완료 | +| Page 3 | bounded/pilot 학습 가능성 검증 | 70% | 기본 가능성 확인, 수익성 개선 필요 | +| Page 4 | pred30/pred60 전체 dataset 학습 루프 연결 | 100% | 완료 | +| Page 5 | budget_20k fine-tuning | 100% | pred30/pred60 checkpoint 생성 완료 | +| Page 6 | checkpoint 예측/대형 walk-forward/rolling 검증 | 95% | 이번 단계 완료 | +| Page 7 | 웹 대시보드 예측/백테스트/필터 리포트 확인 | 82% | 산출물 표시 가능, 비교 UX 추가 개선 여지 | +| Page 8 | staged full-training 실행 계획 | 88% | 게이트 기준 반영 완료 | +| Page 9 | expand_200k/1M/5M/full-window 실제 확대 학습 | 0% | 게이트 미충족으로 보류 | + +전체 진행률은 **91%**로 본다. 단, 이 91%는 “전체 데이터 학습 파이프라인 구축과 검증 체계” 기준이며, “모든 window를 실제로 끝까지 학습한 비율”은 아니다. + +## 10. 2026-05-09 cost sensitivity gate 자동화 후속 결과 + +후속 보고서: `docs/stom_1s_cost_gate_analysis_report.md` + +이번 후속 단계에서는 기존 filter-search/rolling-validation artifact를 입력으로 받아 `5bp`, `10bp`, `15bp`, `25bp` 비용 민감도를 자동 계산하는 `--gate-analysis` 모드를 추가했다. + +실제 대형 artifact 적용 결과: + +| total cost | rolling avg test net | positive fold rate | gate | +| ---: | ---: | ---: | --- | +| 5bp | +0.0234% | 0.500 | PASS | +| 10bp | -0.0266% | 0.375 | FAIL | +| 15bp | -0.0766% | 0.375 | FAIL | +| 25bp | -0.1766% | 0.250 | FAIL | + +최종 판단은 변하지 않는다. + +```text +target cost 25bp 기준 gate: FAIL +expand_200k 실제 학습: 보류 +``` + +5bp에서는 통과되지만, 실제 판단 비용인 25bp에서 실패하므로 학습량 확대보다 score/filter 리디자인 또는 pred30/pred60 ensemble 후보 검증이 먼저다. diff --git a/docs/stom_1s_qlibdataset_finetune_plan.md b/docs/stom_1s_qlibdataset_finetune_plan.md new file mode 100644 index 000000000..b916376bf --- /dev/null +++ b/docs/stom_1s_qlibdataset_finetune_plan.md @@ -0,0 +1,406 @@ +# STOM 1초봉 Kronos QlibDataset 전체 파인튜닝 계획 + +작성일: 2026-05-08 + +## 현재 단계 결론 + +1초봉을 pyqlib provider로 직접 학습하는 경로가 아니라, **STOM 1초봉을 Kronos `QlibDataset` pickle 형식으로 변환한 뒤 `finetune/train_predictor.py`로 파인튜닝**하는 경로가 맞다. + +이번 단계에서는 전체 데이터 학습 전에 필요한 데이터 품질 게이트를 추가했다. + +- `--regularize-1s`: 1초 grid 보정 +- `--split-by session`: 날짜/session 기준 train/val/test split +- `--horizon-seconds`: 30초/60초 후 target을 명시하는 horizon preset +- export report에 `split_sessions`, `grid_summary`, `effective_predict_window` 기록 +- `dump_bin` command에 export freq 보존 + +## 전체 페이지 진행률 + +| 페이지 | 내용 | 상태 | 완료율 | +| ---: | --- | --- | ---: | +| 1 | STOM DB 구조 분석 | 완료 | 100% | +| 2 | STOM tick 기본 OHLCV 변환 | 완료 | 100% | +| 3 | 기존 all-table bounded 학습 | 완료/품질 낮음 | 60% | +| 4 | 1초봉 QlibDataset 전체 학습 데이터 구축 | 진행 중 | 65% | +| 5 | 30초/60초 모델 파인튜닝 | 남음 | 0% | +| 6 | baseline/walk-forward/Top-K 검증 | 남음 | 0% | +| 7 | 대시보드/추천/?? ?? ?? 적용 | 부분 완료 | 45% | + +현재 전체 진행률: 약 **60~65%**. + +## 왜 이 단계가 필요한가 + +기존 모델의 `direction_accuracy=0.40`은 실전 매매 신호로 사용하기 어렵다. 특히 기존 경로에는 다음 문제가 있었다. + +1. `predict_window=30/60`이 실제 30초/60초가 아니라 30/60 row일 수 있음. +2. train/val/test가 group 단위라 같은 거래일이 여러 split에 나뉠 수 있음. +3. 전체 universe 대상이라도 모든 가능한 window를 끝까지 학습한 것이 아니라 bounded 학습임. + +따라서 재학습 전에 먼저 **정확한 시간 grid와 날짜 기준 split**을 고정해야 한다. + +## 파일럿 검증 결과 + +### 30초 horizon 파일럿 + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_grid_pred30_stage_pilot ` + --max-tables 2 ` + --lookback-window 30 ` + --horizon-seconds 30 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 090200 ` + --freq 1s ` + --regularize-1s ` + --split-by session ` + --max-groups 6 ` + --train-ratio 0.5 ` + --val-ratio 0.25 ` + --test-ratio 0.25 +``` + +결과: + +- exported groups: 4 +- exported rows: 435 +- grid inserted rows: 239 +- train/val/test session 중복 없음 +- `QlibDataset` load smoke 성공 + +### 60초 horizon 파일럿 + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_grid_pred60_stage_pilot ` + --max-tables 2 ` + --lookback-window 60 ` + --horizon-seconds 60 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 090300 ` + --freq 1s ` + --regularize-1s ` + --split-by session ` + --max-groups 6 ` + --train-ratio 0.5 ` + --val-ratio 0.25 ` + --test-ratio 0.25 +``` + +결과: + +- exported groups: 4 +- exported rows: 675 +- grid inserted rows: 382 +- train/val/test session 중복 없음 + +## 전체 데이터 export 명령 + +### 30초 후 예측용 전체 데이터 + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_grid_pred30_full ` + --max-tables 0 ` + --lookback-window 300 ` + --horizon-seconds 30 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --freq 1s ` + --regularize-1s ` + --split-by session ` + --train-ratio 0.70 ` + --val-ratio 0.15 ` + --test-ratio 0.15 +``` + +### 60초 후 예측용 전체 데이터 + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_grid_pred60_full ` + --max-tables 0 ` + --lookback-window 300 ` + --horizon-seconds 60 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --freq 1s ` + --regularize-1s ` + --split-by session ` + --train-ratio 0.70 ` + --val-ratio 0.15 ` + --test-ratio 0.15 +``` + +## 파인튜닝 연결 + +30초 모델: + +```powershell +$env:KRONOS_DATASET_PATH="D:\Chanil_Park\Project\Programming\Kronos\finetune\qlib_exports\stom_1s_grid_pred30_full\processed_datasets" +$env:KRONOS_LOOKBACK_WINDOW="300" +$env:KRONOS_PREDICT_WINDOW="30" +$env:KRONOS_USE_COMET="0" +$env:KRONOS_SAVE_PATH="finetune\outputs\stom_1s_grid_pred30" +python finetune\train_predictor.py +``` + +60초 모델: + +```powershell +$env:KRONOS_DATASET_PATH="D:\Chanil_Park\Project\Programming\Kronos\finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets" +$env:KRONOS_LOOKBACK_WINDOW="300" +$env:KRONOS_PREDICT_WINDOW="60" +$env:KRONOS_USE_COMET="0" +$env:KRONOS_SAVE_PATH="finetune\outputs\stom_1s_grid_pred60" +python finetune\train_predictor.py +``` + +## 남은 검증 + +1. 전체 데이터 export를 장시간 실행하고 manifest를 보관한다. +2. 30초/60초 모델을 각각 학습한다. +3. persistence baseline과 같은 holdout 날짜에서 비교한다. +4. `direction_accuracy=0.40` 기존 모델보다 개선되는지 확인한다. +5. 개선되지 않으면 실전 추천에는 계속 사용하지 않는다. + +## 2026-05-08 전체 export 완료 결과 + +`--regularize-1s`, `--split-by session`, `--horizon-seconds`를 적용한 STOM 1초봉 전체 export를 30초/60초 horizon 모두 완료했다. 상세 근거는 `docs/stom_1s_full_export_report.md`에 고정했다. + +| 항목 | 30초 horizon | 60초 horizon | +| --- | ---: | ---: | +| 성공 stock table | 2,425 | 2,425 | +| 제외 non-stock table | 2 | 2 | +| export group | 73,900 | 73,900 | +| export row | 131,470,857 | 131,470,857 | +| train sessions | 665 | 665 | +| val sessions | 143 | 143 | +| test sessions | 143 | 143 | +| session overlap | 0 | 0 | + +현재 페이지 판단: + +```text +Page 1 DB 구조 분석 [██████████] 100% +Page 2 STOM tick OHLCV 변환 [██████████] 100% +Page 3 bounded/pilot 학습 검증 [██████░░░░] 60% +Page 4 1초봉 전체 QlibDataset 구축 [██████████] 100% +Page 5 30초/60초 Kronos 파인튜닝 [██████░░░░] 60% +Page 6 baseline/walk-forward 검증 [████░░░░░░] 40% +Page 7 웹 대시보드/?? ?? Export 연동 [██████░░░░] 60% +``` + +전체 진행률은 약 **82%**로 본다. 다음 commit 단위는 평가 표본을 더 넓히고, 60초 모델의 direction accuracy 개선 신호가 반복되는지 walk-forward로 확인하는 것이다. + +## 2026-05-08 budgeted 파인튜닝 실행 결과 + +Windows + RTX 4080 SUPER 환경에서 전체 STOM 1초봉 pred30/pred60 `processed_datasets`를 실제 Kronos predictor 학습 루프에 넣는 데 성공했다. 상세 근거는 `docs/stom_1s_finetune_execution_report.md`에 고정했다. + +| 항목 | 30초 | 60초 | +| --- | ---: | ---: | +| train possible samples | 75,277,195 | 73,718,875 | +| val possible samples | 16,275,307 | 15,938,107 | +| budgeted train samples | 20,000 | 20,000 | +| budgeted val samples | 4,000 | 4,000 | +| batch size | 4 | 4 | +| train steps | 5,000 | 5,000 | +| best val loss | 2.1549 | 2.1302 | +| duration | 550.89s | 549.04s | + +주의: 위 `best val loss`는 매매 방향 정확도가 아니라 Kronos predictor token loss다. 따라서 기존 `direction_accuracy=0.40`과 직접 비교하지 않는다. 다음 단계에서 checkpoint 기반 prediction CSV를 만들고 실제 등락 방향과 비교해야 한다. + +## 2026-05-08 checkpoint 예측/평가 결과 + +budgeted checkpoint로 test split holdout 예측 CSV를 생성하고 baseline과 비교했다. 상세 근거는 `docs/stom_1s_checkpoint_eval_report.md`에 고정했다. + +| horizon | Kronos direction accuracy | persistence | random | 판단 | +| --- | ---: | ---: | ---: | --- | +| 30초 | 0.3704 | 0.2222 | 0.1111 | 0.40 미달 | +| 60초 | 0.4444 | 0.1111 | 0.2963 | 제한 샘플에서 0.40 초과 | + +단, Top-K net return은 30초/60초 모두 음수다. 따라서 현재 모델은 실전 매수 추천으로 바로 사용하지 않고, 평가 표본 확대와 조건식 보완 후 다시 판단한다. + +## 2026-05-08 pred60 walk-forward 조건식 필터 검증 결과 + +상세 보고서는 `docs/stom_1s_walkforward_filter_report.md`에 고정했다. 이번 단계에서는 pred60 budget checkpoint를 더 넓은 holdout 표본으로 평가하고, 예측 시점 feature만 사용하는 조건식 필터를 탐색했다. + +핵심 결과: + +- 평가 범위: 30개 session × session당 3개 as-of, 546 windows, 146 symbols, 90 rebalance periods +- Kronos direction accuracy: 0.4084 +- Persistence direction accuracy: 0.1832 +- Random(seed 고정) direction accuracy: 0.4084 +- 조건식 없는 Qlib-style Top-K net: -0.2377% +- robust filter net: -0.1008%, coverage 62.22%, direction hit 0.4634 +- opportunistic filter net: -0.0089%, coverage 36.67%, direction hit 0.4762 + +현재 판단: + +```text +Page 1 DB 구조 분석 [█████] 100% +Page 2 STOM tick OHLCV 변환 [█████] 100% +Page 3 bounded/pilot 학습 검증 [███░░] 60% +Page 4 1초봉 전체 QlibDataset 구축 [█████] 100% +Page 5 30초/60초 Kronos 파인튜닝 [████░] 80% +Page 6 baseline/walk-forward 검증 [████░] 80% +Page 7 웹 대시보드/?? ?? Export 연동 [████░] 70% +전체 진행률 [████░] 85% +``` + +중요 결론은 `조건식이 손실을 크게 줄였지만 아직 비용 후 양수 전환은 아니다`이다. 따라서 현재 모델은 연구/검증/대시보드 확인용으로 유지하고, 실제 자동 매수 추천에는 바로 연결하지 않는다. + +다음 단계는 더 큰 walk-forward 표본과 rolling train/test 방식의 조건식 검증이다. + +## 2026-05-08 rolling 조건식 검증 결과 + +상세 보고서는 `docs/stom_1s_rolling_filter_validation_report.md`에 고정했다. 이번 단계에서는 같은 표본에서 고른 조건식을 같은 표본에서 평가하는 한계를 줄이기 위해, 앞쪽 30 periods에서 best filter를 고르고 뒤쪽 30 periods에 그대로 적용하는 rolling validation을 추가했다. + +핵심 결과: + +- rolling fold: 2 +- train/test period: 30 / 30 +- total test trades: 26 +- avg train net: +0.0519% +- avg test net: -0.0351% +- avg test baseline net: -0.2438% +- baseline 대비 test 개선폭: +0.2087%p +- weighted test direction hit: 0.4615 +- positive test fold rate: 0.5 + +현재 판단: + +```text +Page 1 DB 구조 분석 [█████] 100% +Page 2 STOM tick OHLCV 변환 [█████] 100% +Page 3 bounded/pilot 학습 검증 [███░░] 60% +Page 4 1초봉 전체 QlibDataset 구축 [█████] 100% +Page 5 30초/60초 Kronos 파인튜닝 [████░] 80% +Page 6 walk-forward/rolling 검증 [████░] 88% +Page 7 웹 대시보드/?? ?? Export 연동 [████░] 78% +전체 진행률 [████░] 88% +``` + +rolling 결과는 조건식이 baseline 대비 손실을 줄인다는 근거를 보강했지만, 비용 후 평균 test net이 아직 음수라서 실전 자동 매수 승인에는 부족하다. 다음 단계는 `max_sessions 100`, `max_asofs 5`, `max_symbols 50` 이상 대형 walk-forward를 장시간 실행하고 같은 rolling 검증을 반복하는 것이다. + +## 2026-05-09 staged full-training 계획 반영 + +전체 데이터 학습은 `docs/stom_1s_staged_full_training_plan.md`에 실행 가능한 staged roadmap으로 반영했다. 핵심은 현재 완료된 `20k budgeted` 학습을 전량 학습으로 과장하지 않고, 아래 순서로 확장하는 것이다. + +```text +budget_20k 완료 +expand_200k 준비 +expand_1m 준비 +expand_5m 준비 +full_window 후보 +``` + +`finetune/run_stom_1s_finetune.py`에는 `--sample-stage` 옵션을 추가했다. + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --sample-stage expand_200k ` + --output-root finetune\outputs +``` + +현재 판단: + +```text +Page 1 DB 구조 분석 [█████] 100% +Page 2 STOM tick OHLCV 변환 [█████] 100% +Page 3 bounded/pilot 학습 검증 [███░░] 60% +Page 4 1초봉 전체 QlibDataset 구축 [█████] 100% +Page 5 30초/60초 20k 파인튜닝 [█████] 100% +Page 6 walk-forward/rolling 검증 [████░] 88% +Page 7 웹 대시보드/내부 검증 Export [████░] 78% +Page 8 staged full-training 확대 계획 [████░] 80% +전체 진행률 [████░] 89% +``` + +다음 실행 단계는 `expand_200k` 학습이 아니라, 먼저 pred60 대형 walk-forward 평가를 실행해 학습량 확대가 가치 있는지 확인하는 것이다. + +## 2026-05-09 pred60 대형 walk-forward 게이트 결과 + +상세 보고서: `docs/stom_1s_large_walkforward_gate_report.md` + +이번 단계에서는 `expand_200k` 실제 학습으로 넘어가기 전에, 기존 pred60 `budget_20k` checkpoint를 더 큰 holdout walk-forward 표본으로 검증했다. + +핵심 수치: + +| 항목 | 값 | +| --- | ---: | +| selected windows | 3,080 | +| rebalance periods | 500 | +| rows per mode | 184,800 | +| Kronos direction accuracy | 0.4312 | +| random direction accuracy | 0.4084 | +| persistence direction accuracy | 0.1487 | +| Qlib Top-K avg net return | -0.1953% | +| best robust filter avg net return | -0.1266% | +| rolling avg test net return | -0.1766% | +| rolling positive test fold rate | 0.25 | + +판단: + +```text +expand_200k 실제 학습은 이번 단계에서 실행하지 않는다. +방향성 신호는 random보다 높지만, 비용 후 수익성과 rolling 안정성이 아직 기준 미달이다. +``` + +따라서 다음 단계는 학습량 확대가 아니라 score/filter 구조 개선, 비용 민감도 분석, pred30/pred60 ensemble 후보 검증이다. rolling 평균 test net이 0 이상으로 올라오고 여러 fold에서 반복 개선이 확인될 때만 `--sample-stage expand_200k` 학습으로 넘어간다. + +현재 판단: + +```text +Page 1 DB 구조 분석 [█████] 100% +Page 2 STOM tick OHLCV/QlibDataset 구축 [█████] 100% +Page 3 bounded/pilot 학습 검증 [████░] 70% +Page 4 1초봉 전체 학습 루프 연결 [█████] 100% +Page 5 30초/60초 20k 파인튜닝 [█████] 100% +Page 6 대형 walk-forward/rolling 검증 [█████] 95% +Page 7 웹 대시보드/검증 산출물 확인 [████░] 82% +Page 8 staged full-training 계획 [████░] 88% +Page 9 expand/full-window 실제 확대 학습 [░░░░░] 0% +전체 진행률 [█████░] 91% +``` + +주의: 여기서 전체 진행률은 “파이프라인 구축과 검증 체계” 기준이다. STOM tick의 모든 possible window를 실제로 끝까지 학습한 것은 아니며, 확대 학습은 게이트 미충족으로 보류한다. + +## 2026-05-09 cost sensitivity gate 자동화 + +상세 보고서: `docs/stom_1s_cost_gate_analysis_report.md` + +대형 walk-forward 후속으로 `--gate-analysis` 모드를 추가해 비용 민감도와 expand 학습 승인 여부를 자동 계산하게 했다. + +결과: + +| total cost | rolling avg test net | positive fold rate | gate | +| ---: | ---: | ---: | --- | +| 5bp | +0.0234% | 0.500 | PASS | +| 10bp | -0.0266% | 0.375 | FAIL | +| 15bp | -0.0766% | 0.375 | FAIL | +| 25bp | -0.1766% | 0.250 | FAIL | + +현재 실제 판단 기준인 25bp에서는 gate가 실패하므로 `expand_200k`는 계속 보류한다. 다음 단계는 score/filter 리디자인 또는 pred30/pred60 ensemble 후보를 만든 뒤 같은 gate를 재실행하는 것이다. + +```text +Page 1 DB 구조 분석 [█████] 100% +Page 2 STOM tick OHLCV/QlibDataset 구축 [█████] 100% +Page 3 bounded/pilot 학습 검증 [████░] 75% +Page 4 1초봉 전체 학습 루프 연결 [█████] 100% +Page 5 30초/60초 20k 파인튜닝 [█████] 100% +Page 6 대형 walk-forward/rolling/gate 검증 [█████] 96% +Page 7 웹 대시보드/검증 산출물 확인 [████░] 88% +Page 8 staged full-training 계획 [█████] 90% +Page 9 expand/full-window 실제 확대 학습 [░░░░░] 0% +전체 진행률 [█████░] 93% +``` diff --git a/docs/stom_1s_rolling_filter_validation_report.md b/docs/stom_1s_rolling_filter_validation_report.md new file mode 100644 index 000000000..cde711d4c --- /dev/null +++ b/docs/stom_1s_rolling_filter_validation_report.md @@ -0,0 +1,121 @@ +# STOM 1초봉 pred60 rolling 조건식 검증 보고서 + +작성일: 2026-05-08 + +## 1. 목적 + +이전 단계의 filter-search는 같은 표본 안에서 조건식을 고르고 같은 표본에서 성과를 확인했다. +따라서 조건식이 실제로 의미 있는지, 아니면 과최적화인지 분리 검증이 필요했다. + +이번 단계에서는 **앞쪽 기간에서 best filter를 선택하고 뒤쪽 기간에 그대로 적용하는 rolling train/test 검증**을 추가했다. + +## 2. 구현 내용 + +`finetune/search_stom_1s_filters.py`에 다음 기능을 추가했다. + +- `rolling_validate_filters` +- `write_rolling_filter_report` +- CLI 옵션: + - `--rolling-validate` + - `--rolling-train-periods` + - `--rolling-test-periods` + - `--rolling-step-periods` + +대시보드에는 다음 API/패널을 추가했다. + +- `GET /api/stom/filter-reports` +- filter-search JSON 표시 +- rolling validation JSON 표시 +- fold별 train net, test net, baseline net, 개선폭 표시 + +## 3. 실행 명령 + +```powershell +python finetune\search_stom_1s_filters.py ` + --prediction-csv webui\stom_predictions\stom_1s_pred60_walkforward30x3_eval_kronos.csv ` + --output-dir webui\qlib_backtests ` + --prefix stom_1s_pred60_walkforward30x3_eval_kronos_rolling30x30 ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 ` + --min-trades 10 ` + --min-periods 10 ` + --min-coverage 0.25 ` + --rolling-validate ` + --rolling-train-periods 30 ` + --rolling-test-periods 30 ` + --rolling-step-periods 30 +``` + +생성 artifact: + +- `webui/qlib_backtests/stom_1s_pred60_walkforward30x3_eval_kronos_rolling30x30.rolling_filter_validation.json` +- `webui/qlib_backtests/stom_1s_pred60_walkforward30x3_eval_kronos_rolling30x30.rolling_filter_validation_folds.csv` + +## 4. rolling 검증 결과 + +| 항목 | 값 | +| --- | ---: | +| fold_count | 2 | +| total_period_count | 90 | +| train_periods | 30 | +| test_periods | 30 | +| total_test_trade_count | 26 | +| avg_train_net_return_pct | +0.0519% | +| avg_test_net_return_pct | -0.0351% | +| avg_test_baseline_net_return_pct | -0.2438% | +| avg_test_improvement_net_pct | +0.2087%p | +| test_direction_hit_rate_weighted | 0.4615 | +| positive_test_fold_rate | 0.5 | +| overfit_gap_pct | +0.0870%p | +| is_profitable_after_cost | false | + +fold별 결과: + +| fold | selected filter | test net | baseline net | 개선폭 | +| ---: | --- | ---: | ---: | ---: | +| 1 | `ret>=0.05`, `range<=0.1`, `vol<=0.2` | +0.0604% | -0.2316% | +0.2921%p | +| 2 | `ret>=0.05`, `range<=0.1`, `vol<=0.2` | -0.1305% | -0.2559% | +0.1253%p | + +## 5. 해석 + +이번 rolling 검증의 핵심은 다음과 같다. + +```text +조건식은 baseline 대비 손실을 줄이는 효과가 out-of-sample에서도 유지됨. +하지만 25bp 비용 후 평균 test net은 아직 -0.0351%로 음수임. +따라서 조건식은 의미 있는 후보이지만 실전 승인 조건은 아님. +``` + +즉, 이전 opportunistic filter의 `net -0.0089%`는 완전히 무의미한 과최적화로 보기는 어렵다. +다만 rolling test에서는 평균 net이 다시 음수이므로, 아직 자동 매수 추천에 연결하면 안 된다. + +## 6. 대형 walk-forward 실행 준비 상태 + +필수 경로 확인: + +- pred60 prediction CSV: 존재 +- pred60 fine-tuned checkpoint: 존재 +- pred60 processed dataset: 존재 + +다음 대형 평가 예상 규모: + +```text +max_sessions 100 +max_asofs 5 +max_symbols 50 +예상 windows: 최대 25,000 +예상 rows per mode: 최대 1,500,000 +``` + +이 평가는 GPU 추론 시간이 길 수 있으므로 별도 장시간 실행 단계로 분리한다. + +## 7. 다음 판단 기준 + +다음 대형 walk-forward에서 아래 조건을 동시에 만족해야 실전 후보로 승격할 수 있다. + +1. rolling avg_test_net_return_pct가 0보다 커야 한다. +2. baseline 대비 개선폭이 여러 fold에서 반복되어야 한다. +3. positive_test_fold_rate가 0.5를 명확히 넘어야 한다. +4. 거래 수가 너무 적어서는 안 된다. +5. random baseline과 비교해 방향성과 net이 모두 우위여야 한다. diff --git a/docs/stom_1s_staged_full_training_plan.md b/docs/stom_1s_staged_full_training_plan.md new file mode 100644 index 000000000..455d293ee --- /dev/null +++ b/docs/stom_1s_staged_full_training_plan.md @@ -0,0 +1,219 @@ +# STOM 1초봉 staged full-training 실행 계획 + +작성일: 2026-05-09 + +## 1. 목적 + +STOM tick 전체 데이터는 이미 1초봉 `QlibDataset` pickle로 변환되어 Kronos 학습 루프에 연결되어 있다. +하지만 현재 실제 fine-tuning은 `20,000` train sample budget으로 수행되었고, 가능한 모든 train window를 전량 학습한 것은 아니다. + +이 문서는 전체 데이터 학습을 다음처럼 단계형으로 확대하기 위한 실행 계획이다. + +```text +20k baseline 완료 +→ 200k 확대 +→ 1M 확대 +→ 5M 장시간 학습 +→ full-window 전량 epoch 후보 +``` + +## 2. 현재 완료 상태 + +| 항목 | pred30 | pred60 | +| --- | ---: | ---: | +| 전체 주식 table | 2,425 | 2,425 | +| export group | 73,900 | 73,900 | +| export row | 131,470,857 | 131,470,857 | +| possible train samples | 75,277,195 | 73,718,875 | +| possible val samples | 16,275,307 | 15,938,107 | +| 현재 실제 train samples | 20,000 | 20,000 | +| 현재 실제 val samples | 4,000 | 4,000 | + +따라서 현재 상태는 다음과 같이 정의한다. + +```text +전체 데이터셋 구축: 완료 +전체 데이터셋 학습 루프 연결: 완료 +전체 window 전량 학습: 미완료 +``` + +## 3. staged training budget + +| stage | train samples | val samples | 목적 | +| --- | ---: | ---: | --- | +| `budget_20k` | 20,000 | 4,000 | 현재 baseline 재현 | +| `expand_200k` | 200,000 | 40,000 | 학습량 10배 확대 후 방향성/Top-K 변화 확인 | +| `expand_1m` | 1,000,000 | 100,000 | 의미 있는 장시간 학습 후보 | +| `expand_5m` | 5,000,000 | 250,000 | overnight급 장시간 학습 후보 | +| `full_window` | horizon별 possible train 전체 | horizon별 possible val 전체 | 최종 전량 epoch 후보 | + +`full_window`는 pred30 기준 75,277,195 train samples, pred60 기준 73,718,875 train samples다. + +## 4. 실행 CLI + +이번 단계에서 `finetune/run_stom_1s_finetune.py`에 `--sample-stage` 옵션을 추가했다. + +### 4.1 pred60 200k 확대 학습 dry-run + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --sample-stage expand_200k ` + --output-root finetune\outputs ` + --dry-run +``` + +### 4.2 pred60 200k 실제 학습 + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --sample-stage expand_200k ` + --output-root finetune\outputs +``` + +### 4.3 pred60 1M 실제 학습 + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --sample-stage expand_1m ` + --output-root finetune\outputs +``` + +### 4.4 pred60 full-window 전량 후보 dry-run + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --sample-stage full_window ` + --output-root finetune\outputs ` + --dry-run +``` + +## 5. 실행 순서 + +현재 모델의 비용 후 net return이 아직 양수가 아니므로, 바로 full-window 전량 학습으로 가지 않는다. + +권장 순서: + +1. 기존 pred60 checkpoint로 `max_sessions 100`, `max_asofs 5`, `max_symbols 50` 대형 walk-forward를 먼저 실행한다. +2. rolling filter validation을 다시 실행한다. +3. rolling avg_test_net이 0 이상이거나 baseline 대비 개선폭이 강하면 `expand_200k` 학습을 실행한다. +4. `expand_200k` checkpoint를 같은 방식으로 평가한다. +5. 개선되면 `expand_1m`, 이후 `expand_5m`로 확대한다. +6. `expand_5m`에서도 개선이 반복될 때만 `full_window` 전량 학습을 검토한다. + +## 6. 성공/중단 기준 + +확대 학습 성공 기준: + +- rolling avg_test_net_return_pct > 0 +- baseline 대비 개선폭이 여러 fold에서 반복 +- random baseline 대비 방향성/Top-K 모두 우위 +- 거래 수가 너무 적지 않음 + +중단 기준: + +- 학습량을 늘렸는데 direction accuracy와 Top-K net이 개선되지 않음 +- 조건식이 특정 기간에서만 작동하고 rolling test에서 무너짐 +- 비용 후 net이 계속 음수 + +## 7. 현재 진행률 반영 + +```text +전체 데이터셋 구축 [█████] 100% +학습 루프 연결 [█████] 100% +20k budgeted 학습 [█████] 100% +200k 확대 학습 준비 [████░] 80% +1M/5M/full-window 실행 [░░░░░] 0% +대형 walk-forward 후 확대 여부 판단 [███░░] 60% +``` + +전체 프로젝트 진행률은 이번 단계 이후 **89%**로 본다. + +## 8. 2026-05-09 pred60 대형 walk-forward 게이트 결과 + +상세 보고서: `docs/stom_1s_large_walkforward_gate_report.md` + +이번 단계에서는 `expand_200k` 실제 학습으로 넘어가기 전에, 기존 pred60 `budget_20k` checkpoint를 더 큰 holdout walk-forward 표본으로 검증했다. + +핵심 수치: + +| 항목 | 값 | +| --- | ---: | +| selected windows | 3,080 | +| rebalance periods | 500 | +| rows per mode | 184,800 | +| Kronos direction accuracy | 0.4312 | +| random direction accuracy | 0.4084 | +| persistence direction accuracy | 0.1487 | +| Qlib Top-K avg net return | -0.1953% | +| best robust filter avg net return | -0.1266% | +| rolling avg test net return | -0.1766% | +| rolling positive test fold rate | 0.25 | + +판단: + +```text +expand_200k 실제 학습은 이번 단계에서 실행하지 않는다. +방향성 신호는 random보다 높지만, 비용 후 수익성과 rolling 안정성이 아직 기준 미달이다. +``` + +따라서 다음 단계는 학습량 확대가 아니라 score/filter 구조 개선, 비용 민감도 분석, pred30/pred60 ensemble 후보 검증이다. rolling 평균 test net이 0 이상으로 올라오고 여러 fold에서 반복 개선이 확인될 때만 `--sample-stage expand_200k` 학습으로 넘어간다. + +현재 판단: + +```text +Page 1 DB 구조 분석 [█████] 100% +Page 2 STOM tick OHLCV/QlibDataset 구축 [█████] 100% +Page 3 bounded/pilot 학습 검증 [████░] 70% +Page 4 1초봉 전체 학습 루프 연결 [█████] 100% +Page 5 30초/60초 20k 파인튜닝 [█████] 100% +Page 6 대형 walk-forward/rolling 검증 [█████] 95% +Page 7 웹 대시보드/검증 산출물 확인 [████░] 82% +Page 8 staged full-training 계획 [████░] 88% +Page 9 expand/full-window 실제 확대 학습 [░░░░░] 0% +전체 진행률 [█████░] 91% +``` + +주의: 여기서 전체 진행률은 “파이프라인 구축과 검증 체계” 기준이다. STOM tick의 모든 possible window를 실제로 끝까지 학습한 것은 아니며, 확대 학습은 게이트 미충족으로 보류한다. + +## 9. 2026-05-09 cost sensitivity gate 자동화 + +상세 보고서: `docs/stom_1s_cost_gate_analysis_report.md` + +`expand_200k`를 실행하기 전 확인해야 하는 gate를 코드로 고정했다. + +```powershell +python finetune\search_stom_1s_filters.py ` + --gate-analysis ` + --filter-report webui\qlib_backtests\stom_1s_pred60_walkforward100x5x50_eval_kronos.filter_search.json ` + --rolling-report webui\qlib_backtests\stom_1s_pred60_walkforward100x5x50_eval_kronos_rolling100x50.rolling_filter_validation.json ` + --total-cost-bps-grid 5,10,15,25 ` + --target-total-cost-bps 25 +``` + +현재 target 25bp 기준 결과: + +```text +rolling avg test net -0.1766% +positive fold rate 0.25 +total test trades 150 +gate result FAIL +decision hold_expand_200k +``` + +따라서 staged training 순서는 다음처럼 갱신한다. + +```text +1. score/filter 리디자인 +2. cost gate 재검증 +3. target 25bp gate 통과 시 expand_200k 실행 +4. expand_200k checkpoint 재평가 +5. 이후 1M/5M/full-window 확대 검토 +``` diff --git a/docs/stom_1s_walkforward_filter_report.md b/docs/stom_1s_walkforward_filter_report.md new file mode 100644 index 000000000..14aa26b30 --- /dev/null +++ b/docs/stom_1s_walkforward_filter_report.md @@ -0,0 +1,218 @@ +# STOM 1초봉 pred60 walk-forward 조건식 필터 검증 보고서 + +작성일: 2026-05-08 + +## 1. 목적 + +이 단계의 목적은 이전 제한 샘플 평가에서 `direction_accuracy=0.4444`로 보였던 +pred60 checkpoint가 더 넓은 holdout walk-forward 표본에서도 의미가 있는지 확인하고, +Kronos 예측값을 그대로 쓰지 않고 **예측 시점에 이미 알 수 있는 조건식**으로 보완할 수 +있는지 검증하는 것이다. + +검증 질문은 다음과 같다. + +1. 60초 후 방향성 적중률이 작은 표본에서만 우연히 높았는가? +2. Qlib-style Top-K 비용 차감 수익률이 개선되는가? +3. 조건식 필터가 미래 실제값을 보지 않고도 손실을 줄이는가? +4. 대시보드에서 실제값/예측값과 백테스트 artifact를 안전하게 확인할 수 있는가? + +## 2. 사용 데이터와 실행 조건 + +- 데이터: `finetune/qlib_exports/stom_1s_grid_pred60_full/processed_datasets` +- 모델: `finetune/outputs/stom_1s_grid_pred60_full_budget/finetune_predictor/checkpoints/best_model` +- lookback: 300초 +- horizon: 60초 +- 평가 범위: test split 중 30개 session, session당 3개 as-of 시각 +- 평가 window: 546개 +- 예측 row: 32,760개 +- 종목 수: 146개 +- 리밸런싱 period: 90개 +- Top-K: 5 +- 비용 가정: 수수료 15bp + 슬리피지 10bp = 25bp + +실행 명령: + +```powershell +python finetune\evaluate_stom_1s_checkpoint.py ` + --dataset-path finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets ` + --model-path finetune\outputs\stom_1s_grid_pred60_full_budget\finetune_predictor\checkpoints\best_model ` + --output-dir webui\stom_predictions ` + --prefix stom_1s_pred60_walkforward30x3_eval ` + --lookback-window 300 ` + --predict-window 60 ` + --max-symbols 20 ` + --max-asofs 3 ` + --max-sessions 30 ` + --stride 300 ` + --batch-size 4 ` + --top-k 5 ` + --device cuda:0 +``` + +## 3. 확장 walk-forward 방향성 결과 + +| 모델 | direction_accuracy | 평균 실제 등락률 | Top-K 평균 실제 등락률 | Top-K hit rate | +| --- | ---: | ---: | ---: | ---: | +| Kronos pred60 | 0.4084 | +0.0378% | +0.0134% | 0.4161 | +| Persistence | 0.1832 | +0.0378% | +0.0463% | 0.1767 | +| Random(seed 고정) | 0.4084 | +0.0378% | +0.0627% | 0.4004 | + +해석: + +- Kronos는 persistence보다 방향성에서는 크게 낫다. +- 그러나 같은 표본에서 random baseline도 0.4084가 나왔으므로 `0.4084` 자체를 강한 알파로 확정할 수 없다. +- Top-K gross는 플러스지만 1초봉 60초 매매에서 25bp 비용을 차감하면 수익성이 무너진다. + +## 4. Qlib-style Top-K 비용 차감 결과 + +실행 명령: + +```powershell +python finetune\qlib_stom_pipeline.py score-backtest ` + --prediction-csv webui\stom_predictions\stom_1s_pred60_walkforward30x3_eval_kronos.csv ` + --output-dir webui\qlib_backtests ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 +``` + +결과: + +| 항목 | 값 | +| --- | ---: | +| period_count | 90 | +| trade_count | 447 | +| avg_gross_return_pct | +0.0123% | +| avg_net_return_pct | -0.2377% | +| hit_rate | 0.4049 | +| direction_hit_rate | 0.4161 | +| cumulative_return_pct | -19.3225% | +| max_drawdown_pct | -19.3225% | + +판단: + +- 현재 모델을 조건식 없이 Top-K 매수 추천에 바로 쓰면 비용 후 손실이다. +- 60초 horizon에서 25bp 비용은 매우 큰 허들이므로, 예측 방향성만으로는 부족하다. + +## 5. 조건식 필터 탐색 방법 + +이번 조건식은 실제 미래값을 사용하지 않고, 예측 시점에 알 수 있는 값만 사용했다. + +사용한 입력: + +- `pred_return_window`: Kronos가 예측한 60초 후 등락률 +- `pred_path_consistency`: 예측 경로가 기준가 위/아래에 일관되게 머문 비율 +- `pred_range_pct`: 예측 경로의 변동 폭 +- `history_mean_amount`: lookback 구간 평균 거래대금 +- `history_volatility_pct`: lookback 구간 가격 변동성 + +탐색 명령: + +```powershell +python finetune\search_stom_1s_filters.py ` + --prediction-csv webui\stom_predictions\stom_1s_pred60_walkforward30x3_eval_kronos.csv ` + --output-dir webui\qlib_backtests ` + --prefix stom_1s_pred60_walkforward30x3_eval_kronos ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 ` + --min-trades 30 ` + --min-periods 30 ` + --min-coverage 0.5 +``` + +## 6. 조건식 탐색 결과 + +### 6.1 Robust 조건식 + +최소 coverage 50%, 최소 30 trades, 최소 30 periods 조건을 둔 보수적 탐색 결과다. + +| 항목 | 값 | +| --- | ---: | +| filter | `pred_return >= 0.05`, `history_volatility <= 0.2` | +| period_count | 56 | +| trade_count | 82 | +| coverage | 62.22% | +| avg_gross_return_pct | +0.0796% | +| avg_net_return_pct | -0.1008% | +| direction_hit_rate | 0.4634 | +| cumulative_return_pct | -5.5769% | +| baseline 대비 net 개선 | +0.1370%p | + +### 6.2 Opportunistic 조건식 + +coverage 10% 이상으로 더 공격적으로 좁힌 탐색 결과다. + +| 항목 | 값 | +| --- | ---: | +| filter | `pred_return >= 0.05`, `pred_range_pct <= 0.1`, `history_volatility <= 0.2` | +| period_count | 33 | +| trade_count | 42 | +| coverage | 36.67% | +| avg_gross_return_pct | +0.1995% | +| avg_net_return_pct | -0.0089% | +| direction_hit_rate | 0.4762 | +| cumulative_return_pct | -0.3629% | +| baseline 대비 net 개선 | +0.2288%p | + +해석: + +- 조건식은 손실을 확실히 줄였다. +- 예측 등락률이 충분히 양수이고, 예측 경로 폭과 직전 변동성이 낮은 구간만 고르면 비용 후 손실이 거의 0에 가까워진다. +- 그러나 아직 비용 후 양수 전환은 아니다. +- 이번 grid에서는 거래대금 quantile 조건이 최종 best에 선택되지 않았다. 현재 표본에서는 거래대금 조건보다 예측 등락률, 예측 경로 안정성, 직전 변동성이 더 직접적인 필터였다. + +## 7. 대시보드 반영 사항 + +평가 CSV에는 다음 예측 시점 feature가 추가되었다. + +- `pred_path_consistency` +- `pred_range_pct` +- `history_volatility_pct` +- `history_return_pct` +- `history_mean_amount` +- `history_last_amount` +- `history_mean_volume` +- `history_last_volume` + +또한 `webui/qlib_backtests`에는 일반 Qlib Top-K JSON과 filter-search JSON이 함께 생성된다. +기존 대시보드는 모든 JSON을 Qlib backtest로 표시했기 때문에 filter-search JSON을 선택하면 +`metrics`가 없어 오류가 날 수 있었다. 이번 단계에서 `metrics`가 있는 Qlib Top-K artifact만 +목록에 표시하도록 안전장치를 추가했다. + +## 8. 현재 결론 + +```text +방향성 신호: 제한적으로 존재 +랜덤 대비 우위: 아직 불충분 +비용 차감 수익성: 조건식 적용 후 크게 개선되지만 아직 양수 아님 +실전 추천 사용: 보류 +다음 필요 작업: 더 큰 walk-forward와 rolling train/test 방식의 조건식 과최적화 검증 +``` + +즉, `정확도 0.4라서 완전히 무의미하다`라기보다는 +`persistence보다는 낫고 조건식으로 손실을 크게 줄일 수 있으나, 아직 실제 매수 추천 시스템에 투입할 정도는 아니다`가 +현재의 정확한 결론이다. + +## 9. 다음 권장 검증 + +1. `max_sessions 100`, `max_asofs 5`, `max_symbols 50` 이상으로 확대 평가한다. +2. 조건식 탐색을 같은 데이터에서 고르고 같은 데이터에서 평가하지 말고, 앞쪽 session에서 조건식을 찾고 뒤쪽 session에서 검증하는 rolling 방식으로 바꾼다. +3. 비용을 25bp, 15bp, 5bp로 나누어 민감도 분석한다. +4. pred30/pred60 ensemble, 1초봉/1분봉 혼합 feature, 거래대금 상위 rank feature를 추가한다. +5. 대시보드에 filter-search 결과 표와 best-filter 적용 equity curve를 별도 패널로 추가한다. + +## 10. 후속 rolling 검증 링크 + +이 보고서 이후 같은 pred60 walk-forward CSV에 대해 rolling train/test 조건식 검증을 추가로 수행했다. + +상세 결과는 `docs/stom_1s_rolling_filter_validation_report.md`를 참조한다. + +핵심 결론: + +- rolling avg_test_net_return_pct: -0.0351% +- rolling avg_test_baseline_net_return_pct: -0.2438% +- baseline 대비 개선폭: +0.2087%p +- 비용 후 양수 전환: 실패 + +따라서 조건식은 baseline 대비 손실을 줄이지만, 아직 실전 자동 매수 승인 조건은 아니다. diff --git a/docs/stom_2025_dataset_export_report.md b/docs/stom_2025_dataset_export_report.md new file mode 100644 index 000000000..a567661f9 --- /dev/null +++ b/docs/stom_2025_dataset_export_report.md @@ -0,0 +1,181 @@ +# 2025년 STOM tick pred60 processed dataset export 보고서 + +작성일: 2026-05-11 KST +목적: 2025년 STOM tick 전체 데이터를 Kronos-small 공식 학습에 사용할 수 있는 `processed_datasets` 형식으로 생성하고 검증한다. +상위 목표: 2025년 전체 학습 → 실제값/예측값 대시보드 검증 → 성과 개선 시 Kronos-base/전체 연도 확대 판단 + +## 1. 이번 단계의 위치 + +이 단계는 **본 학습이 아니라 본 학습 직전 데이터셋 생성 단계**다. + +```text +전체 진행률: ██████████████████░░ 90% +현재 단계: ████████████████████ 100% 2025 processed dataset export 완료 +남은 단계: ██░░░░░░░░░░░░░░░░░░ 10% 2025 full training → 평가/대시보드 +``` + +방향성: + +1. 2025년만 분리한 데이터셋을 만든다. +2. train/val/test split이 session 기준으로 분리됐는지 확인한다. +3. full training 명령의 train/val sample 수를 export report 기준으로 다시 고정한다. +4. 그 다음에만 8일 이상 걸리는 Kronos-small 전체 학습을 시작한다. + +## 2. 실행 명령 + +```powershell +C:\Python\64\Python3119\python.exe finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_grid_pred60_2025 ` + --lookback-window 300 ` + --predict-window 60 ` + --horizon-seconds 60 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --session-start 20250101 ` + --session-end 20251231 ` + --freq 1s ` + --regularize-1s ` + --split-by session +``` + +실행 시간: + +| 항목 | 값 | +|---|---:| +| 시작 | 2026-05-11 11:04:29 | +| 종료 | 2026-05-11 11:28:22 | +| 소요 시간 | 1,433.24초, 약 23분 53초 | +| exit code | 0 | + +## 3. 생성 산출물 + +경로: + +```text +finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets +``` + +| 파일 | 크기 | +|---|---:| +| `train_data.pkl` | 1,325,464,369 bytes, 약 1.234GB | +| `val_data.pkl` | 276,595,451 bytes, 약 0.258GB | +| `test_data.pkl` | 273,245,379 bytes, 약 0.254GB | +| `stom_qlib_export_report.json` | 1,299,962 bytes, 약 1.24MB | + +주의: 위 산출물은 대용량 학습 데이터이므로 `.gitignore` 대상이며 커밋하지 않는다. + +## 4. export report 검증 결과 + +주요 설정: + +| 항목 | 값 | +|---|---| +| `session_start` | `20250101` | +| `session_end` | `20251231` | +| `lookback_window` | 300 | +| `predict_window` | 60 | +| `horizon_seconds` | 60 | +| `freq` | `1s` | +| `regularize_1s` | true | +| `split_by` | `session` | +| `price_mode` | `close_only` | + +전체 export: + +| 항목 | 값 | +|---|---:| +| selected_table_count | all | +| exported_group_count | 18,750 | +| exported_row_count | 33,360,325 | +| regularized_groups | 18,750 | +| inserted_rows | 2,925,081 | +| warnings_count | 1 | + +split 결과: + +| split | sessions | first | last | groups | rows | 가능한 pred60 samples | +|---|---:|---|---|---:|---:|---:| +| train | 168 | 20250103 | 20250910 | 13,256 | 23,579,043 | 18,806,883 | +| val | 36 | 20250911 | 20251106 | 2,764 | 4,920,437 | 3,925,397 | +| test | 36 | 20251107 | 20251230 | 2,730 | 4,860,845 | 3,878,045 | + +계산식: + +```text +possible_samples = rows - groups * (lookback_window + predict_window) + = rows - groups * 360 +``` + +## 5. preflight 재검증 결과 + +export 후 preflight를 다시 실행했다. + +결과: + +| 항목 | 값 | +|---|---| +| status | `ready_with_actions` | +| blockers | 0개 | +| warnings | 0개 | +| export_report_loaded | true | +| target sample source | `export_report` | +| next_action | `run_training_2025_full_small` | + +학습 대상 샘플 수가 scan report 기준이 아니라 실제 export report 기준으로 재계산되도록 보정했다. + +| 항목 | 값 | +|---|---:| +| train samples | 18,806,883 | +| validation samples | 3,925,397 | +| train+validation | 22,732,280 | + +## 6. 다음 본 학습 명령 + +다음 단계에서 실행할 명령은 아래와 같다. + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage both ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --run-name stom_1s_grid_pred60_2025_full_small ` + --dataset-sample-mode full_sequential ` + --batch-size 4 ` + --num-workers 0 ` + --n-train-iter 18806883 ` + --n-val-iter 3925397 ` + --log-interval 1000 +``` + +dry-run 검증 결과: + +- tokenizer stage manifest 생성 확인 +- predictor stage manifest 생성 확인 +- predictor stage의 `KRONOS_FINETUNED_TOKENIZER_PATH`가 같은 run의 tokenizer checkpoint로 연결됨 + +## 7. 남은 단계 + +| 단계 | 내용 | 상태 | +|---:|---|---| +| 1 | 2025년 preflight | 완료 | +| 2 | 2025년 processed dataset export | 완료 | +| 3 | 2025년 Kronos-small tokenizer→predictor full training | 다음 단계 | +| 4 | 학습 checkpoint 검증 | 남음 | +| 5 | 예측 CSV 생성 | 남음 | +| 6 | 웹 대시보드 실제값/예측값/종목별 통계 검증 | 남음 | +| 7 | 성과 개선 시 Kronos-base/전체 연도 확대 판단 | 남음 | + +## 8. 다음 권장 OMX 명령 + +```text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습을 checkpoint/resume와 절전 방지 조건을 확인한 뒤 실행하고, tokenizer/predictor 로그와 checkpoint 생성 여부를 주기적으로 점검하며 완료 후 문서와 commit으로 남기세요. +``` + +주의: + +- 다음 단계부터는 실제 학습이며 4080 Super 기준 약 8일 이상 걸릴 수 있다. +- 실행 전 Windows 절전 방지, 충분한 냉각, 로그 백업, 중단 시 재개 전략을 다시 확인해야 한다. +- 본 학습이 끝나기 전까지 정확도/예측률 개선 여부는 판단할 수 없다. diff --git a/docs/stom_2025_full_small_walkforward_eval_dashboard.md b/docs/stom_2025_full_small_walkforward_eval_dashboard.md new file mode 100644 index 000000000..eab52880a --- /dev/null +++ b/docs/stom_2025_full_small_walkforward_eval_dashboard.md @@ -0,0 +1,306 @@ +# STOM 2025 full-small predictor 평가 및 대시보드 연결 보고 + +작성일: 2026-05-22 KST + +## 1. 목적 + +완료된 `stom_1s_grid_pred60_2025_full_small` predictor checkpoint가 실제로 예측에 사용 가능한지 확인하고, 사용자가 웹 대시보드에서 실제값과 예측값, 방향 적중률, 종목별 통계, Top-K 후보를 이해하기 쉽게 확인하도록 연결했다. + +## 2. 사용 모델과 데이터 + +| 항목 | 값 | +| --- | --- | +| 모델 run | `stom_1s_grid_pred60_2025_full_small` | +| predictor checkpoint | `finetune/outputs/stom_1s_grid_pred60_2025_full_small/finetune_predictor/checkpoints/best_model` | +| tokenizer checkpoint | `finetune/outputs/stom_1s_grid_pred60_2025_full_small/finetune_tokenizer/checkpoints/latest_train_model` | +| 데이터셋 | `finetune/qlib_exports/stom_1s_grid_pred60_2025/processed_datasets` | +| split | test split | +| 예측 horizon | 60초 | +| lookback | 300초 | +| 평가 범위 | 2025-11-07 ~ 2025-12-30 test session 전체, 각 session 3개 as-of, 최대 50종목 | + +## 3. 실행 명령 + +```powershell +C:\Python\64\Python3119\python.exe finetune\evaluate_stom_1s_checkpoint.py ` + --dataset-path finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --model-path finetune\outputs\stom_1s_grid_pred60_2025_full_small\finetune_predictor\checkpoints\best_model ` + --tokenizer-path finetune\outputs\stom_1s_grid_pred60_2025_full_small\finetune_tokenizer\checkpoints\latest_train_model ` + --output-dir webui\stom_predictions ` + --prefix stom_1s_pred60_2025_full_small_walkforward36x3x50_eval ` + --lookback-window 300 ` + --predict-window 60 ` + --max-symbols 50 ` + --max-asofs 3 ` + --max-sessions 36 ` + --stride 300 ` + --batch-size 8 ` + --top-k 5 ` + --device cuda:0 +``` + +Qlib-style Top-K 비용 반영 점검: + +```powershell +C:\Python\64\Python3119\python.exe finetune\qlib_stom_pipeline.py score-backtest ` + --prediction-csv webui\stom_predictions\stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos.csv ` + --output-dir webui\qlib_backtests ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 +``` + +## 4. 산출물 + +| 종류 | 경로 | +| --- | --- | +| Kronos 예측 CSV | `webui/stom_predictions/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos.csv` | +| persistence baseline CSV | `webui/stom_predictions/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_persistence.csv` | +| random baseline CSV | `webui/stom_predictions/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_random.csv` | +| 비교 JSON | `webui/stom_predictions/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_comparison.json` | +| Qlib Top-K JSON | `webui/qlib_backtests/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos.qlib_topk5.json` | + +## 5. 핵심 결과 + +| 지표 | Kronos | Persistence | Random | +| --- | ---: | ---: | ---: | +| rows | 40,860 | 40,860 | 40,860 | +| windows | 681 | 681 | 681 | +| symbols | 155 | 155 | 155 | +| direction accuracy | 0.4479 | 0.1204 | 0.4493 | +| MAPE | 0.8730 | 0.3852 | 0.3906 | +| 평균 예측 등락률 | -0.0218% | 0.0000% | 0.0041% | +| 평균 실제 등락률 | 0.0598% | 0.0598% | 0.0598% | +| Top-K hit rate | 0.4667 | 0.1241 | 0.4352 | +| Top-K 평균 실제 등락률 | 0.0459% | 0.0711% | 0.0758% | + +비용 반영 Qlib-style Top-K: + +| 항목 | 값 | +| --- | ---: | +| period count | 108 | +| trade count | 540 | +| avg gross return | 0.0459% | +| avg net return | -0.2041% | +| cumulative return | -19.8646% | +| max drawdown | -20.4773% | +| direction hit rate | 0.4667 | + +## 6. 해석 + +- 모델은 정상 로드되고 2025년 test split 전체 거래일 범위에서 예측 CSV를 생성했다. +- direction accuracy는 0.4479로 0.40 기준선은 넘었지만 random baseline 0.4493과 거의 같아, 방향성만으로 우위가 확정되지는 않는다. +- MAPE는 persistence/random보다 높아 가격 경로 자체는 baseline보다 거칠다. +- Top-K hit rate는 persistence보다 높지만, 비용 25bp를 반영하면 평균 순수익과 누적수익이 음수다. +- 따라서 현재 checkpoint는 “탐색/시각화/조건식 연구용”으로는 의미가 있지만, 실전 자동매매 신호로 바로 사용하기에는 부족하다. + +## 7. 대시보드 업데이트 + +`http://127.0.0.1:5070/training` → `예측 진단` 탭에서 최신 Kronos CSV를 자동 선택하고 다음을 표시한다. + +1. 사용자용 판정 카드: 탐색 가치 / 조건식 보완 / 실전 보류 +2. 방향 적중률, MAPE, 평가 windows/symbols/sessions, 평균 실제 등락률 KPI +3. 조건식/Top-K 필터별 hit rate와 실제 수익률 +4. 선택 window의 실제 종가 vs Kronos 예측 종가 차트 +5. 전체 window의 예측 등락률 vs 실제 등락률 산점도 +6. Kronos 점수 상위 후보 테이블 +7. 상위/주의 종목별 방향 적중률과 MAPE + +## 8. 다음 단계 + +1. 비용 음수 문제를 줄이기 위해 score filter, 거래대금/체결강도/변동성 조건을 추가한다. +2. random과 거의 같은 direction accuracy를 개선하기 위해 seed/epoch/model size 비교를 진행한다. +3. full as-of 평가를 원하면 `max-asofs`를 3보다 크게 늘려 더 촘촘한 walk-forward 평가를 실행한다. +4. 대시보드에서 후보를 클릭하면 해당 window 차트로 전환하는 상호작용을 추가한다. + +## 9. 조건식 / 비용 필터 최적화 1차 실행 결과 + +작성일: 2026-05-22 KST + +위 8번의 다음 단계 중 “비용 음수 문제를 줄이기 위한 score filter / 거래대금 / 변동성 조건”을 먼저 실행했다. + +### 9.1 실행 명령 + +```powershell +$pred = 'webui\stom_predictions\stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos.csv' +$prefix = 'stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos_cost25' + +C:\Python\64\Python3119\python.exe finetune\search_stom_1s_filters.py ` + --prediction-csv $pred ` + --output-dir webui\qlib_backtests ` + --prefix $prefix ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 ` + --min-trades 10 ` + --min-periods 3 ` + --min-coverage 0.5 + +C:\Python\64\Python3119\python.exe finetune\search_stom_1s_filters.py ` + --prediction-csv $pred ` + --output-dir webui\qlib_backtests ` + --prefix $prefix ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 ` + --min-trades 10 ` + --min-periods 3 ` + --min-coverage 0.5 ` + --rolling-validate ` + --rolling-train-periods 30 ` + --rolling-test-periods 10 ` + --rolling-step-periods 10 + +C:\Python\64\Python3119\python.exe finetune\search_stom_1s_filters.py ` + --output-dir webui\qlib_backtests ` + --prefix $prefix ` + --gate-analysis ` + --filter-report webui\qlib_backtests\${prefix}.filter_search.json ` + --rolling-report webui\qlib_backtests\${prefix}.rolling_filter_validation.json ` + --total-cost-bps-grid 5,10,15,25 ` + --min-avg-test-net-pct 0 ` + --min-positive-test-fold-rate 0.5 ` + --min-improvement-net-pct 0 ` + --min-total-test-trades 100 +``` + +### 9.2 산출물 + +| 종류 | 경로 | +| --- | --- | +| 필터 탐색 JSON | `webui/qlib_backtests/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos_cost25.filter_search.json` | +| 필터 Top20 CSV | `webui/qlib_backtests/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos_cost25.filter_search_top20.csv` | +| rolling 검증 JSON | `webui/qlib_backtests/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos_cost25.rolling_filter_validation.json` | +| cost gate JSON | `webui/qlib_backtests/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos_cost25.cost_gate.json` | + +### 9.3 기본 Top-K 대비 최적 필터 + +| 항목 | 기본 Top-K | 최적 필터 | +| --- | ---: | ---: | +| 필터 | 없음 | `ret>=0.05`, `cons>=0.5`, `amount q>=0.75` | +| 평가 period | 108 | 56 | +| trade 수 | 540 | 87 | +| coverage | 100.00% | 51.85% | +| 평균 gross return | 0.0459% | 0.1590% | +| 평균 net return, 비용 25bp | -0.2041% | -0.0168% | +| direction hit rate | 46.67% | 47.13% | +| 누적수익 | -19.8646% | -1.1639% | + +해석: + +- 필터를 걸면 무조건 매수하는 기본 Top-K보다 손실은 크게 줄어든다. +- 하지만 25bp 비용을 넣으면 평균 순수익이 아직 음수다. +- 방향 적중률도 47.13%로 50%를 넘지 못했다. + +### 9.4 Rolling validation 결과 + +| 항목 | 값 | +| --- | ---: | +| fold 수 | 7 | +| 총 test trade | 66 | +| 평균 train net return | 0.0831% | +| 평균 test net return | -0.2043% | +| 평균 test baseline net return | -0.1726% | +| test 개선폭 | -0.0317% | +| test 방향 적중률, weighted | 39.39% | +| 양수 fold 비율 | 28.57% | +| overfit gap | 0.2874% | + +Cost sensitivity: + +| 총 비용 | rolling test net | passes gate | +| ---: | ---: | --- | +| 5bp | -0.0043% | false | +| 10bp | -0.0543% | false | +| 15bp | -0.1043% | false | +| 25bp | -0.2043% | false | + +### 9.5 결정 + +`cost_gate.json`의 결정은 다음과 같다. + +| 항목 | 값 | +| --- | --- | +| decision | `hold_expand_200k` | +| expand_training_allowed | `false` | +| reason | target cost scenario failed at least one rolling profitability/stability gate | + +즉, 현재 checkpoint와 현재 조건식만으로는 200k / 전체 확장 학습을 바로 진행하기보다, 먼저 다음 문제를 해결하는 것이 안전하다. + +1. 방향 적중률이 random 수준을 넘지 못하는 문제 +2. train 구간에서 고른 필터가 test 구간에서 무너지는 과최적화 문제 +3. 1초/60초 초단기 horizon에서 25bp 비용을 이기기 어려운 문제 +4. 가격 경로 MAPE가 persistence/random보다 높은 문제 + +### 9.6 현재 결론 + +파인튜닝은 실패라기보다 “학습은 정상 완료됐지만 현재 설정에서는 실전 신호력이 부족한 상태”다. 모델이 CSV를 만들고, checkpoint가 정상 로드되고, 대시보드에서 실제/예측 비교까지 가능하므로 파이프라인은 동작한다. 다만 예측 품질은 아직 실전 기준을 통과하지 못했다. + +다음 권장 작업은 모델을 무작정 크게 돌리는 것이 아니라, 먼저 아래 중 하나를 비교 실험하는 것이다. + +1. horizon을 30초, 60초, 120초, 300초로 나눠 실제로 비용을 이길 수 있는 구간 찾기 +2. 방향 분류 / 등락률 회귀를 분리해 loss와 평가 지표를 목적에 맞게 조정 +3. 종목별 정규화와 장초반 이벤트성 특징을 강화 +4. rolling validation에서 통과하는 조건식만 대시보드에 “실전 후보”로 표시 + +## 10. Horizon 30/60/120/300초 비교 실행 결과 + +작성일: 2026-05-22 KST + +권장 OMX 단계에 따라 동일한 `stom_1s_grid_pred60_2025_full_small` checkpoint로 30초, 60초, 120초, 300초 예측을 같은 walk-forward 범위에서 비교했다. + +공통 조건: + +| 항목 | 값 | +| --- | --- | +| 평가 범위 | 2025-11-07 ~ 2025-12-30 test split | +| session 수 | 36 | +| as-of | session당 최대 3개 | +| 종목 | as-of당 최대 50개 | +| lookback | 300초 | +| 비용 | 25bp = 수수료 15bp + 슬리피지 10bp | + +### 10.1 Horizon별 핵심 비교 + +| horizon | Kronos 방향 적중률 | random 방향 적중률 | random 대비 | Top-K net | 최적 필터 net | rolling net | rolling 방향 | gate | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| 30초 | 39.21% | 38.77% | +0.44%p | -0.2522% | -0.0465% | -0.2398% | 32.08% | 실패 | +| 60초 | 44.79% | 44.93% | -0.15%p | -0.2041% | -0.0168% | -0.2043% | 39.39% | 실패 | +| 120초 | 45.67% | 42.73% | +2.94%p | -0.1735% | -0.0335% | -0.2903% | 49.35% | 실패 | +| 300초 | 49.19% | 46.26% | +2.94%p | -0.1145% | +0.0922% | -0.0052% | 44.29% | 실패 | + +### 10.2 해석 + +- 30초는 너무 짧아 노이즈가 크고 방향 적중률도 40% 미만이다. +- 60초는 기존 주 실험 horizon이지만 random과 거의 같아 우위가 없다. +- 120초는 random 대비 방향성 edge가 생기지만 rolling net이 더 나빠 실전 안정성이 부족하다. +- 300초는 현재 비교 중 가장 유망하다. 방향 적중률 49.19%, random 대비 +2.94%p, 최적 필터 in-sample net +0.0922%, rolling net -0.0052%로 손익분기점에 가장 가깝다. +- 다만 300초도 rolling cost gate를 통과하지 못했으므로 아직 “확장 학습/실전 사용 승인”은 아니다. + +### 10.3 대시보드 반영 + +`http://127.0.0.1:5070/training` → `예측 진단` 상단에 `HORIZON COMPARISON · 30/60/120/300초 비교` 섹션을 추가했다. + +표시 항목: + +1. horizon별 Kronos 방향 적중률 +2. random baseline 방향 적중률 +3. random 대비 edge +4. Top-K 비용 반영 net +5. 최적 필터 net +6. rolling validation net +7. rolling 방향 적중률 +8. gate 통과/보류 여부 + +현재 dashboard 판정 문구는 다음과 같다. + +> 300초가 상대적으로 가장 유망하지만 아직 rolling cost gate는 통과하지 못했습니다. + +### 10.4 다음 판단 + +현재 결과만 보면 다음 실험 우선순위는 300초다. + +1. 300초 horizon 중심으로 조건식 grid를 더 세분화한다. +2. 300초 전용 predictor fine-tuning을 따로 진행한다. 지금은 60초 목적의 run에서 300초 예측을 확장 평가한 것이므로 목적 불일치가 있다. +3. 비용 25bp를 이기기 어렵다면 실제 체결 비용을 5/10/15/25bp로 나누어 현실적인 수수료/슬리피지 가정을 재검토한다. +4. 300초가 rolling net 0% 근처까지 왔으므로 거래 횟수를 줄이는 고확신 필터, 종목 유동성 필터, 변동성 필터를 더 강화한다. diff --git a/docs/stom_2025_full_training_launch_report.md b/docs/stom_2025_full_training_launch_report.md new file mode 100644 index 000000000..70a78f0f6 --- /dev/null +++ b/docs/stom_2025_full_training_launch_report.md @@ -0,0 +1,201 @@ +# 2025년 STOM tick pred60 Kronos-small 전체 학습 시작 보고서 + +작성일: 2026-05-11 KST +목적: 2025년 STOM tick pred60 전체 데이터셋으로 Kronos-small 공식 tokenizer→predictor full training을 실제로 시작하고, `/training` 대시보드에서 초기 progress/log/GPU 갱신을 확인한다. + +## 1. 이번 단계의 위치 + +이번 단계는 **본 학습을 백그라운드로 시작하고 초기 학습이 실제로 도는지 확인하는 단계**다. 전체 학습 완료 단계가 아니다. + +```text +전체 진행률: ███████████████████░ 97% +현재 단계: ████████████████████ 100% 2025 full training 시작 및 초기 live 검증 완료 +남은 단계: █░░░░░░░░░░░░░░░░░░░ 3% 장기 학습 완료 → checkpoint → 예측/성과 검증 +``` + +방향성: + +1. 2025년 전체 데이터셋을 사용한다. +2. Kronos 공식 순서인 tokenizer → predictor를 지킨다. +3. 학습 완료까지 기다리지 않고 먼저 live progress/log/GPU가 정상 갱신되는지 확인한다. +4. 장기 학습 완료 후 checkpoint로 예측 CSV를 만들고 `/stom`에서 실제값/예측값을 검증한다. + +## 2. 시작 전 확인 + +| 항목 | 결과 | +|---|---| +| git 상태 | clean, `master...origin/master [ahead 49]` 상태에서 시작 | +| 중복 학습 프로세스 | 없음 | +| 2025 processed dataset | 존재 | +| `/training` 서버 | `http://127.0.0.1:5070/training`, 200 OK | +| GPU | NVIDIA GeForce RTX 4080 SUPER | + +데이터셋 파일: + +| 파일 | 크기 | +|---|---:| +| `train_data.pkl` | 1,325,464,369 bytes | +| `val_data.pkl` | 276,595,451 bytes | +| `test_data.pkl` | 273,245,379 bytes | + +## 3. 실제 실행 명령 + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage both ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --run-name stom_1s_grid_pred60_2025_full_small ` + --dataset-sample-mode full_sequential ` + --batch-size 4 ` + --num-workers 0 ` + --n-train-iter 18806883 ` + --n-val-iter 3925397 ` + --log-interval 1000 +``` + +백그라운드 프로세스: + +| 역할 | PID | +|---|---:| +| runner `run_stom_1s_finetune.py` | 3944 | +| child `train_tokenizer.py` | 70448 | +| `/training` webui parent | 147452 | +| `/training` webui child/reloader | 101468 | + +런타임 로그: + +```text +.omx/training-launch/stom_2025_full_runner.out.log +.omx/training-launch/stom_2025_full_runner.err.log +finetune/outputs/stom_1s_grid_pred60_2025_full_small/logs/tokenizer.stdout.log +finetune/outputs/stom_1s_grid_pred60_2025_full_small/logs/tokenizer.progress.json +``` + +주의: 위 런타임 산출물과 학습 output은 `.gitignore` 대상이며 커밋하지 않는다. + +## 4. 초기 live 검증 결과 + +학습은 tokenizer 단계에서 정상 시작되었다. + +데이터셋/step 확인: + +| 항목 | 값 | +|---|---:| +| train samples | 18,806,883 | +| validation samples | 3,925,397 | +| tokenizer train steps/epoch | 4,701,721 | +| validation steps | 981,350 | +| batch size | 4 | +| sample mode | `full_sequential` | + +첫 training step 로그 확인: + +```text +[Rank 0, Epoch 1/1, Step 1000/4701721] LR 0.000020, Loss: -0.0308 +[Rank 0, Epoch 1/1, Step 2000/4701721] LR 0.000020, Loss: -0.0299 +``` + +`/api/training/status` 확인 시점의 핵심 값: + +| 항목 | 값 | +|---|---:| +| status | `running` | +| current stage | `tokenizer` | +| step | 2,000 / 4,701,721 | +| tokenizer stage percent | 0.0425% | +| overall percent | 0.0213% | +| last loss | -0.0299 | +| samples/sec | 약 50.52 | +| GPU utilization | 약 37~40% | +| VRAM 사용량 | 약 3,109 MiB / 16,376 MiB | +| GPU 온도 | 약 46~51°C | + +브라우저 검증: + +- URL: `http://127.0.0.1:5070/training` +- 확인: run 목록, `running`, tokenizer stage, step/loss, GPU, log tail 표시 +- console/page error: 0개 +- 검증 artifact: `.omx/training-launch/training_dashboard_live_step_check.json`, `.omx/training-launch/training_dashboard_live_step.png` + +## 5. 시간 해석 + +대시보드의 초기 ETA는 tokenizer stage 기준으로 약 4일대가 표시되었다. 다만 이 값은 step 1,000~2,000 근처의 매우 초기 속도 기준이므로 흔들릴 수 있다. + +운영 판단은 여전히 다음처럼 잡는다. + +```text +보수적 전체 예상: 8~9일 +초기 관측 기반 tokenizer 예상: 약 4일대 +predictor까지 포함한 전체 완료 시점은 계속 모니터링 필요 +``` + +## 6. 현재 결론 + +2025년 STOM tick pred60 Kronos-small 전체 학습은 실제로 시작되었고, `/training` 대시보드도 실제 학습 progress/log/GPU를 표시하고 있다. + +현재 완료된 것: + +- full training background launch 완료 +- tokenizer child process 실행 확인 +- 전체 train/val sample 수 확인 +- step/loss 로그 확인 +- progress JSON 갱신 확인 +- `/training` 브라우저 표시 확인 + +아직 완료되지 않은 것: + +- tokenizer epoch 완료 +- tokenizer checkpoint 저장 +- predictor 학습 시작/완료 +- 최종 checkpoint 검증 +- 예측 CSV 생성 +- `/stom` 실제값/예측값/종목별 통계 검증 + +## 7. 모니터링 명령 + +상태 확인: + +```powershell +Get-Content finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.progress.json -Tail 80 +``` + +로그 확인: + +```powershell +Get-Content finetune\outputs\stom_1s_grid_pred60_2025_full_small\logs\tokenizer.stdout.log -Tail 40 +``` + +프로세스 확인: + +```powershell +Get-CimInstance Win32_Process -Filter "Name='python.exe'" | + Where-Object { $_.CommandLine -match 'run_stom_1s_finetune|train_tokenizer|train_predictor' } | + Select-Object ProcessId,CommandLine +``` + +GPU 확인: + +```powershell +nvidia-smi +``` + +웹 확인: + +```text +http://127.0.0.1:5070/training +``` + +## 8. 다음 권장 OMX 명령 + +```text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습의 tokenizer 단계가 계속 정상 진행 중인지 progress/log/GPU를 재점검하고, checkpoint 생성 전까지 중간 상태를 문서와 commit으로 주기적으로 남기세요. +``` + +중요: + +- 학습 프로세스를 종료하지 말 것. +- Windows 절전/재부팅을 피할 것. +- 학습 산출물은 커밋하지 말 것. +- tokenizer 완료 후 predictor가 자동 시작되는지 확인할 것. diff --git a/docs/stom_2025_full_training_preflight.md b/docs/stom_2025_full_training_preflight.md new file mode 100644 index 000000000..7ae7e3c6a --- /dev/null +++ b/docs/stom_2025_full_training_preflight.md @@ -0,0 +1,257 @@ +# 2025년 STOM tick Kronos-small 전체 학습 Preflight 보고서 + +작성일: 2026-05-11 KST +대상 단계: 2025년 STOM tick 전체 데이터 → Kronos-small 공식 tokenizer→predictor 학습 전 점검 +관련 상위 문서: + +- `docs/stom_kronos_conversation_work_summary.md` +- `docs/stom_gpu_rental_kronos_training_plan.md` +- `docs/stom_kronos_official_execution_plan.md` + +## 1. 이번 단계의 목표 + +이번 단계는 8일 이상 걸릴 수 있는 본 학습을 바로 시작하지 않고, 먼저 다음 조건을 자동으로 확인하는 것이다. + +1. STOM tick DB를 read-only로 열 수 있는지 +2. 2025년 구간만 export할 수 있는 코드 경로가 있는지 +3. CUDA/VRAM/디스크 여유 공간이 본 학습에 적합한지 +4. 2025년 train/validation 샘플 수가 공식 학습 명령에 반영되는지 +5. tokenizer→predictor 공식 순서와 checkpoint handoff가 유지되는지 +6. 다음 장기 실행 명령을 사람이 그대로 복사해도 되는지 + +## 2. 이번 커밋에서 추가/수정한 내용 + +| 파일 | 내용 | +|---|---| +| `.gitignore` | `.omx/` 런타임/분석 산출물이 실수로 커밋되지 않도록 제외 | +| `finetune_csv/stom_tick_dataset.py` | `session_start`, `session_end` 필터 추가 | +| `finetune/qlib_stom_pipeline.py` | Qlib/Kronos export 명령에 `--session-start`, `--session-end` 추가 | +| `finetune/preflight_stom_2025_full.py` | 2025년 전체 학습 전 DB/CUDA/디스크/샘플/명령어 preflight 자동화 | +| `tests/test_stom_2025_preflight.py` | preflight 명령 생성 및 DB read-only 검증 테스트 | +| `tests/test_stom_qlib_pipeline.py` | session range export 테스트 추가 | +| `tests/test_stom_tick_dataset.py` | 개별 STOM table session range 읽기 테스트 추가 | + +## 3. 실제 preflight 실행 결과 + +실행 명령: + +```powershell +C:\Python\64\Python3119\python.exe finetune\preflight_stom_2025_full.py ` + --python-exe C:\Python\64\Python3119\python.exe ` + --json-output .omx\analysis\stom_2025_full_preflight_report.json +``` + +핵심 결과: + +| 항목 | 결과 | +|---|---:| +| 상태 | `ready_with_actions` | +| blocker | 0개 | +| 경고 | 2025년 processed dataset이 아직 없음 → 다음 단계에서 export 필요 | +| DB 크기 | 27.69GB | +| DB table count | 2,427 | +| DB read-only/query_only | 통과 | +| DB write probe | `attempt to write a readonly database`로 차단됨 | +| GPU | NVIDIA GeForce RTX 4080 SUPER | +| VRAM | 15.99GB | +| PyTorch | 2.9.0+cu128 | +| CUDA | 사용 가능 | +| D: 여유 공간 | 약 538.64GB | + +2025년 샘플 기준: + +| split | samples | +|---|---:| +| train | 18,771,531 | +| validation | 3,922,758 | +| train+validation | 22,694,289 | + +4080 Super 예상 시간: + +```text +약 192.81시간 = 약 8.03일 +``` + +이 시간은 공식 200k tokenizer+predictor 실측치 `7,340.567561초 / 240k samples`를 선형 환산한 값이다. 실제 본 학습은 디스크, 절전, 발열, dataloader 상태에 따라 8.5~9일로 보는 것이 안전하다. + +## 4. 2025년 dataset export 명령 + +다음 단계에서 먼저 실행할 명령이다. + +```powershell +C:\Python\64\Python3119\python.exe finetune/qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_grid_pred60_2025 ` + --lookback-window 300 ` + --predict-window 60 ` + --horizon-seconds 60 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --session-start 20250101 ` + --session-end 20251231 ` + --freq 1s ` + --regularize-1s ` + --split-by session +``` + +예상 산출물: + +```text +finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets\train_data.pkl +finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets\val_data.pkl +finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets\test_data.pkl +finetune\qlib_exports\stom_1s_grid_pred60_2025\stom_qlib_export_report.json +``` + +## 5. 2025년 Kronos-small 본 학습 명령 + +2025년 processed dataset 생성 후 실행할 명령이다. + +```powershell +C:\Python\64\Python3119\python.exe finetune/run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage both ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --run-name stom_1s_grid_pred60_2025_full_small ` + --dataset-sample-mode full_sequential ` + --batch-size 4 ` + --num-workers 0 ` + --n-train-iter 18771531 ` + --n-val-iter 3922758 ` + --log-interval 1000 +``` + +공식 순서: + +1. `train_tokenizer.py` +2. `train_predictor.py` +3. predictor 실행 시 tokenizer checkpoint 자동 전달 + +## 6. Smoke 검증 + +### 6.1 2025 session filter export smoke + +실행: + +```powershell +C:\Python\64\Python3119\python.exe finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir .omx\smoke\stom_2025_session_filter_export_smoke ` + --max-tables 3 ` + --lookback-window 300 ` + --predict-window 60 ` + --horizon-seconds 60 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --session-start 20250101 ` + --session-end 20251231 ` + --freq 1s ` + --regularize-1s ` + --split-by session +``` + +결과: + +| 항목 | 값 | +|---|---:| +| selected_table_count | 3 | +| exported_group_count | 5 | +| exported_row_count | 8,786 | +| split sessions | 모두 2025년 | + +### 6.2 tokenizer→predictor dry-run + +실행: + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode smoke ` + --train-stage both ` + --dataset-dir .omx\smoke\stom_2025_session_filter_export_smoke\processed_datasets ` + --run-name stom_2025_preflight_dryrun ` + --dataset-sample-mode full_sequential ` + --batch-size 1 ` + --num-workers 0 ` + --n-train-iter 3 ` + --n-val-iter 1 ` + --dry-run +``` + +결과: + +- tokenizer manifest 생성 확인 +- predictor manifest 생성 확인 +- predictor의 `KRONOS_FINETUNED_TOKENIZER_PATH`가 tokenizer checkpoint 경로로 연결되는 것 확인 + +## 7. 테스트 결과 + +실행: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest ` + tests/test_stom_2025_preflight.py ` + tests/test_stom_qlib_pipeline.py ` + tests/test_stom_tick_dataset.py ` + -q +``` + +결과: + +```text +13 passed, 1 warning +``` + +## 8. 전체 계획/현재 단계/남은 단계 + +```text +전체 진행률: ████████████████░░░░ 80% +현재 단계: ████████████████████ 100% 2025 full preflight 완료 +남은 단계: ████░░░░░░░░░░░░░░░░ 20% export → 학습 → 평가 → 대시보드 +``` + +| 단계 | 내용 | 상태 | +|---:|---|---| +| 1 | STOM DB 구조 이해/변환 | 완료 | +| 2 | 공식 tokenizer→predictor 200k 학습 | 완료 | +| 3 | 대시보드 실제값/예측값 검증 | 완료 | +| 4 | GPU 대여/시간/비용 검토 | 완료 | +| 5 | 2025년 전체 학습 preflight | 완료 | +| 6 | 2025년 processed dataset export | 다음 단계 | +| 7 | 2025년 Kronos-small 전체 학습 | 남음 | +| 8 | 2025 test + 2026 forward 검증 | 남음 | +| 9 | 대시보드 성과 비교 및 Kronos-base 확대 판단 | 남음 | + +## 9. 다음 권장 OMX 명령 + +다음은 바로 dataset export를 진행하는 단계다. + +```text +$ralph 2025년 STOM tick pred60 processed dataset export를 실행하고 export report, train/val/test pkl 생성 여부, session split, row/sample 수를 검증한 뒤 문서와 commit으로 남기세요. +``` + +주의: + +- 이 단계는 학습이 아니라 dataset export다. +- export 성공 후에야 8일 이상 걸리는 본 학습 명령을 실행할 수 있다. +- Windows 절전 방지와 장기 실행 로그/백업 정책은 본 학습 직전에 다시 확인해야 한다. + +--- + +## 10. 2026-05-11 추가 확인: 2025 processed dataset export 완료 + +preflight 이후 실제 2025년 dataset export를 완료했다. + +- export output: `finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets` +- export duration: 1,433.24초 +- `train_data.pkl`: 1,325,464,369 bytes +- `val_data.pkl`: 276,595,451 bytes +- `test_data.pkl`: 273,245,379 bytes +- export report 기준 train samples: 18,806,883 +- export report 기준 validation samples: 3,925,397 +- preflight 재실행 결과: `next_action=run_training_2025_full_small`, blocker 0개, warning 0개 + +따라서 이 문서의 다음 단계는 이제 export가 아니라 **2025년 Kronos-small full training 실행**이다. 상세 export 보고서는 `docs/stom_2025_dataset_export_report.md`를 기준으로 한다. diff --git a/docs/stom_2025_full_training_progress_checkpoint.md b/docs/stom_2025_full_training_progress_checkpoint.md new file mode 100644 index 000000000..990c4fc2f --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint.md @@ -0,0 +1,113 @@ +# 2025년 STOM tick pred60 Kronos-small 전체 학습 중간 체크포인트 + +작성일: 2026-05-11 KST +대상 run: `stom_1s_grid_pred60_2025_full_small` +목적: 장기 학습이 실제로 계속 진행 중인지 tokenizer progress/log/GPU를 확인하고, 방향성을 잃지 않도록 현재 단계와 남은 단계를 기록한다. + +## 1. 현재 단계 위치 + +```text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 단계 완료율: ████████████████████ 100% 중간 진행 점검 완료 +실제 학습 진행률: ░░░░░░░░░░░░░░░░░░░░ 0.3190% +``` + +이번 단계의 완료 조건은 **학습 완료가 아니라 중간 진행이 계속 증가하는지 검증하는 것**이다. + +## 2. 프로세스 생존 확인 + +| 역할 | PID | 상태 | +|---|---:|---| +| `/training` webui parent | 147452 | 실행 중 | +| `/training` webui child/reloader | 101468 | 실행 중 | +| full training runner | 3944 | 실행 중 | +| tokenizer child | 70448 | 실행 중 | + +중요: 위 학습 프로세스, 특히 `3944`, `70448`은 임의 종료하지 않는다. + +## 3. 진행률 2회 샘플 검증 + +두 시점에서 `/api/training/status`와 tokenizer log를 확인했다. + +| 시점 | stage | step | total steps | loss | stage % | overall % | samples/sec | ETA seconds | +|---|---|---:|---:|---:|---:|---:|---:|---:| +| 1차 | tokenizer | 28,000 | 4,701,721 | -0.0274 | 0.5955 | 0.2978 | 72.33 | 258,484 | +| 2차 | tokenizer | 30,000 | 4,701,721 | -0.0316 | 0.6381 | 0.3190 | 72.63 | 257,301 | + +해석: + +- 90초 뒤 step이 28,000 → 30,000으로 증가했다. 문서 검증 직전 추가 API 확인에서는 step 31,000까지 증가했다. +- loss 로그가 계속 생성되고 있다. +- `/training` API와 stdout log가 동일한 진행 상태를 표시한다. +- 현재 기준 tokenizer 단계는 정상 진행 중이다. + +## 4. 최근 로그 tail + +```text +[Rank 0, Epoch 1/1, Step 19000/4701721] LR 0.000028, Loss: -0.0302 +[Rank 0, Epoch 1/1, Step 20000/4701721] LR 0.000029, Loss: -0.0299 +[Rank 0, Epoch 1/1, Step 21000/4701721] LR 0.000030, Loss: -0.0311 +[Rank 0, Epoch 1/1, Step 22000/4701721] LR 0.000031, Loss: -0.0294 +[Rank 0, Epoch 1/1, Step 23000/4701721] LR 0.000032, Loss: -0.0285 +[Rank 0, Epoch 1/1, Step 24000/4701721] LR 0.000033, Loss: -0.0224 +[Rank 0, Epoch 1/1, Step 25000/4701721] LR 0.000034, Loss: -0.0298 +[Rank 0, Epoch 1/1, Step 26000/4701721] LR 0.000035, Loss: -0.0318 +[Rank 0, Epoch 1/1, Step 27000/4701721] LR 0.000036, Loss: -0.0319 +[Rank 0, Epoch 1/1, Step 28000/4701721] LR 0.000037, Loss: -0.0274 +[Rank 0, Epoch 1/1, Step 29000/4701721] LR 0.000038, Loss: -0.0329 +[Rank 0, Epoch 1/1, Step 30000/4701721] LR 0.000039, Loss: -0.0316 +``` + +## 5. GPU 상태 + +| 항목 | 값 | +|---|---:| +| GPU | NVIDIA GeForce RTX 4080 SUPER | +| utilization | 약 35~37% | +| VRAM | 약 3,121~3,128 MiB / 16,376 MiB | +| power.draw | `[N/A]`로 미제공 | +| 온도 | 약 44~46°C | + +해석: + +- GPU는 사용 중이며 온도는 안정 범위로 보인다. +- VRAM 사용량은 약 3.1GB로 4080 SUPER 16GB 한도 내다. +- `power.draw`는 이 환경의 `nvidia-smi`가 `[N/A]`를 반환하므로 웹에서도 `-`로 표시되는 것이 정상이다. + +## 6. 현재 결론 + +현재 학습은 정상 진행 중이다. + +확인된 것: + +- 프로세스 생존 +- tokenizer running 상태 +- step 증가 +- loss 로그 생성 +- GPU 사용 +- `/training` API 갱신 + +아직 완료되지 않은 것: + +- tokenizer 전체 4,701,721 step 완료 +- tokenizer validation +- tokenizer checkpoint 저장 +- predictor 자동 시작 +- predictor checkpoint 저장 +- 최종 예측 CSV 생성 +- `/stom` 실제값/예측값 성과 검증 + +## 7. 다음 점검 권장 기준 + +다음 점검은 아래 중 하나를 기준으로 수행한다. + +1. 약 30분~1시간 뒤 step이 계속 증가하는지 확인 +2. 1% 단위 진행률 도달 시점 기록 +3. GPU 사용률/온도 이상 징후 확인 +4. tokenizer checkpoint 생성 전까지 중간 상태를 반복 기록 + +## 8. 다음 권장 OMX 명령 + +```text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 progress/log/GPU를 다시 점검하고, step 증가와 이상 여부를 문서화한 뒤 checkpoint 생성 전까지 중간 commit으로 남기세요. +``` diff --git a/docs/stom_2025_full_training_progress_checkpoint_02.md b/docs/stom_2025_full_training_progress_checkpoint_02.md new file mode 100644 index 000000000..e887cdc97 --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_02.md @@ -0,0 +1,105 @@ +# 2025년 STOM tick pred60 Kronos-small 전체 학습 중간 체크포인트 2 + +작성일: 2026-05-11 KST +대상 run: stom_1s_grid_pred60_2025_full_small +목적: tokenizer 장기 학습이 계속 증가 중인지 추가 확인하고, checkpoint 생성 전까지 진행 상황을 체계적으로 누적 기록한다. + +## 1. 현재 단계 위치 + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 두 번째 중간 progress/log/GPU 확인 완료 +실제 학습 진행률: ░░░░░░░░░░░░░░░░░░░░ 1.4888% +~~~ + +이번 단계도 **학습 완료가 아니라 정상 진행 여부 확인**이 완료 조건이다. + +## 2. 프로세스 생존 확인 + +| 역할 | PID | 상태 | +|---|---:|---| +| /training webui | 147452 | 실행 중 | +| full training runner | 3944 | 실행 중 | +| tokenizer child | 70448 | 실행 중 | +| /training webui | 127412 | 실행 중 | + +중요: 3944, 70448은 현재 학습의 핵심 프로세스이므로 종료하지 않는다. + +## 3. 진행률 샘플 검증 + +| 시점 | 관측 시간 | stage | step | total steps | loss | stage % | overall % | samples/sec | ETA seconds | +|---|---|---|---:|---:|---:|---:|---:|---:|---:| +| 1차 | 2026-05-11 15:38:15 KST | tokenizer | 132,000 | 4,701,721 | -0.0251 | 2.8075 | 1.4037 | 76.90 | 237,712 | +| 2차 | 2026-05-11 15:40:02 KST | tokenizer | 134,000 | 4,701,721 | -0.0308 | 2.8500 | 1.4250 | 76.87 | 237,700 | +| 검증 log | 2026-05-11 15:46 KST | tokenizer | 140,000 | 4,701,721 | -0.0339 | 2.9776 | 1.4888 | 76.81 | 237,574 | + +해석: + +- 90초 뒤 step이 132,000 → 134,000으로 증가했고, 문서 검증 직전 로그 기준 140,000 step까지 추가 증가했다. +- 이전 체크포인트의 32,000 step 대비 크게 증가했다. +- API/로그가 모두 tokenizer running 상태와 step 증가를 보여준다. +- ETA는 장기 학습 중 변동될 수 있으며, 현재 관측치는 약 66시간 전후로 표시된다. + +## 4. 최근 로그 tail + +~~~text +[Rank 0, Epoch 1/1, Step 129000/4701721] LR 0.000197, Loss: -0.0336 +[Rank 0, Epoch 1/1, Step 130000/4701721] LR 0.000197, Loss: -0.0331 +[Rank 0, Epoch 1/1, Step 131000/4701721] LR 0.000198, Loss: -0.0316 +[Rank 0, Epoch 1/1, Step 132000/4701721] LR 0.000198, Loss: -0.0251 +[Rank 0, Epoch 1/1, Step 133000/4701721] LR 0.000199, Loss: -0.0335 +[Rank 0, Epoch 1/1, Step 134000/4701721] LR 0.000199, Loss: -0.0308 +[Rank 0, Epoch 1/1, Step 135000/4701721] LR 0.000199, Loss: -0.0332 +[Rank 0, Epoch 1/1, Step 136000/4701721] LR 0.000199, Loss: -0.0318 +[Rank 0, Epoch 1/1, Step 137000/4701721] LR 0.000200, Loss: -0.0113 +[Rank 0, Epoch 1/1, Step 138000/4701721] LR 0.000200, Loss: -0.0344 +[Rank 0, Epoch 1/1, Step 139000/4701721] LR 0.000200, Loss: -0.0317 +[Rank 0, Epoch 1/1, Step 140000/4701721] LR 0.000200, Loss: -0.0339 +~~~ + +## 5. GPU 상태 + +최신 nvidia-smi 관측: + +~~~text +NVIDIA GeForce RTX 4080 SUPER, 38, 3004, 16376, [N/A], 43 +~~~ + +이전 2회 샘플에서는 utilization 38~46%, VRAM 3.1~3.3GB, 온도 44~50°C 수준이었다. 최신 관측도 GPU가 계속 사용 중임을 보여준다. + +## 6. 현재 결론 + +현재 tokenizer 학습은 정상적으로 진행 중이다. + +확인된 것: + +- 프로세스 생존 +- tokenizer running 유지 +- step 증가 +- loss 로그 계속 생성 +- GPU 사용 지속 +- /training API 정상 응답 + +아직 완료되지 않은 것: + +- tokenizer 4,701,721 step 완료 +- tokenizer validation 및 checkpoint 저장 +- predictor 자동 시작 +- predictor checkpoint 저장 +- checkpoint 기반 예측 CSV 생성 +- /stom 실제값/예측값 성과 검증 + +## 7. 다음 점검 기준 + +다음 점검에서는 아래를 확인한다. + +1. step이 140,000 이후 계속 증가하는지 +2. GPU utilization과 온도가 안정적인지 +3. tokenizer checkpoint가 아직 생성 전인지, 또는 생성되었는지 +4. predictor로 자동 전환되었는지 여부 + +## 8. 다음 권장 OMX 명령 + +~~~text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ diff --git a/docs/stom_2025_full_training_progress_checkpoint_03.md b/docs/stom_2025_full_training_progress_checkpoint_03.md new file mode 100644 index 000000000..224d31779 --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_03.md @@ -0,0 +1,120 @@ +# 2025년 STOM tick pred60 Kronos-small 전체 학습 중간 체크포인트 3 + +작성일: 2026-05-11 KST +대상 run: stom_1s_grid_pred60_2025_full_small +목적: 세 번째 중간 점검으로 tokenizer 장기 학습이 계속 증가하는지, checkpoint 또는 predictor 전환이 발생했는지 확인한다. + +## 1. 현재 단계 위치 + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 세 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: ░░░░░░░░░░░░░░░░░░░░ 1.7759% +~~~ + +이번 단계의 완료 조건은 학습 완료가 아니라 장기 학습의 정상 진행, checkpoint 미생성/전환 전 상태의 정확한 기록이다. + +## 2. 프로세스 생존 확인 + +| 역할 | PID | 상태 | +|---|---:|---| +| /training webui | 147452 | 실행 중 | +| full training runner | 3944 | 실행 중 | +| tokenizer child | 70448 | 실행 중 | +| /training webui | 108252 | 실행 중 | + +중요: full training runner와 tokenizer child는 현재 학습의 핵심 프로세스이므로 종료하지 않는다. + +## 3. 진행률 샘플 검증 + +| 구분 | 관측 시간 | stage | step | total steps | loss | stage % | overall % | samples/sec | ETA seconds | +|---|---|---|---:|---:|---:|---:|---:|---:|---:| +| 이전 commit 후 최종 관측 | 2026-05-11 15:47 KST | tokenizer | 142,000 | 4,701,721 | -0.0293 | 3.0202 | 1.5101 | 76.76 | 237,596 | +| 이번 1차 샘플 | 2026-05-11 16:06 KST | tokenizer | 164,000 | 4,701,721 | -0.0329 | 3.4881 | 1.7440 | 76.54 | 237,151 | +| 이번 2차 샘플 | 2026-05-11 16:07 KST | tokenizer | 165,000 | 4,701,721 | -0.0339 | 3.5094 | 1.7547 | 76.52 | 237,147 | +| 문서 작성 직전 | 2026-05-11 16:08 KST | tokenizer | 167,000 | 4,701,721 | -0.0269 | 3.5519 | 1.7759 | 76.5 | 237,116 | + +해석: + +- 이전 commit 후 142,000 step에서 이번 점검 167,000 step까지 증가했다. +- 70초 간격 재샘플에서 164,000 → 165,000 step 증가를 확인했다. +- samples/sec는 약 76.5 수준으로 이전 관측과 유사하다. +- tokenizer는 아직 stage 1/2이며 predictor는 아직 시작되지 않았다. + +## 4. checkpoint 및 predictor 전환 확인 + +| 항목 | 결과 | +|---|---| +| tokenizer progress status | running | +| train stage | tokenizer | +| requested train stage | both | +| checkpoint/model file count | 0 | +| checkpoint/model search result | NO_CHECKPOINT_OR_MODEL_FILE_FOUND | + +결론: 현재 시점에는 tokenizer checkpoint 또는 predictor model checkpoint가 아직 생성되지 않았다. 따라서 predictor 전환도 아직 전이다. + +## 5. 최근 로그 tail + +~~~text +[Rank 0, Epoch 1/1, Step 156000/4701721] LR 0.000200, Loss: -0.0328 +[Rank 0, Epoch 1/1, Step 157000/4701721] LR 0.000200, Loss: -0.0314 +[Rank 0, Epoch 1/1, Step 158000/4701721] LR 0.000200, Loss: -0.0319 +[Rank 0, Epoch 1/1, Step 159000/4701721] LR 0.000200, Loss: -0.0228 +[Rank 0, Epoch 1/1, Step 160000/4701721] LR 0.000200, Loss: -0.0317 +[Rank 0, Epoch 1/1, Step 161000/4701721] LR 0.000200, Loss: -0.0320 +[Rank 0, Epoch 1/1, Step 162000/4701721] LR 0.000200, Loss: -0.0336 +[Rank 0, Epoch 1/1, Step 163000/4701721] LR 0.000200, Loss: -0.0322 +[Rank 0, Epoch 1/1, Step 164000/4701721] LR 0.000200, Loss: -0.0329 +[Rank 0, Epoch 1/1, Step 165000/4701721] LR 0.000200, Loss: -0.0339 +[Rank 0, Epoch 1/1, Step 166000/4701721] LR 0.000200, Loss: -0.0322 +[Rank 0, Epoch 1/1, Step 167000/4701721] LR 0.000200, Loss: -0.0269 +~~~ + +## 6. GPU 상태 + +최신 nvidia-smi 관측: + +~~~text +NVIDIA GeForce RTX 4080 SUPER, 39, 2995, 16376, [N/A], 44 +~~~ + +GPU는 계속 사용 중이며 VRAM 사용량과 온도는 장기 실행 기준 안정 범위로 관측된다. + +## 7. 현재 결론 + +현재 tokenizer 학습은 정상 진행 중이다. + +확인된 것: + +- runner/tokenizer 프로세스 생존 +- tokenizer running 유지 +- step 증가 지속 +- loss 로그 생성 지속 +- GPU 사용 지속 +- checkpoint/model 파일은 아직 미생성 +- predictor 전환 전 + +아직 완료되지 않은 것: + +- tokenizer 4,701,721 step 완료 +- tokenizer validation 및 checkpoint 저장 +- predictor 자동 시작 +- predictor checkpoint 저장 +- checkpoint 기반 예측 CSV 생성 +- /stom 실제값/예측값 성과 검증 + +## 8. 다음 점검 기준 + +다음 점검에서는 아래를 확인한다. + +1. step이 167,000 이후 계속 증가하는지 +2. checkpoint/model 파일이 생성되었는지 +3. predictor.progress.json 또는 predictor 로그가 생겼는지 +4. GPU 사용률과 온도가 안정적인지 +5. /training 대시보드가 계속 최신 상태를 표시하는지 + +## 9. 다음 권장 OMX 명령 + +~~~text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ \ No newline at end of file diff --git a/docs/stom_2025_full_training_progress_checkpoint_04.md b/docs/stom_2025_full_training_progress_checkpoint_04.md new file mode 100644 index 000000000..3ef0a2373 --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_04.md @@ -0,0 +1,131 @@ +# 2025년 STOM tick pred60 Kronos-small 전체 학습 중간 체크포인트 4 + +작성일: 2026-05-11 KST +대상 run: stom_1s_grid_pred60_2025_full_small +목적: 네 번째 중간 점검으로 장기 tokenizer 학습이 계속 증가하는지, checkpoint 또는 predictor 전환이 발생했는지 확인한다. + +## 1. 전체 계획과 현재 위치 + +| 페이지/단계 | 목표 | 상태 | 완료율 | +|---|---|---|---:| +| 1. STOM tick DB 이해와 1초봉/QLib 변환 | 전체 데이터 학습 가능한 형태 준비 | 완료 | 100% | +| 2. Kronos 공식 가이드 준수 파인튜닝 파이프라인 | full sequential 학습 실행 가능 | 완료 | 100% | +| 3. 웹 학습 모니터링 | /training에서 progress/log/GPU 확인 | 완료 | 100% | +| 4. 2025년 전체 tokenizer 학습 | 전체 데이터 tokenizer 학습 진행 | 진행 중 | 9.8262% | +| 5. predictor 학습 자동 전환 | tokenizer 완료 후 predictor 시작 | 대기 | 0% | +| 6. checkpoint 기반 예측 생성 | 학습 모델로 예측 CSV 생성 | 대기 | 0% | +| 7. 실제값 vs 예측값 대시보드 성과 검증 | 종목별/전체 통계와 그래프 확인 | 대기 | 0% | + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 네 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: ░░░░░░░░░░░░░░░░░░░░ 4.9131% +~~~ + +이번 단계의 완료 조건은 학습 완료가 아니라 **장기 학습 정상 진행, checkpoint 미생성/전환 전 상태, 다음 점검 기준**을 정확히 남기는 것이다. + +## 2. 프로세스 생존 확인 + +| 역할 | PID | 상태 | +|---|---:|---| +| /training webui | 147452 | 실행 중 | +| full training runner | 3944 | 실행 중 | +| tokenizer child | 70448 | 실행 중 | +| /training webui | 108252 | 실행 중 | + +중요: full training runner와 tokenizer child는 현재 학습의 핵심 프로세스이므로 종료하지 않는다. + +## 3. 진행률 샘플 검증 + +| 구분 | 관측 시간 | stage | step | total steps | loss | LR | stage % | overall % | samples/sec | ETA | +|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 이전 commit 최종 관측 | 2026-05-11 16:09 KST | tokenizer | 167,000 | 4,701,721 | -0.0269 | 0.000200 | 3.5519 | 1.7759 | 76.50 | 237,116s | +| 이번 1차 샘플 | 2026-05-11 20:15 KST | tokenizer | 460,000 | 4,701,721 | -0.0303 | 0.000198 | 9.7837 | 4.8918 | 78.15 | 217,103s | +| 이번 2차 샘플 | 2026-05-11 20:18 KST | tokenizer | 462,000 | 4,701,721 | -0.0191 | 0.000198 | 9.8262 | 4.9131 | 78.04 | 217,308s | +| 문서 작성 직전 | 2026-05-11 20:19 KST | tokenizer | 462,000 | 4,701,721 | -0.0191 | 0.000198 | 9.8262 | 4.9131 | 78.04 | 217,308s | + +해석: + +- 이전 checkpoint 167,000 step에서 이번 점검 462,000 step까지 증가했다. +- 짧은 간격 재샘플에서 460,000 → 462,000 step 증가를 확인했다. +- samples/sec는 약 78.04 수준으로 유지 중이다. +- ETA는 약 60.4 시간으로 표시되지만 장기 학습 중 속도 변화에 따라 달라질 수 있다. +- tokenizer는 아직 stage 1/2이며 predictor는 아직 시작되지 않았다. + +## 4. checkpoint 및 predictor 전환 확인 + +| 항목 | 결과 | +|---|---| +| tokenizer progress status | running | +| train stage | tokenizer | +| requested train stage | both | +| checkpoint/model/predictor file count | 0 | +| checkpoint/model/predictor search result | NO_CHECKPOINT_OR_PREDICTOR_FILE_FOUND | + +결론: 현재 시점에는 tokenizer checkpoint 또는 predictor 관련 파일이 아직 생성되지 않았다. 따라서 predictor 전환도 아직 전이다. + +## 5. 최근 로그 tail + +~~~text +[Rank 0, Epoch 1/1, Step 451000/4701721] LR 0.000198, Loss: -0.0299 +[Rank 0, Epoch 1/1, Step 452000/4701721] LR 0.000198, Loss: -0.0285 +[Rank 0, Epoch 1/1, Step 453000/4701721] LR 0.000198, Loss: -0.0284 +[Rank 0, Epoch 1/1, Step 454000/4701721] LR 0.000198, Loss: -0.0295 +[Rank 0, Epoch 1/1, Step 455000/4701721] LR 0.000198, Loss: -0.0327 +[Rank 0, Epoch 1/1, Step 456000/4701721] LR 0.000198, Loss: -0.0245 +[Rank 0, Epoch 1/1, Step 457000/4701721] LR 0.000198, Loss: -0.0306 +[Rank 0, Epoch 1/1, Step 458000/4701721] LR 0.000198, Loss: -0.0301 +[Rank 0, Epoch 1/1, Step 459000/4701721] LR 0.000198, Loss: -0.0314 +[Rank 0, Epoch 1/1, Step 460000/4701721] LR 0.000198, Loss: -0.0303 +[Rank 0, Epoch 1/1, Step 461000/4701721] LR 0.000198, Loss: -0.0266 +[Rank 0, Epoch 1/1, Step 462000/4701721] LR 0.000198, Loss: -0.0191 +~~~ + +## 6. GPU 상태 + +최신 nvidia-smi 관측: + +~~~text +NVIDIA GeForce RTX 4080 SUPER, 38, 3018, 16376, [N/A], 42 +~~~ + +GPU는 계속 사용 중이며, VRAM 사용량과 온도는 장기 실행 기준 안정 범위로 관측된다. + +## 7. 현재 결론 + +현재 tokenizer 학습은 정상 진행 중이다. + +확인된 것: + +- runner/tokenizer 프로세스 생존 +- tokenizer running 유지 +- step 증가 지속 +- loss 로그 생성 지속 +- GPU 사용 지속 +- checkpoint/model/predictor 파일은 아직 미생성 +- predictor 전환 전 + +아직 완료되지 않은 것: + +- tokenizer 4,701,721 step 완료 +- tokenizer validation 및 checkpoint 저장 +- predictor 자동 시작 +- predictor checkpoint 저장 +- checkpoint 기반 예측 CSV 생성 +- /stom 실제값/예측값 성과 검증 + +## 8. 다음 점검 기준 + +다음 점검에서는 아래를 확인한다. + +1. step이 462,000 이후 계속 증가하는지 +2. checkpoint/model 파일이 생성되었는지 +3. predictor.progress.json 또는 predictor 로그가 생겼는지 +4. GPU 사용률과 온도가 안정적인지 +5. /training 대시보드가 계속 최신 상태를 표시하는지 + +## 9. 다음 권장 OMX 명령 + +~~~text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ \ No newline at end of file diff --git a/docs/stom_2025_full_training_progress_checkpoint_05.md b/docs/stom_2025_full_training_progress_checkpoint_05.md new file mode 100644 index 000000000..cba494a66 --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_05.md @@ -0,0 +1,131 @@ +# 2025년 STOM tick pred60 Kronos-small 전체 학습 중간 체크포인트 5 + +작성일: 2026-05-11 KST +대상 run: stom_1s_grid_pred60_2025_full_small +목적: 다섯 번째 중간 점검으로 장기 tokenizer 학습이 계속 증가하는지, checkpoint 또는 predictor 전환이 발생했는지 확인한다. + +## 1. 전체 계획과 현재 위치 + +| 페이지/단계 | 목표 | 상태 | 완료율 | +|---|---|---|---:| +| 1. STOM tick DB 이해와 1초봉/QLib 변환 | 전체 데이터 학습 가능한 형태 준비 | 완료 | 100% | +| 2. Kronos 공식 가이드 준수 파인튜닝 파이프라인 | full sequential 학습 실행 가능 | 완료 | 100% | +| 3. 웹 학습 모니터링 | /training에서 progress/log/GPU 확인 | 완료 | 100% | +| 4. 2025년 전체 tokenizer 학습 | 전체 데이터 tokenizer 학습 진행 | 진행 중 | 14.1225% | +| 5. predictor 학습 자동 전환 | tokenizer 완료 후 predictor 시작 | 대기 | 0% | +| 6. checkpoint 기반 예측 생성 | 학습 모델로 예측 CSV 생성 | 대기 | 0% | +| 7. 실제값 vs 예측값 대시보드 성과 검증 | 종목별/전체 통계와 그래프 확인 | 대기 | 0% | + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 다섯 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: █░░░░░░░░░░░░░░░░░░░ 7.0612% +~~~ + +이번 단계의 완료 조건은 학습 완료가 아니라 **장기 학습 정상 진행, checkpoint 미생성/전환 전 상태, 다음 점검 기준**을 정확히 남기는 것이다. + +## 2. 프로세스 생존 확인 + +| 역할 | PID | 상태 | +|---|---:|---| +| /training webui | 147452 | 실행 중 | +| full training runner | 3944 | 실행 중 | +| tokenizer child | 70448 | 실행 중 | +| /training webui | 108252 | 실행 중 | + +중요: full training runner와 tokenizer child는 현재 학습의 핵심 프로세스이므로 종료하지 않는다. + +## 3. 진행률 샘플 검증 + +| 구분 | 관측 시간 | stage | step | total steps | loss | LR | stage % | overall % | samples/sec | ETA | +|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 이전 commit 최종 관측 | 2026-05-11 20:19 KST | tokenizer | 463,000 | 4,701,721 | -0.0180 | 0.000198 | 9.8475 | 4.9237 | 77.97 | 217,449s | +| 이번 1차 샘플 | 2026-05-11 23:43 KST | tokenizer | 662,000 | 4,701,721 | -0.0297 | 0.000194 | 14.0800 | 7.0400 | 73.49 | 219,874s | +| 이번 2차 샘플 | 2026-05-11 23:45 KST | tokenizer | 663,000 | 4,701,721 | -0.0298 | 0.000194 | 14.1012 | 7.0506 | 73.46 | 219,916s | +| 문서 작성 직전 | 2026-05-11 23:46 KST | tokenizer | 664,000 | 4,701,721 | -0.0324 | 0.000194 | 14.1225 | 7.0612 | 73.42 | 219,970s | + +해석: + +- 이전 checkpoint 463,000 step에서 이번 점검 664,000 step까지 증가했다. +- 짧은 간격 재샘플에서 662,000 → 663,000 step 증가를 확인했다. +- samples/sec는 약 73.42 수준이다. +- ETA는 약 61.1 시간으로 표시되지만 장기 학습 중 속도 변화에 따라 달라질 수 있다. +- tokenizer는 아직 stage 1/2이며 predictor는 아직 시작되지 않았다. + +## 4. checkpoint 및 predictor 전환 확인 + +| 항목 | 결과 | +|---|---| +| tokenizer progress status | running | +| train stage | tokenizer | +| requested train stage | both | +| checkpoint/model/predictor file count | 0 | +| checkpoint/model/predictor search result | NO_CHECKPOINT_OR_PREDICTOR_FILE_FOUND | + +결론: 현재 시점에는 tokenizer checkpoint 또는 predictor 관련 파일이 아직 생성되지 않았다. 따라서 predictor 전환도 아직 전이다. + +## 5. 최근 로그 tail + +~~~text +[Rank 0, Epoch 1/1, Step 653000/4701721] LR 0.000194, Loss: -0.0289 +[Rank 0, Epoch 1/1, Step 654000/4701721] LR 0.000194, Loss: 0.0012 +[Rank 0, Epoch 1/1, Step 655000/4701721] LR 0.000194, Loss: -0.0311 +[Rank 0, Epoch 1/1, Step 656000/4701721] LR 0.000194, Loss: -0.0319 +[Rank 0, Epoch 1/1, Step 657000/4701721] LR 0.000194, Loss: -0.0271 +[Rank 0, Epoch 1/1, Step 658000/4701721] LR 0.000194, Loss: -0.0297 +[Rank 0, Epoch 1/1, Step 659000/4701721] LR 0.000194, Loss: -0.0219 +[Rank 0, Epoch 1/1, Step 660000/4701721] LR 0.000194, Loss: -0.0288 +[Rank 0, Epoch 1/1, Step 661000/4701721] LR 0.000194, Loss: -0.0313 +[Rank 0, Epoch 1/1, Step 662000/4701721] LR 0.000194, Loss: -0.0297 +[Rank 0, Epoch 1/1, Step 663000/4701721] LR 0.000194, Loss: -0.0298 +[Rank 0, Epoch 1/1, Step 664000/4701721] LR 0.000194, Loss: -0.0324 +~~~ + +## 6. GPU 상태 + +최신 nvidia-smi 관측: + +~~~text +NVIDIA GeForce RTX 4080 SUPER, 38, 3179, 16376, [N/A], 42 +~~~ + +GPU는 계속 사용 중이며, VRAM 사용량과 온도는 장기 실행 기준 안정 범위로 관측된다. + +## 7. 현재 결론 + +현재 tokenizer 학습은 정상 진행 중이다. + +확인된 것: + +- runner/tokenizer 프로세스 생존 +- tokenizer running 유지 +- step 증가 지속 +- loss 로그 생성 지속 +- GPU 사용 지속 +- checkpoint/model/predictor 파일은 아직 미생성 +- predictor 전환 전 + +아직 완료되지 않은 것: + +- tokenizer 4,701,721 step 완료 +- tokenizer validation 및 checkpoint 저장 +- predictor 자동 시작 +- predictor checkpoint 저장 +- checkpoint 기반 예측 CSV 생성 +- /stom 실제값/예측값 성과 검증 + +## 8. 다음 점검 기준 + +다음 점검에서는 아래를 확인한다. + +1. step이 664,000 이후 계속 증가하는지 +2. checkpoint/model 파일이 생성되었는지 +3. predictor.progress.json 또는 predictor 로그가 생겼는지 +4. GPU 사용률과 온도가 안정적인지 +5. /training 대시보드가 계속 최신 상태를 표시하는지 + +## 9. 다음 권장 OMX 명령 + +~~~text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ \ No newline at end of file diff --git a/docs/stom_2025_full_training_progress_checkpoint_06.md b/docs/stom_2025_full_training_progress_checkpoint_06.md new file mode 100644 index 000000000..88265cc47 --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_06.md @@ -0,0 +1,131 @@ +# 2025년 STOM tick pred60 Kronos-small 전체 학습 중간 체크포인트 6 + +작성일: 2026-05-12 KST +대상 run: stom_1s_grid_pred60_2025_full_small +목적: 날짜가 2026-05-12로 넘어간 뒤에도 장기 tokenizer 학습이 계속 증가하는지, checkpoint 또는 predictor 전환이 발생했는지 확인한다. + +## 1. 전체 계획과 현재 위치 + +| 페이지/단계 | 목표 | 상태 | 완료율 | +|---|---|---|---:| +| 1. STOM tick DB 이해와 1초봉/QLib 변환 | 전체 데이터 학습 가능한 형태 준비 | 완료 | 100% | +| 2. Kronos 공식 가이드 준수 파인튜닝 파이프라인 | full sequential 학습 실행 가능 | 완료 | 100% | +| 3. 웹 학습 모니터링 | /training에서 progress/log/GPU 확인 | 완료 | 100% | +| 4. 2025년 전체 tokenizer 학습 | 전체 데이터 tokenizer 학습 진행 | 진행 중 | 14.5479% | +| 5. predictor 학습 자동 전환 | tokenizer 완료 후 predictor 시작 | 대기 | 0% | +| 6. checkpoint 기반 예측 생성 | 학습 모델로 예측 CSV 생성 | 대기 | 0% | +| 7. 실제값 vs 예측값 대시보드 성과 검증 | 종목별/전체 통계와 그래프 확인 | 대기 | 0% | + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 여섯 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: █░░░░░░░░░░░░░░░░░░░ 7.2739% +~~~ + +이번 단계의 완료 조건은 학습 완료가 아니라 **날짜 전환 후에도 장기 학습 정상 진행, checkpoint 미생성/전환 전 상태, 다음 점검 기준**을 정확히 남기는 것이다. + +## 2. 프로세스 생존 확인 + +| 역할 | PID | 상태 | +|---|---:|---| +| /training webui | 147452 | 실행 중 | +| full training runner | 3944 | 실행 중 | +| tokenizer child | 70448 | 실행 중 | +| /training webui | 108252 | 실행 중 | + +중요: full training runner와 tokenizer child는 현재 학습의 핵심 프로세스이므로 종료하지 않는다. + +## 3. 진행률 샘플 검증 + +| 구분 | 관측 시간 | stage | step | total steps | loss | LR | stage % | overall % | samples/sec | ETA | +|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 이전 commit 최종 관측 | 2026-05-11 23:47 KST | tokenizer | 665,000 | 4,701,721 | -0.0281 | 0.000193 | 14.1438 | 7.0719 | 73.39 | 220,026s | +| 이번 1차 샘플 | 2026-05-12 00:07 KST | tokenizer | 682,000 | 4,701,721 | -0.0332 | 0.000193 | 14.5053 | 7.2527 | 72.91 | 220,516s | +| 이번 2차 샘플 | 2026-05-12 00:08 KST | tokenizer | 683,000 | 4,701,721 | -0.0233 | 0.000193 | 14.5266 | 7.2633 | 72.88 | 220,570s | +| 문서 작성 직전 | 2026-05-12 00:09 KST | tokenizer | 684,000 | 4,701,721 | -0.0261 | 0.000193 | 14.5479 | 7.2739 | 72.85 | 220,602s | + +해석: + +- 이전 checkpoint 665,000 step에서 이번 점검 684,000 step까지 증가했다. +- 짧은 간격 재샘플에서 682,000 → 683,000 step 증가를 확인했다. +- samples/sec는 약 72.85 수준이다. +- ETA는 약 61.3 시간으로 표시되지만 장기 학습 중 속도 변화에 따라 달라질 수 있다. +- tokenizer는 아직 stage 1/2이며 predictor는 아직 시작되지 않았다. + +## 4. checkpoint 및 predictor 전환 확인 + +| 항목 | 결과 | +|---|---| +| tokenizer progress status | running | +| train stage | tokenizer | +| requested train stage | both | +| checkpoint/model/predictor file count | 0 | +| checkpoint/model/predictor search result | NO_CHECKPOINT_OR_PREDICTOR_FILE_FOUND | + +결론: 현재 시점에는 tokenizer checkpoint 또는 predictor 관련 파일이 아직 생성되지 않았다. 따라서 predictor 전환도 아직 전이다. + +## 5. 최근 로그 tail + +~~~text +[Rank 0, Epoch 1/1, Step 673000/4701721] LR 0.000193, Loss: -0.0309 +[Rank 0, Epoch 1/1, Step 674000/4701721] LR 0.000193, Loss: -0.0294 +[Rank 0, Epoch 1/1, Step 675000/4701721] LR 0.000193, Loss: -0.0309 +[Rank 0, Epoch 1/1, Step 676000/4701721] LR 0.000193, Loss: -0.0310 +[Rank 0, Epoch 1/1, Step 677000/4701721] LR 0.000193, Loss: -0.0186 +[Rank 0, Epoch 1/1, Step 678000/4701721] LR 0.000193, Loss: -0.0317 +[Rank 0, Epoch 1/1, Step 679000/4701721] LR 0.000193, Loss: -0.0296 +[Rank 0, Epoch 1/1, Step 680000/4701721] LR 0.000193, Loss: -0.0268 +[Rank 0, Epoch 1/1, Step 681000/4701721] LR 0.000193, Loss: -0.0302 +[Rank 0, Epoch 1/1, Step 682000/4701721] LR 0.000193, Loss: -0.0332 +[Rank 0, Epoch 1/1, Step 683000/4701721] LR 0.000193, Loss: -0.0233 +[Rank 0, Epoch 1/1, Step 684000/4701721] LR 0.000193, Loss: -0.0261 +~~~ + +## 6. GPU 상태 + +최신 nvidia-smi 관측: + +~~~text +NVIDIA GeForce RTX 4080 SUPER, 38, 3076, 16376, [N/A], 42 +~~~ + +GPU는 계속 사용 중이며, VRAM 사용량과 온도는 장기 실행 기준 안정 범위로 관측된다. + +## 7. 현재 결론 + +현재 tokenizer 학습은 정상 진행 중이다. + +확인된 것: + +- 날짜 전환 후에도 runner/tokenizer 프로세스 생존 +- tokenizer running 유지 +- step 증가 지속 +- loss 로그 생성 지속 +- GPU 사용 지속 +- checkpoint/model/predictor 파일은 아직 미생성 +- predictor 전환 전 + +아직 완료되지 않은 것: + +- tokenizer 4,701,721 step 완료 +- tokenizer validation 및 checkpoint 저장 +- predictor 자동 시작 +- predictor checkpoint 저장 +- checkpoint 기반 예측 CSV 생성 +- /stom 실제값/예측값 성과 검증 + +## 8. 다음 점검 기준 + +다음 점검에서는 아래를 확인한다. + +1. step이 684,000 이후 계속 증가하는지 +2. checkpoint/model 파일이 생성되었는지 +3. predictor.progress.json 또는 predictor 로그가 생겼는지 +4. GPU 사용률과 온도가 안정적인지 +5. /training 대시보드가 계속 최신 상태를 표시하는지 + +## 9. 다음 권장 OMX 명령 + +~~~text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ \ No newline at end of file diff --git a/docs/stom_2025_full_training_progress_checkpoint_07.md b/docs/stom_2025_full_training_progress_checkpoint_07.md new file mode 100644 index 000000000..2b698eafd --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_07.md @@ -0,0 +1,131 @@ +# 2025년 STOM tick pred60 Kronos-small 전체 학습 중간 체크포인트 7 + +작성일: 2026-05-12 KST +대상 run: stom_1s_grid_pred60_2025_full_small +목적: 일곱 번째 중간 점검으로 tokenizer 장기 학습이 계속 증가하는지, checkpoint 또는 predictor 전환이 발생했는지 확인한다. + +## 1. 전체 계획과 현재 위치 + +| 페이지/단계 | 목표 | 상태 | 완료율 | +|---|---|---|---:| +| 1. STOM tick DB 이해와 1초봉/QLib 변환 | 전체 데이터 학습 가능한 형태 준비 | 완료 | 100% | +| 2. Kronos 공식 가이드 준수 파인튜닝 파이프라인 | full sequential 학습 실행 가능 | 완료 | 100% | +| 3. 웹 학습 모니터링 | /training에서 progress/log/GPU 확인 | 완료 | 100% | +| 4. 2025년 전체 tokenizer 학습 | 전체 데이터 tokenizer 학습 진행 | 진행 중 | 14.6967% | +| 5. predictor 학습 자동 전환 | tokenizer 완료 후 predictor 시작 | 대기 | 0% | +| 6. checkpoint 기반 예측 생성 | 학습 모델로 예측 CSV 생성 | 대기 | 0% | +| 7. 실제값 vs 예측값 대시보드 성과 검증 | 종목별/전체 통계와 그래프 확인 | 대기 | 0% | + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 일곱 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: █░░░░░░░░░░░░░░░░░░░ 7.3484% +~~~ + +이번 단계의 완료 조건은 학습 완료가 아니라 **장기 학습 정상 진행, checkpoint 미생성/전환 전 상태, 다음 점검 기준**을 정확히 남기는 것이다. + +## 2. 프로세스 생존 확인 + +| 역할 | PID | 상태 | +|---|---:|---| +| /training webui | 147452 | 실행 중 | +| full training runner | 3944 | 실행 중 | +| tokenizer child | 70448 | 실행 중 | +| /training webui | 108252 | 실행 중 | + +중요: full training runner와 tokenizer child는 현재 학습의 핵심 프로세스이므로 종료하지 않는다. + +## 3. 진행률 샘플 검증 + +| 구분 | 관측 시간 | stage | step | total steps | loss | LR | stage % | overall % | samples/sec | ETA | +|---|---|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 이전 commit 최종 관측 | 2026-05-12 00:10 KST | tokenizer | 685,000 | 4,701,721 | -0.0300 | 0.000193 | 14.5691 | 7.2846 | 72.81 | 220,658s | +| 이번 1차 샘플 | 2026-05-12 00:15 KST | tokenizer | 689,000 | 4,701,721 | -0.0326 | 0.000193 | 14.6542 | 7.3271 | 72.69 | 220,806s | +| 이번 2차 샘플 | 2026-05-12 00:16 KST | tokenizer | 690,000 | 4,701,721 | -0.0299 | 0.000193 | 14.6755 | 7.3377 | 72.66 | 220,859s | +| 문서 작성 직전 | 2026-05-12 00:17 KST | tokenizer | 691,000 | 4,701,721 | -0.0238 | 0.000193 | 14.6967 | 7.3484 | 72.62 | 220,930s | + +해석: + +- 이전 checkpoint 685,000 step에서 이번 점검 691,000 step까지 증가했다. +- 짧은 간격 재샘플에서 689,000 → 690,000 step 증가를 확인했다. +- samples/sec는 약 72.62 수준이다. +- ETA는 약 61.4 시간으로 표시되지만 장기 학습 중 속도 변화에 따라 달라질 수 있다. +- tokenizer는 아직 stage 1/2이며 predictor는 아직 시작되지 않았다. + +## 4. checkpoint 및 predictor 전환 확인 + +| 항목 | 결과 | +|---|---| +| tokenizer progress status | running | +| train stage | tokenizer | +| requested train stage | both | +| checkpoint/model/predictor file count | 0 | +| checkpoint/model/predictor search result | NO_CHECKPOINT_OR_PREDICTOR_FILE_FOUND | + +결론: 현재 시점에는 tokenizer checkpoint 또는 predictor 관련 파일이 아직 생성되지 않았다. 따라서 predictor 전환도 아직 전이다. + +## 5. 최근 로그 tail + +~~~text +[Rank 0, Epoch 1/1, Step 680000/4701721] LR 0.000193, Loss: -0.0268 +[Rank 0, Epoch 1/1, Step 681000/4701721] LR 0.000193, Loss: -0.0302 +[Rank 0, Epoch 1/1, Step 682000/4701721] LR 0.000193, Loss: -0.0332 +[Rank 0, Epoch 1/1, Step 683000/4701721] LR 0.000193, Loss: -0.0233 +[Rank 0, Epoch 1/1, Step 684000/4701721] LR 0.000193, Loss: -0.0261 +[Rank 0, Epoch 1/1, Step 685000/4701721] LR 0.000193, Loss: -0.0300 +[Rank 0, Epoch 1/1, Step 686000/4701721] LR 0.000193, Loss: -0.0308 +[Rank 0, Epoch 1/1, Step 687000/4701721] LR 0.000193, Loss: -0.0143 +[Rank 0, Epoch 1/1, Step 688000/4701721] LR 0.000193, Loss: -0.0315 +[Rank 0, Epoch 1/1, Step 689000/4701721] LR 0.000193, Loss: -0.0326 +[Rank 0, Epoch 1/1, Step 690000/4701721] LR 0.000193, Loss: -0.0299 +[Rank 0, Epoch 1/1, Step 691000/4701721] LR 0.000193, Loss: -0.0238 +~~~ + +## 6. GPU 상태 + +최신 nvidia-smi 관측: + +~~~text +NVIDIA GeForce RTX 4080 SUPER, 41, 3082, 16376, [N/A], 42 +~~~ + +GPU는 계속 사용 중이며, VRAM 사용량과 온도는 장기 실행 기준 안정 범위로 관측된다. + +## 7. 현재 결론 + +현재 tokenizer 학습은 정상 진행 중이다. + +확인된 것: + +- runner/tokenizer 프로세스 생존 +- tokenizer running 유지 +- step 증가 지속 +- loss 로그 생성 지속 +- GPU 사용 지속 +- checkpoint/model/predictor 파일은 아직 미생성 +- predictor 전환 전 + +아직 완료되지 않은 것: + +- tokenizer 4,701,721 step 완료 +- tokenizer validation 및 checkpoint 저장 +- predictor 자동 시작 +- predictor checkpoint 저장 +- checkpoint 기반 예측 CSV 생성 +- /stom 실제값/예측값 성과 검증 + +## 8. 다음 점검 기준 + +다음 점검에서는 아래를 확인한다. + +1. step이 691,000 이후 계속 증가하는지 +2. checkpoint/model 파일이 생성되었는지 +3. predictor.progress.json 또는 predictor 로그가 생겼는지 +4. GPU 사용률과 온도가 안정적인지 +5. /training 대시보드가 계속 최신 상태를 표시하는지 + +## 9. 다음 권장 OMX 명령 + +~~~text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ \ No newline at end of file diff --git a/docs/stom_2025_full_training_progress_checkpoint_08.md b/docs/stom_2025_full_training_progress_checkpoint_08.md new file mode 100644 index 000000000..5dc272d7a --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_08.md @@ -0,0 +1,135 @@ +# STOM 2025 전체 학습 진행 체크포인트 08 + +작성일: 2026-05-12 KST +실행 모드: `$ralph` 다음 단계 점검 +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 1. 이번 단계의 목적 + +이번 단계는 새 학습을 다시 시작하는 것이 아니라, 이미 실행 중인 STOM 1초봉/tick 기반 2025년 전체 학습이 절전 이후에도 실제로 계속 진행 중인지 검증하고 그 상태를 문서와 commit으로 고정하는 단계다. + +핵심 목표: + +1. tokenizer 단계가 살아 있는지 확인 +2. step이 실제로 증가하는지 확인 +3. GPU/프로세스가 계속 사용 중인지 확인 +4. checkpoint 또는 predictor 전환이 발생했는지 확인 +5. `/training`, `/`, `/stom` 웹 대시보드가 학습 상태 자동 갱신 UI를 제공하는지 확인 + +## 2. OMX 도구 사용 결과 + +`omx explore`는 Windows 환경에서 POSIX sh/bash wrapper 의존성 때문에 실패했다. `omx sparkshell`은 현재 설치된 OMX CLI에서 프로그램을 찾지 못했다. 따라서 이번 검증은 AGENTS.md 지침에 따라 PowerShell/Python 기반의 읽기 전용 확인으로 대체했다. + +```text +omx explore: Windows allowlist runtime POSIX harness 미준비로 실패 +omx sparkshell: program not found +fallback: PowerShell + Python urllib + nvidia-smi +``` + +## 3. 실시간 학습 상태 + +| 항목 | 값 | +|---|---:| +| 전체 status | running | +| 현재 stage | tokenizer | +| tokenizer step | 920000 / 4701721 | +| tokenizer stage 진행률 | 19.5673% | +| 전체 2-stage 진행률 | 9.7837% | +| 최신 loss 로그 | `[Rank 0, Epoch 1/1, Step 920000/4701721] LR 0.000186, Loss: -0.0307` | + +진행률: + +```text +tokenizer 단계 [████░░░░░░░░░░░░░░░░] 19.57% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 9.78% +predictor 단계 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +중요한 해석: + +- 전체 학습은 `tokenizer -> predictor` 2단계로 계산된다. +- 현재 `overall_percent`가 약 9.78%인 이유는 tokenizer 단계가 약 19.57% 진행됐고 predictor 단계가 아직 0%이기 때문이다. +- 아직 predictor 전환 전이므로 현재 대시보드의 예측 성과는 “학습 완료 모델 성과”가 아니다. + +## 4. step 증가 검증 + +70초 간격 검증에서 아래 변화가 확인됐다. + +```text +before: Step 918000 / 4701721, overall 9.7624% +after : Step 919000 / 4701721, overall 9.7730% +CPU delta for tokenizer PID 70448 over 70s: +70.30 seconds +``` + +추가 상태 문서 작성 시점에는 API가 `Step 920000 / 4701721`까지 갱신됐다. 따라서 현재 학습은 멈춘 상태가 아니라 진행 중이다. 단, 로그는 `--log-interval 1000` 기준이라 20초 관측에서는 변화가 보이지 않을 수 있다. + +## 5. GPU/프로세스 상태 + +GPU 최신 관측: + +```text +NVIDIA GeForce RTX 4080 SUPER, util 약 42%, VRAM 약 3130 / 16376 MiB, temp 약 42C +``` + +프로세스 관측: + +- runner PID `3944` 생존 +- tokenizer child PID `70448` 생존 +- webui PID `147452`, `82776` 생존 + +판단: + +- GPU는 RTX 4080 SUPER를 계속 사용 중이다. +- VRAM 사용량과 온도는 장기 실행 기준 안정 범위로 관측된다. +- 70초 동안 tokenizer CPU time도 증가했으므로 프로세스는 대기 상태가 아니라 실제 연산 중이다. + +## 6. checkpoint/predictor 상태 + +현재 model weight/checkpoint/predictor 관련 weight 파일 수: + +```text +0 +``` + +확인 결과: + +- `finetune_tokenizer/checkpoints` 디렉터리는 존재하지만 아직 저장된 weight 파일은 없다. +- predictor progress/log/checkpoint도 아직 생성되지 않았다. +- 따라서 학습 완료 모델로 예측을 평가하는 단계는 아직 시작 전이다. + +## 7. 웹 대시보드 상태 + +HTTP 확인 결과: + +- `http://127.0.0.1:5070/training?refresh_interval=10` 응답 정상, 자동 새로고침 UI 포함 +- `http://127.0.0.1:5070/?refresh_interval=10` 응답 정상, 학습 요약 패널 포함 +- `http://127.0.0.1:5070/stom?refresh_interval=10` 응답 정상, 학습 상태 strip 포함 + +사용자가 직접 확인할 URL: + +```text +http://127.0.0.1:5070/training?refresh_interval=10 +``` + +## 8. 현재 단계와 남은 단계 + +| 구분 | 상태 | 완료율 | +|---|---|---:| +| STOM DB -> Qlib/학습 데이터 준비 | 완료 | 100% | +| 전체 학습 실행 준비/런칭 | 완료 | 100% | +| 실시간 학습 대시보드 통합 | 완료 | 100% | +| tokenizer 학습 | 진행 중 | 19.57% | +| predictor 학습 | 대기 | 0% | +| checkpoint 기반 예측 CSV 생성 | 대기 | 0% | +| 실제값 vs 예측값 대시보드 성과 검증 | 대기 | 0% | +| trading score/filter 재검증 | 대기 | 0% | + +전체 프로젝트 관리 진행률은 개발/대시보드 기준으로는 약 97%지만, 실제 학습 산출물 기준으로는 아직 tokenizer 약 19.57% / 전체 both-stage 약 9.78% 단계다. + +## 9. 다음 권장 OMX 명령 + +다음 단계도 새 학습을 시작하지 말고 현재 long-running 학습을 건드리지 않는 점검으로 진행한다. + +```text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +``` diff --git a/docs/stom_2025_full_training_progress_checkpoint_09.md b/docs/stom_2025_full_training_progress_checkpoint_09.md new file mode 100644 index 000000000..3691677fa --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_09.md @@ -0,0 +1,155 @@ +# STOM 2025 전체 학습 진행 체크포인트 09 + +작성일: 2026-05-12 KST +실행 모드: `$ralph` 다음 단계 점검 +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 1. 이번 단계의 목적 + +이번 단계는 STOM tick/1초봉 기반 2025년 전체 학습을 다시 시작하거나 수정하는 단계가 아니다. 이미 실행 중인 장기 학습 프로세스를 유지한 채, 실제 진행률이 증가하는지와 checkpoint/predictor 전환 여부를 재검증하고 문서와 commit으로 남기는 단계다. + +목적을 잃지 않기 위한 기준: + +1. 현재 long-running 학습 프로세스는 중단하지 않는다. +2. STOM DB/sample 데이터는 읽기 전용으로만 다룬다. +3. predictor 전환 전에는 “학습 완료 모델 성과”로 해석하지 않는다. +4. 매 점검마다 진행률, checkpoint, GPU, 대시보드 상태를 기록한다. + +## 2. OMX 도구 사용 결과 + +AGENTS.md 지침에 따라 `omx explore`를 먼저 시도했다. 현재 Windows 환경에서는 POSIX wrapper 기반 allowlist harness 문제로 실패했다. 이어서 `omx sparkshell`도 시도했지만 현재 CLI에서 `program not found`로 실패했다. + +따라서 이번 점검도 PowerShell, Python urllib, `nvidia-smi`, git 검증으로 대체했다. + +```text +omx explore: Windows POSIX harness 미준비로 실패 +omx sparkshell: program not found +fallback: PowerShell + Python urllib + nvidia-smi + git +``` + +## 3. 실시간 학습 상태 + +| 항목 | 값 | +|---|---:| +| 전체 status | running | +| 현재 stage | tokenizer | +| tokenizer step | 1,020,000 / 4,701,721 | +| tokenizer stage 진행률 | 21.6942% | +| 전체 2-stage 진행률 | 10.8471% | +| samples/sec | 약 67.40 | +| 추정 steps/sec | 약 16.85 | +| tokenizer ETA | 약 60.7시간 | +| 최신 loss 로그 | `[Rank 0, Epoch 1/1, Step 1020000/4701721] LR 0.000182, Loss: -0.0311` | + +진행률: + +```text +tokenizer 단계 [████░░░░░░░░░░░░░░░░] 21.69% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 10.85% +predictor 단계 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 4. 이전 체크포인트 대비 증가량 + +이전 commit `dc4e72a`의 체크포인트 08은 `Step 922000 / 4701721`이었다. + +이번 점검 기준: + +```text +이전 체크포인트: 922000 step +현재 체크포인트: 1020000 step +증가량: +98000 step +tokenizer 진행률: 19.6098% -> 21.6942% +전체 진행률: 9.8049% -> 10.8471% +``` + +따라서 학습은 절전/장기 실행 이후에도 계속 누적 진행되고 있다. + +## 5. step 증가 재검증 + +75초 간격으로 live API와 tokenizer CPU time을 다시 확인했다. + +```text +before: Step 1019000 / 4701721, overall 10.8365% +after : Step 1020000 / 4701721, overall 10.8471% +CPU delta for tokenizer PID 70448 over 75s: +75.22 seconds +``` + +해석: + +- step이 실제로 1,000 증가했다. +- tokenizer 프로세스 CPU time도 관측 시간과 거의 같은 폭으로 증가했다. +- 학습은 멈춘 상태가 아니라 계속 진행 중이다. + +## 6. GPU/프로세스 상태 + +GPU 최신 관측: + +```text +NVIDIA GeForce RTX 4080 SUPER, util 약 40%, VRAM 약 3241 / 16376 MiB, temp 약 42C +``` + +프로세스 관측: + +- runner PID `3944` 생존 +- tokenizer child PID `70448` 생존 +- webui PID `147452`, `82776` 생존 + +판단: + +- GPU와 CPU 모두 장기 학습에 계속 사용 중이다. +- VRAM 사용량과 온도는 안정 범위로 보인다. +- 현재 병목은 VRAM 한계보다 학습 loop/데이터 처리/모델 연산 속도 쪽으로 보는 것이 맞다. + +## 7. checkpoint/predictor 상태 + +현재 artifact 확인 결과: + +```text +checkpoint/model/predictor artifact count: 0 +finetune_tokenizer/checkpoints exists: true +finetune_tokenizer/checkpoints file count: 0 +``` + +결론: + +- tokenizer checkpoint는 아직 저장되지 않았다. +- predictor progress/log/checkpoint는 아직 생성되지 않았다. +- predictor 전환 전이므로 아직 실제 예측 모델 성과를 판단할 수 없다. + +## 8. 웹 대시보드 상태 + +HTTP 확인 결과: + +- `http://127.0.0.1:5070/training?refresh_interval=10` 응답 정상, 자동 새로고침 UI 포함 +- `http://127.0.0.1:5070/?refresh_interval=10` 응답 정상, 학습 요약 패널 포함 +- `http://127.0.0.1:5070/stom?refresh_interval=10` 응답 정상, 학습 상태 strip 포함 + +사용자가 직접 확인할 URL: + +```text +http://127.0.0.1:5070/training?refresh_interval=10 +``` + +## 9. 현재 단계와 남은 단계 + +| 구분 | 상태 | 완료율 | +|---|---|---:| +| STOM DB -> Qlib/학습 데이터 준비 | 완료 | 100% | +| 전체 학습 실행 준비/런칭 | 완료 | 100% | +| 실시간 학습 대시보드 통합 | 완료 | 100% | +| tokenizer 학습 | 진행 중 | 21.69% | +| predictor 학습 | 대기 | 0% | +| checkpoint 기반 예측 CSV 생성 | 대기 | 0% | +| 실제값 vs 예측값 대시보드 성과 검증 | 대기 | 0% | +| trading score/filter 재검증 | 대기 | 0% | + +현재는 “개발 준비” 관점에서는 거의 완료 단계지만, “학습 산출물” 관점에서는 아직 전체 both-stage 기준 약 10.85%다. + +## 10. 다음 권장 OMX 명령 + +다음 단계도 새 학습을 시작하지 말고 현재 long-running 학습을 보존하면서 점검한다. + +```text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +``` diff --git a/docs/stom_2025_full_training_progress_checkpoint_10.md b/docs/stom_2025_full_training_progress_checkpoint_10.md new file mode 100644 index 000000000..e0047535e --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_10.md @@ -0,0 +1,156 @@ +# STOM 2025 전체 학습 진행 체크포인트 10 + +작성일: 2026-05-12 KST +실행 모드: `$ralph` 다음 단계 점검 +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 1. 이번 단계의 목적 + +이번 단계는 실행 중인 STOM tick/1초봉 기반 2025년 전체 학습을 보존하면서 진행 상태를 다시 확인하는 체크포인트다. 학습을 재시작하거나 중단하지 않고, 현재 tokenizer 진행률과 checkpoint/predictor 전환 여부만 검증한다. + +목표를 잃지 않기 위한 기준: + +1. long-running 학습 프로세스를 중단하지 않는다. +2. STOM DB/sample 데이터는 수정하지 않는다. +3. checkpoint가 생기기 전까지 예측 성과를 말하지 않는다. +4. predictor 전환 전에는 “학습 완료 모델”로 판단하지 않는다. +5. 매 점검 결과를 문서와 commit으로 남긴다. + +## 2. OMX 도구 사용 결과 + +AGENTS.md 지침에 따라 `omx explore`를 먼저 시도했지만, 현재 Windows 환경에서 POSIX wrapper 기반 allowlist harness가 준비되지 않아 실패했다. 이어 `omx sparkshell`도 시도했지만 현재 CLI에서 `program not found`로 실패했다. + +따라서 이번 점검은 PowerShell, Python urllib, `nvidia-smi`, git 검증으로 대체했다. + +```text +omx explore: Windows POSIX harness 미준비로 실패 +omx sparkshell: program not found +fallback: PowerShell + Python urllib + nvidia-smi + git +``` + +## 3. 실시간 학습 상태 + +| 항목 | 값 | +|---|---:| +| 전체 status | running | +| 현재 stage | tokenizer | +| tokenizer step | 1,079,000 / 4,701,721 | +| tokenizer stage 진행률 | 22.9490% | +| 전체 2-stage 진행률 | 11.4745% | +| samples/sec | 약 66.88 | +| 추정 steps/sec | 약 16.72 | +| tokenizer ETA | 약 60.2시간 | +| 최신 loss 로그 | `[Rank 0, Epoch 1/1, Step 1079000/4701721] LR 0.000180, Loss: -0.0291` | + +진행률: + +```text +tokenizer 단계 [█████░░░░░░░░░░░░░░░] 22.95% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 11.47% +predictor 단계 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 4. 이전 체크포인트 대비 증가량 + +이전 commit `98630b1`의 체크포인트 09는 `Step 1,020,000 / 4,701,721`이었다. + +이번 점검 기준: + +```text +이전 체크포인트: 1,020,000 step +현재 체크포인트: 1,079,000 step +증가량: +59,000 step +tokenizer 진행률: 21.6942% -> 22.9490% +전체 진행률: 10.8471% -> 11.4745% +``` + +따라서 학습은 checkpoint 09 이후에도 계속 누적 진행되고 있다. + +## 5. step 증가 재검증 + +75초 간격으로 live API와 tokenizer CPU time을 다시 확인했다. + +```text +before: Step 1078000 / 4701721, overall 11.4639% +after : Step 1079000 / 4701721, overall 11.4745% +CPU delta for tokenizer PID 70448 over 75s: +75.23 seconds +``` + +해석: + +- step이 실제로 1,000 증가했다. +- tokenizer 프로세스 CPU time도 관측 시간과 거의 같은 폭으로 증가했다. +- 학습은 멈춘 상태가 아니라 계속 진행 중이다. + +## 6. GPU/프로세스 상태 + +GPU 최신 관측: + +```text +NVIDIA GeForce RTX 4080 SUPER, util 약 35%, VRAM 약 3316 / 16376 MiB, temp 약 50C +``` + +프로세스 관측: + +- runner PID `3944` 생존 +- tokenizer child PID `70448` 생존 +- webui PID `147452`, `122960` 생존 + +판단: + +- GPU와 CPU 모두 장기 학습에 계속 사용 중이다. +- VRAM 사용량은 4080 SUPER 기준 여유가 있으나, 현재 학습 속도는 약 66.9 samples/sec 수준으로 관측된다. +- 장기 학습 진행 상황을 볼 때 GPU 고부하 단일 작업이라기보다 데이터 처리와 학습 루프가 함께 병목에 영향을 주는 상태로 해석된다. + +## 7. checkpoint/predictor 상태 + +현재 artifact 확인 결과: + +```text +checkpoint/model/predictor artifact count: 0 +finetune_tokenizer/checkpoints exists: true +finetune_tokenizer/checkpoints file count: 0 +``` + +결론: + +- tokenizer checkpoint는 아직 저장되지 않았다. +- predictor progress/log/checkpoint는 아직 생성되지 않았다. +- predictor 전환 전이므로 아직 실제 예측 모델 성과를 판단할 수 없다. + +## 8. 웹 대시보드 상태 + +HTTP 확인 결과: + +- `http://127.0.0.1:5070/training?refresh_interval=10` 응답 정상, 자동 새로고침 UI 포함 +- `http://127.0.0.1:5070/?refresh_interval=10` 응답 정상, 학습 요약 패널 포함 +- `http://127.0.0.1:5070/stom?refresh_interval=10` 응답 정상, 학습 상태 strip 포함 + +사용자가 직접 확인할 URL: + +```text +http://127.0.0.1:5070/training?refresh_interval=10 +``` + +## 9. 현재 단계와 남은 단계 + +| 구분 | 상태 | 완료율 | +|---|---|---:| +| STOM DB -> Qlib/학습 데이터 준비 | 완료 | 100% | +| 전체 학습 실행 준비/런칭 | 완료 | 100% | +| 실시간 학습 대시보드 통합 | 완료 | 100% | +| tokenizer 학습 | 진행 중 | 22.95% | +| predictor 학습 | 대기 | 0% | +| checkpoint 기반 예측 CSV 생성 | 대기 | 0% | +| 실제값 vs 예측값 대시보드 성과 검증 | 대기 | 0% | +| trading score/filter 재검증 | 대기 | 0% | + +현재는 “개발/대시보드 준비” 관점에서는 거의 완료 단계지만, “학습 산출물” 관점에서는 전체 both-stage 기준 약 11.47%다. + +## 10. 다음 권장 OMX 명령 + +다음 단계도 새 학습을 시작하지 말고 현재 long-running 학습을 보존하면서 점검한다. + +```text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +``` diff --git a/docs/stom_2025_full_training_progress_checkpoint_11.md b/docs/stom_2025_full_training_progress_checkpoint_11.md new file mode 100644 index 000000000..844d2e177 --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_11.md @@ -0,0 +1,156 @@ +# STOM 2025 전체 학습 진행 체크포인트 11 + +작성일: 2026-05-12 KST +실행 모드: `$ralph` 다음 단계 점검 +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 1. 이번 단계의 목적 + +이번 단계는 실행 중인 STOM tick/1초봉 기반 2025년 전체 학습을 보존하면서 진행 상태를 다시 확인하는 체크포인트다. 학습을 재시작하거나 중단하지 않고, 현재 tokenizer 진행률과 checkpoint/predictor 전환 여부만 검증한다. + +목표를 잃지 않기 위한 기준: + +1. long-running 학습 프로세스를 중단하지 않는다. +2. STOM DB/sample 데이터는 수정하지 않는다. +3. checkpoint가 생기기 전까지 예측 성과를 말하지 않는다. +4. predictor 전환 전에는 “학습 완료 모델”로 판단하지 않는다. +5. 매 점검 결과를 문서와 commit으로 남긴다. + +## 2. OMX 도구 사용 결과 + +AGENTS.md 지침에 따라 `omx explore`를 먼저 시도했지만, 현재 Windows 환경에서 POSIX wrapper 기반 allowlist harness가 준비되지 않아 실패했다. 이어 `omx sparkshell`도 시도했지만 현재 CLI에서 `program not found`로 실패했다. + +따라서 이번 점검은 PowerShell, Python urllib, `nvidia-smi`, git 검증으로 대체했다. + +```text +omx explore: Windows POSIX harness 미준비로 실패 +omx sparkshell: program not found +fallback: PowerShell + Python urllib + nvidia-smi + git +``` + +## 3. 실시간 학습 상태 + +| 항목 | 값 | +|---|---:| +| 전체 status | running | +| 현재 stage | tokenizer | +| tokenizer step | 1,136,000 / 4,701,721 | +| tokenizer stage 진행률 | 24.1614% | +| 전체 2-stage 진행률 | 12.0807% | +| samples/sec | 약 66.24 | +| 추정 steps/sec | 약 16.56 | +| tokenizer ETA | 약 59.8시간 | +| 최신 loss 로그 | `[Rank 0, Epoch 1/1, Step 1136000/4701721] LR 0.000177, Loss: -0.0311` | + +진행률: + +```text +tokenizer 단계 [█████░░░░░░░░░░░░░░░] 24.16% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 12.08% +predictor 단계 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 4. 이전 체크포인트 대비 증가량 + +이전 commit `28043a7`의 체크포인트 10은 `Step 1,079,000 / 4,701,721`이었다. + +이번 점검 기준: + +```text +이전 체크포인트: 1,079,000 step +현재 체크포인트: 1,136,000 step +증가량: +57,000 step +tokenizer 진행률: 22.9490% -> 24.1614% +전체 진행률: 11.4745% -> 12.0807% +``` + +따라서 학습은 checkpoint 10 이후에도 계속 누적 진행되고 있다. + +## 5. step 증가 재검증 + +75초 간격으로 live API와 tokenizer CPU time을 다시 확인했다. + +```text +before: Step 1134000 / 4701721, overall 12.0594% +after : Step 1136000 / 4701721, overall 12.0807% +CPU delta for tokenizer PID 70448 over 75s: +75.30 seconds +``` + +해석: + +- step이 실제로 2,000 증가했다. +- tokenizer 프로세스 CPU time도 관측 시간과 거의 같은 폭으로 증가했다. +- 학습은 멈춘 상태가 아니라 계속 진행 중이다. + +## 6. GPU/프로세스 상태 + +GPU 최신 관측: + +```text +NVIDIA GeForce RTX 4080 SUPER, util 약 43%, VRAM 약 3294 / 16376 MiB, temp 약 50C +``` + +프로세스 관측: + +- runner PID `3944` 생존 +- tokenizer child PID `70448` 생존 +- webui PID `147452`, `91172` 생존 + +판단: + +- GPU와 CPU 모두 장기 학습에 계속 사용 중이다. +- VRAM 사용량은 4080 SUPER 기준 여유가 있으나, 현재 학습 속도는 약 66.2 samples/sec 수준으로 관측된다. +- 장기 학습 진행 상황을 볼 때 GPU 고부하 단일 작업이라기보다 데이터 처리와 학습 루프가 함께 병목에 영향을 주는 상태로 해석된다. + +## 7. checkpoint/predictor 상태 + +현재 artifact 확인 결과: + +```text +checkpoint/model/predictor artifact count: 0 +finetune_tokenizer/checkpoints exists: true +finetune_tokenizer/checkpoints file count: 0 +``` + +결론: + +- tokenizer checkpoint는 아직 저장되지 않았다. +- predictor progress/log/checkpoint는 아직 생성되지 않았다. +- predictor 전환 전이므로 아직 실제 예측 모델 성과를 판단할 수 없다. + +## 8. 웹 대시보드 상태 + +HTTP 확인 결과: + +- `http://127.0.0.1:5070/training?refresh_interval=10` 응답 정상, 자동 새로고침 UI 포함 +- `http://127.0.0.1:5070/?refresh_interval=10` 응답 정상, 학습 요약 패널 포함 +- `http://127.0.0.1:5070/stom?refresh_interval=10` 응답 정상, 학습 상태 strip 포함 + +사용자가 직접 확인할 URL: + +```text +http://127.0.0.1:5070/training?refresh_interval=10 +``` + +## 9. 현재 단계와 남은 단계 + +| 구분 | 상태 | 완료율 | +|---|---|---:| +| STOM DB -> Qlib/학습 데이터 준비 | 완료 | 100% | +| 전체 학습 실행 준비/런칭 | 완료 | 100% | +| 실시간 학습 대시보드 통합 | 완료 | 100% | +| tokenizer 학습 | 진행 중 | 24.16% | +| predictor 학습 | 대기 | 0% | +| checkpoint 기반 예측 CSV 생성 | 대기 | 0% | +| 실제값 vs 예측값 대시보드 성과 검증 | 대기 | 0% | +| trading score/filter 재검증 | 대기 | 0% | + +현재는 “개발/대시보드 준비” 관점에서는 거의 완료 단계지만, “학습 산출물” 관점에서는 전체 both-stage 기준 약 12.08%다. + +## 10. 다음 권장 OMX 명령 + +다음 단계도 새 학습을 시작하지 말고 현재 long-running 학습을 보존하면서 점검한다. + +```text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +``` diff --git a/docs/stom_2025_full_training_progress_checkpoint_12.md b/docs/stom_2025_full_training_progress_checkpoint_12.md new file mode 100644 index 000000000..ba5535b5c --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_12.md @@ -0,0 +1,157 @@ +# STOM 2025 전체 학습 진행 체크포인트 12 + +작성일: 2026-05-12 21:57:41 KST +실행 모드: 읽기 전용 학습 감시 / 주기적 진행 확인 +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 1. 이번 단계의 목적 + +이번 단계의 목적은 개발 방향을 다시 원래 목표인 **STOM 1초봉 2025 full Kronos-small 파인튜닝 진행 감시**로 되돌리는 것이다. + +중요 원칙은 다음과 같다. + +1. 실행 중인 장기 학습 프로세스를 중단하지 않는다. +2. DB, CUDA, PyTorch, 학습 코드, finetune 산출물은 수정하지 않는다. +3. `/api/training/status`, `/api/training/gpu`, `/api/training/artifacts`, `/api/training/history`만 읽는다. +4. checkpoint/predictor가 준비되기 전까지 예측 성과를 확정하지 않는다. +5. 각 중간 점검 결과를 문서와 commit으로 남겨 계획/현재 단계/남은 단계를 추적한다. + +## 2. 현재 학습 상태 + +| 항목 | 현재 값 | +|---|---:| +| Run | `stom_1s_grid_pred60_2025_full_small` | +| 전체 상태 | `running` | +| 현재 stage | `tokenizer` | +| stage 상태 | `running` | +| tokenizer step | `1,847,000 / 4,701,721` | +| tokenizer 진행률 | `39.2835%` | +| 전체 both-stage 진행률 | `19.6417%` | +| samples/sec | `63.6832` | +| ETA seconds | `179,307.55` | +| 완료 예상(KST) | `2026-05-14 23:46:09 KST` | +| 최신 loss | `-0.0293` | +| 최신 progress 업데이트 | `2026-05-12T12:56:58Z` | + +진행률 바: + +```text +tokenizer stage [████████░░░░░░░░░░░░] 39.28% +전체 both-stage 학습 [████░░░░░░░░░░░░░░░░] 19.64% +predictor stage [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 3. 직전 체크포인트 대비 증가 + +직전 문서 `stom_2025_full_training_progress_checkpoint_11.md`의 기준 값은 다음과 같았다. + +| 항목 | 체크포인트 11 | 체크포인트 12 | 증가 | +|---|---:|---:|---:| +| tokenizer step | `1,136,000` | `1,847,000` | `+711,000` | +| tokenizer 진행률 | `24.1614%` | `39.2835%` | `+15.1221%p` | +| 전체 진행률 | `12.0807%` | `19.6417%` | `+7.5610%p` | + +판단: + +- 학습은 멈추지 않고 유의미하게 전진했다. +- step, loss, history 모두 새 값으로 갱신되고 있다. +- 아직 tokenizer 단계이므로 predictor 기반 예측 성과는 판단하지 않는다. + +## 4. GPU / 시스템 상태 + +| 항목 | 현재 값 | +|---|---:| +| GPU 정보 사용 가능 | `true` | +| 평균 GPU Util | `37.0%` | +| 총 VRAM 사용률 | `21.97%` | +| 전력 실측 | `nvidia-smi power draw unavailable` | + +해석: + +- GPU와 VRAM은 사용 중이다. +- 전력 값은 이번 응답에서 null이었지만 GPU 상태 API 자체는 정상 응답했다. +- 현재 병목은 고정적으로 GPU 100%를 채우는 형태라기보다 데이터/학습 루프 혼합 병목으로 보이며, 기존 관찰과 크게 다르지 않다. + +## 5. Artifact / checkpoint / predictor 상태 + +| 항목 | 현재 값 | +|---|---:| +| artifact level | `waiting` | +| model weight file count | `0` | +| checkpoint file count | `0` | +| tokenizer checkpoint ready | `false` | +| predictor started | `false` | +| predictor checkpoint ready | `false` | + +판단: + +- tokenizer checkpoint는 아직 준비되지 않았다. +- predictor 단계는 아직 시작되지 않았다. +- 따라서 현재 단계에서 실제 예측 성과/정확도/수익률을 판단하면 안 된다. + +## 6. History 근거 + +최신 history point: + +```text +[Rank 0, Epoch 1/1, Step 1847000/4701721] LR 0.000139, Loss: -0.0293 +``` + +history point count: `5` + +판단: + +- 진행 로그 tail과 history 파싱이 정상적으로 갱신되고 있다. +- 웹 대시보드가 표시할 progress 소스가 살아 있다. + +## 7. 전체 계획 / 현재 단계 / 남은 단계 + +| 단계 | 상태 | 완료율 | +|---|---|---:| +| STOM DB -> Qlib/학습 데이터 준비 | 완료 | 100% | +| 2025 full 학습 실행 준비 | 완료 | 100% | +| 웹 학습 모니터/성과 대시보드 준비 | 완료 | 100% | +| 루트/학습/STOM 대시보드 KST/한글/테마 개선 | 완료 | 100% | +| tokenizer 학습 | 진행 중 | 39.28% | +| tokenizer checkpoint 생성 확인 | 대기 | 0% | +| predictor 학습 시작 확인 | 대기 | 0% | +| predictor checkpoint 생성 확인 | 대기 | 0% | +| predictor 완료 후 예측/실제값 비교 | 대기 | 0% | +| 성과 지표/종목별 통계/추천 점수 검증 | 대기 | 0% | + +현재 전체 프로젝트 관점 진행률: + +```text +준비/대시보드 개발 [████████████████████] 100% +학습 산출물 생성 [████░░░░░░░░░░░░░░░░] 19.64% +예측 성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0% +``` + +## 8. 다음 단계 + +다음 단계는 계속 **주기적 학습 확인**이다. + +권장 확인 주기: + +- 사람이 보는 중간 점검: 30분~1시간 간격 +- 웹 대시보드 자동 새로고침: 10~30초 +- 중요 milestone: + 1. tokenizer 50% + 2. tokenizer 75% + 3. tokenizer 100% + 4. tokenizer checkpoint 생성 + 5. predictor 시작 + 6. predictor checkpoint 생성 + 7. predictor 완료 + +## 9. 다음 권장 OMX 명령 + +```text +omx ralph --prompt "STOM 2025 full Kronos-small 학습을 중단하지 말고 읽기 전용으로 /api/training/status, /api/training/gpu, /api/training/artifacts, /api/training/history를 확인하여 tokenizer 진행률, ETA(KST), loss, GPU, checkpoint, predictor 전환 여부를 문서와 commit으로 남겨주세요." +``` + +## 10. 결론 + +현재는 **주기적 학습 확인 단계가 맞다.** + +이번 체크포인트 기준으로 학습은 `running`이며 tokenizer 단계가 `39.2835%`, 전체 both-stage가 `19.6417%`까지 진행되었다. predictor는 아직 시작되지 않았고 checkpoint도 아직 없으므로, 다음 의사결정은 tokenizer checkpoint 생성 또는 predictor 전환이 관찰될 때 진행한다. diff --git a/docs/stom_2025_full_training_progress_checkpoint_13.md b/docs/stom_2025_full_training_progress_checkpoint_13.md new file mode 100644 index 000000000..8b0aa881c --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_13.md @@ -0,0 +1,189 @@ +# STOM 2025 전체 학습 진행 체크포인트 13 + +작성일: 2026-05-13 09:59:16 KST +실행 모드: 읽기 전용 학습 감시 / 주기적 진행 확인 +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 1. 이번 단계의 목적 + +이번 단계는 2026-05-13 오전 기준으로 장기 실행 중인 **STOM 1초봉 2025 full Kronos-small 파인튜닝**의 진행 상태를 다시 확인하고, 전체 계획/현재 단계/남은 단계를 commit 단위로 고정하기 위한 체크포인트다. + +이번 점검에서 가장 중요한 변화는 **tokenizer 학습이 50% milestone을 통과했다는 점**이다. + +중요 원칙: + +1. 실행 중인 학습 프로세스를 중단/재시작하지 않는다. +2. DB, CUDA, PyTorch, 학습 코드, finetune 산출물은 수정하지 않는다. +3. `/api/training/status`, `/api/training/gpu`, `/api/training/artifacts`, `/api/training/history`만 읽는다. +4. checkpoint/predictor가 준비되기 전까지 예측 성과를 확정하지 않는다. +5. milestone마다 문서와 commit을 남겨 방향성을 잃지 않는다. + +## 2. OMX 사용 및 fallback + +OMX 지침에 따라 먼저 `omx explore`를 사용해 read-only 맥락 확인을 시도했다. 현재 Windows 환경에서는 POSIX sh/bash wrapper 기반 allowlist runtime이 준비되지 않아 실패했다. + +```text +omx explore: Windows POSIX harness 미준비로 실패 +fallback: PowerShell + Python urllib + git +``` + +따라서 이번 점검은 fallback 경로로 수행했지만, 수행 범위는 여전히 읽기 전용이다. + +## 3. 현재 학습 상태 + +| 항목 | 현재 값 | +|---|---:| +| Run | `stom_1s_grid_pred60_2025_full_small` | +| 전체 상태 | `running` | +| 현재 stage | `tokenizer` | +| stage 상태 | `running` | +| tokenizer step | `2,457,000 / 4,701,721` | +| tokenizer 진행률 | `52.2575%` | +| 전체 both-stage 진행률 | `26.1287%` | +| samples/sec | `61.6811` | +| ETA seconds | `145,569.44` | +| 완료 예상(KST) | `2026-05-15 02:25:25 KST` | +| 최신 loss | `-0.0255` | +| learning rate | `0.000098` | +| 최신 progress 업데이트 | `2026-05-13T00:59:02Z` | + +진행률 바: + +```text +tokenizer stage [██████████░░░░░░░░░░] 52.26% +전체 both-stage 학습 [█████░░░░░░░░░░░░░░░] 26.13% +predictor stage [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 4. 직전 체크포인트 대비 증가 + +직전 문서 `stom_2025_full_training_progress_checkpoint_12.md`의 기준 값은 다음과 같았다. + +| 항목 | 체크포인트 12 | 체크포인트 13 | 증가 | +|---|---:|---:|---:| +| tokenizer step | `1,847,000` | `2,457,000` | `+610,000` | +| tokenizer 진행률 | `39.2835%` | `52.2575%` | `+12.9740%p` | +| 전체 진행률 | `19.6417%` | `26.1287%` | `+6.4870%p` | +| samples/sec | `63.6832` | `61.6811` | `-2.0021` | + +판단: + +- 학습은 밤 사이 계속 진행되었다. +- tokenizer 50% milestone을 통과했다. +- 속도는 소폭 낮아졌지만 여전히 60 samples/sec 수준으로 진행 중이다. +- checkpoint는 아직 없으므로 결과 성과 판단은 여전히 보류한다. + +## 5. GPU / 시스템 상태 + +| 항목 | 현재 값 | +|---|---:| +| GPU 정보 사용 가능 | `true` | +| 평균 GPU Util | `36.0%` | +| 총 VRAM 사용률 | `21.66%` | +| VRAM 사용량 | `3,547 / 16,376 MiB` | +| 전력 실측 | `nvidia-smi power draw unavailable` | +| 전력 제한 | `320.0 W` | +| GPU 상태 생성 시각 | `2026-05-13T00:59:15Z` | + +해석: + +- GPU는 계속 사용 중이다. +- VRAM 사용량은 약 3.5GB로 안정적이다. +- GPU util은 36% 수준으로, 기존 관찰처럼 GPU 100% 고정형 학습은 아니다. +- 학습이 멈춘 상태는 아니며 step/history가 함께 증가하고 있다. + +## 6. Artifact / checkpoint / predictor 상태 + +| 항목 | 현재 값 | +|---|---:| +| artifact level | `waiting` | +| artifact label | `checkpoint 대기` | +| model weight file count | `0` | +| checkpoint file count | `0` | +| tokenizer checkpoint ready | `false` | +| predictor started | `false` | +| predictor checkpoint ready | `false` | +| latest artifact updated at | `null` | + +현재 artifact message: + +```text +현재 run에서 tokenizer/predictor checkpoint 또는 model weight 파일이 아직 확인되지 않았습니다. +``` + +판단: + +- tokenizer checkpoint는 아직 생성되지 않았다. +- predictor는 아직 시작되지 않았다. +- 예측/실제값 비교 또는 정확도 평가는 아직 수행 대상이 아니다. + +## 7. History 근거 + +최신 history point: + +```text +[Rank 0, Epoch 1/1, Step 2457000/4701721] LR 0.000098, Loss: -0.0255 +``` + +history point count: `8` + +판단: + +- progress log 파싱이 정상이다. +- step과 loss가 갱신되고 있으므로 학습은 살아 있다. +- 웹 대시보드가 읽는 history 소스도 정상이다. + +## 8. 전체 계획 / 현재 단계 / 남은 단계 + +| 단계 | 상태 | 완료율 | +|---|---|---:| +| STOM DB -> Qlib/학습 데이터 준비 | 완료 | 100% | +| 2025 full 학습 실행 준비 | 완료 | 100% | +| 웹 학습 모니터/성과 대시보드 준비 | 완료 | 100% | +| 루트/학습/STOM 대시보드 KST/한글/테마 개선 | 완료 | 100% | +| tokenizer 학습 | 진행 중 | 52.26% | +| tokenizer 50% milestone | 완료 | 100% | +| tokenizer 75% milestone | 대기 | 0% | +| tokenizer checkpoint 생성 확인 | 대기 | 0% | +| predictor 학습 시작 확인 | 대기 | 0% | +| predictor checkpoint 생성 확인 | 대기 | 0% | +| predictor 완료 후 예측/실제값 비교 | 대기 | 0% | +| 성과 지표/종목별 통계/추천 점수 검증 | 대기 | 0% | + +현재 전체 프로젝트 관점 진행률: + +```text +준비/대시보드 개발 [████████████████████] 100% +tokenizer 학습 [██████████░░░░░░░░░░] 52.26% +학습 산출물 생성 [█████░░░░░░░░░░░░░░░] 26.13% +예측 성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0% +``` + +## 9. 다음 단계 + +다음 단계는 계속 **주기적 학습 확인**이다. + +다음 우선 milestone: + +1. tokenizer 75% 도달 여부 확인 +2. tokenizer 100% 도달 여부 확인 +3. tokenizer checkpoint 생성 여부 확인 +4. predictor 단계 시작 여부 확인 + +권장 확인 주기: + +- 사람이 확인하는 점검: 1~2시간 간격 +- 대시보드 자동 새로고침: 10~30초 +- 다음 문서화 타이밍: tokenizer 75% 근처 또는 checkpoint 생성 시점 + +## 10. 다음 권장 OMX 명령 + +```text +omx ralph --prompt "STOM 2025 full Kronos-small 학습을 중단하지 말고 읽기 전용으로 /api/training/status, /api/training/gpu, /api/training/artifacts, /api/training/history를 확인하여 tokenizer 75% 도달 여부, ETA(KST), loss, GPU, checkpoint, predictor 전환 여부를 문서와 commit으로 남겨주세요." +``` + +## 11. 결론 + +현재는 **주기적 학습 확인 단계가 계속 맞다.** + +이번 체크포인트 기준으로 학습은 `running`이며 tokenizer 단계가 `52.2575%`, 전체 both-stage가 `26.1287%`까지 진행되었다. tokenizer 50% milestone은 통과했지만 checkpoint와 predictor는 아직 준비되지 않았다. 따라서 다음 의사결정은 tokenizer 75% 또는 checkpoint 생성/ predictor 전환이 관찰될 때 진행한다. diff --git a/docs/stom_2025_full_training_progress_checkpoint_14.md b/docs/stom_2025_full_training_progress_checkpoint_14.md new file mode 100644 index 000000000..010e8ff9d --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_14.md @@ -0,0 +1,247 @@ +# STOM 2025 전체 학습 진행 체크포인트 14 + +작성일: 2026-05-13 22:17:50 KST +실행 모드: 읽기 전용 학습 감시 / predictor 고효율 전환 준비 후 상태 고정 +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 1. 이번 단계의 목적 + +이번 단계는 현재 실행 중인 **STOM 1초봉 2025 full Kronos-small 파인튜닝**을 중단하지 않고, 최신 tokenizer 진행률과 checkpoint/predictor 상태를 다시 확인하여 전체 계획/현재 단계/남은 단계를 commit 단위로 고정하기 위한 체크포인트다. + +이번 점검에서 중요한 변화는 두 가지다. + +1. tokenizer 학습이 **66% 구간**까지 정상 진행되었다. +2. 직전 작업에서 predictor 단계의 GPU 효율 전환 준비가 commit `989b23b`로 완료되었다. + +중요 원칙: + +1. 실행 중인 학습 프로세스를 중단/재시작하지 않는다. +2. DB, CUDA, PyTorch, finetune output은 수정하지 않는다. +3. checkpoint가 생성되기 전까지 predictor 최적화 전환을 실행하지 않는다. +4. 현재 predictor 성과/정확도/수익률은 아직 판단하지 않는다. +5. 각 milestone을 문서와 commit으로 남겨 방향성을 잃지 않는다. + +## 2. OMX 사용 및 fallback + +이번 단계에서 OMX read-only 점검을 우선 시도했다. + +```text +omx sparkshell --prompt "Read-only로 STOM 2025 full Kronos 학습 상태를 점검..." +``` + +결과: + +```text +omx sparkshell: program not found +``` + +따라서 실제 검증은 fallback 경로로 수행했다. + +```text +fallback: PowerShell + Python urllib + local dashboard APIs + process list + git +``` + +읽은 API: + +- `http://127.0.0.1:5070/api/training/status` +- `http://127.0.0.1:5070/api/training/gpu` +- `http://127.0.0.1:5070/api/training/artifacts` +- `http://127.0.0.1:5070/api/training/history?limit=10` + +## 3. 현재 학습 상태 + +| 항목 | 현재 값 | +|---|---:| +| Run | `stom_1s_grid_pred60_2025_full_small` | +| 전체 상태 | `running` | +| 현재 stage | `tokenizer` | +| stage 상태 | `running` | +| tokenizer step | `3,116,000 / 4,701,721` | +| tokenizer 진행률 | `66.2736%` | +| 전체 both-stage 진행률 | `33.1368%` | +| samples/sec | `61.2087` | +| 경과 시간 | `56.56 h` | +| tokenizer 남은 시간 | `28.79 h` | +| tokenizer 완료 예상(KST) | `2026-05-15 03:04:57 KST` | +| 현재 설정 유지 시 전체 완료 예상(KST) | `2026-05-18 16:25:56 KST` | +| 최신 loss | `-0.0305` | +| learning rate | `0.000054` | +| 최신 progress 업데이트 | `2026-05-13T13:17:17Z` | + +진행률 바: + +```text +tokenizer stage [█████████████░░░░░░░] 66.27% +전체 both-stage 학습 [███████░░░░░░░░░░░░░] 33.14% +predictor stage [░░░░░░░░░░░░░░░░░░░░] 0.00% +예측/성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 4. 직전 체크포인트 대비 증가 + +직전 문서 `stom_2025_full_training_progress_checkpoint_13.md`의 기준 값과 비교한다. + +| 항목 | 체크포인트 13 | 체크포인트 14 | 증가 | +|---|---:|---:|---:| +| tokenizer step | `2,457,000` | `3,116,000` | `+659,000` | +| tokenizer 진행률 | `52.2575%` | `66.2736%` | `+14.0161%p` | +| 전체 진행률 | `26.1287%` | `33.1368%` | `+7.0081%p` | +| samples/sec | `61.6811` | `61.2087` | `-0.4724` | + +판단: + +- 학습은 계속 진행 중이며 step/history가 증가했다. +- tokenizer 50% milestone 이후 75%를 향해 정상적으로 이동 중이다. +- 속도는 거의 동일한 61 samples/sec 구간이다. +- checkpoint는 아직 없으므로 학습 중단/재시작은 여전히 금지한다. + +## 5. GPU / 시스템 상태 + +| 항목 | 현재 값 | +|---|---:| +| GPU 정보 사용 가능 | `true` | +| 평균 GPU Util | `40.0%` | +| 총 VRAM 사용률 | `19.67%` | +| VRAM 사용량 | `3,221 / 16,376 MiB` | +| 전력 실측 | `null` | +| 전력 제한 | `320.0 W` | + +해석: + +- GPU는 계속 사용 중이다. +- VRAM 여유는 여전히 크다. +- 현재 tokenizer run은 `batch_size=4`, `num_workers=0`으로 이미 실행 중이므로, 지금 학습에 새 효율 설정을 소급 반영하지 않는다. +- GPU 효율 개선은 tokenizer checkpoint 이후 predictor 전환 시점에 적용한다. + +## 6. Artifact / checkpoint / predictor 상태 + +| 항목 | 현재 값 | +|---|---:| +| artifact level | `waiting` | +| artifact label | `checkpoint 대기` | +| model weight file count | `0` | +| checkpoint file count | `0` | +| tokenizer checkpoint ready | `false` | +| predictor started | `false` | +| predictor checkpoint ready | `false` | + +현재 artifact message: + +```text +현재 run에서 tokenizer/predictor checkpoint 또는 model weight 파일이 아직 확인되지 않았습니다. +``` + +판단: + +- tokenizer checkpoint가 아직 없으므로 predictor 최적화 전환은 아직 실행 대상이 아니다. +- 자동 predictor가 시작되었는지도 아직 아니다. +- 예측값/실제값 대시보드 성과 판단은 predictor checkpoint 이후로 미룬다. + +## 7. 프로세스 생존 근거 + +확인된 학습 관련 프로세스: + +| 프로세스 | PID | 의미 | +|---|---:|---| +| `run_stom_1s_finetune.py` | `3944` | 현재 both-stage 부모 프로세스 | +| `train_tokenizer.py` | `70448` | 현재 tokenizer 학습 프로세스 | +| `run_stom_server_live.py` | `15080` | 학습 상태 확인용 로컬 서버 | +| `web.main:app` | `113648`, `182288` | 웹/API 서버 | +| `webui/run.py` | `147452`, `190596` | 웹 UI 프로세스 | + +판단: + +- 대화 재시작 후에도 학습 프로세스는 살아 있다. +- 현재 단계에서 PC 재부팅/프로세스 종료/절전 진입은 여전히 피해야 한다. + +## 8. predictor 고효율 전환 준비 상태 + +직전 작업에서 다음 commit으로 predictor 전환 준비를 완료했다. + +```text +989b23b predictor 전환 시점에 GPU 효율 설정을 분리하게 하다 +``` + +추가된 옵션: + +- `--tokenizer-batch-size` +- `--predictor-batch-size` +- `--tokenizer-num-workers` +- `--predictor-num-workers` + +준비 문서: + +```text +docs/stom_predictor_max_efficiency_handoff.md +``` + +주의: + +- 현재 이미 실행 중인 부모 프로세스에는 이 새 옵션이 소급 적용되지 않는다. +- tokenizer checkpoint 생성 후 predictor-only 고효율 실행 또는 통제된 전환이 필요하다. +- checkpoint가 생기기 전에는 전환하지 않는다. + +## 9. 전체 계획 / 현재 단계 / 남은 단계 + +| 페이지/단계 | 상태 | 완료율 | 비고 | +|---|---|---:|---| +| 1. STOM DB 분석/학습 데이터 변환 | 완료 | 100% | 2025 pred60 학습 데이터 준비 완료 | +| 2. Kronos 공식 방식 tokenizer/predictor 파이프라인 구축 | 완료 | 100% | runner/monitor/dashboard 준비 | +| 3. 웹 학습 모니터/성과 대시보드/KST/한글/테마 | 완료 | 100% | 웹 UI 개선 완료 | +| 4. tokenizer 2025 full 학습 | 진행 중 | 66.27% | 현재 핵심 진행 단계 | +| 5. tokenizer checkpoint 생성 확인 | 대기 | 0% | 아직 checkpoint 없음 | +| 6. predictor 고효율 전환 준비 | 완료 | 100% | commit `989b23b` | +| 7. predictor 최적화 벤치마크 | 대기 | 0% | checkpoint 이후 실행 | +| 8. predictor full 학습 | 대기 | 0% | batch/workers 최적화 후보 사용 | +| 9. predictor checkpoint 생성 확인 | 대기 | 0% | 모델 사용 가능 조건 | +| 10. 실제값/예측값 대시보드 검증 | 대기 | 0% | 성과 지표/종목별 비교 | +| 11. 종목별 통계/점수화/추천 활용 검증 | 대기 | 0% | 최종 목적 | + +전체 진행률 요약: + +```text +준비/대시보드 개발 [████████████████████] 100% +tokenizer 학습 [█████████████░░░░░░░] 66.27% +전체 both-stage 학습 [███████░░░░░░░░░░░░░] 33.14% +predictor 최적화 준비 [████████████████████] 100% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0% +예측/성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0% +``` + +## 10. 다음 단계 + +다음 단계는 계속 **주기적 학습 확인**이다. + +우선순위: + +1. tokenizer 75% 도달 확인 +2. tokenizer 100% 도달 확인 +3. tokenizer checkpoint 생성 확인 +4. 자동 predictor 시작 여부 확인 +5. checkpoint가 존재하면 predictor 고효율 전환 계획 실행 + +권장 확인 주기: + +- 사람이 확인하는 점검: 1~2시간 간격 +- 대시보드 자동 새로고침: 10~30초 +- 다음 문서화 타이밍: tokenizer 75% 근처 또는 checkpoint 생성 시점 + +## 11. 다음 권장 OMX 명령 + +현재 `omx sparkshell`은 PATH에서 찾을 수 없었다. 따라서 다음 명령은 두 단계로 권장한다. + +### 11.1 OMX 환경 확인 + +```powershell +omx doctor +``` + +### 11.2 학습 상태 read-only 점검 요청 + +```text +omx 스킬을 사용해서 STOM 2025 full Kronos 학습 상태를 read-only로 점검하고, /api/training/status, /api/training/gpu, /api/training/artifacts, /api/training/history 기준으로 tokenizer 75% 도달 여부, ETA(KST), checkpoint, predictor 시작 여부를 문서와 commit으로 남겨주세요. checkpoint가 없으면 학습은 건드리지 마세요. +``` + +## 12. 결론 + +현재 작업 방향은 유지한다. 지금은 **tokenizer 장기 학습 감시 단계**이며, predictor 고효율 전환 준비는 완료되었다. 다음 의사결정은 tokenizer checkpoint가 실제 생성된 뒤에만 진행한다. 현재 상태에서 학습 프로세스를 중단하거나 재부팅하는 것은 checkpoint가 없기 때문에 권장하지 않는다. diff --git a/docs/stom_2025_full_training_progress_checkpoint_15.md b/docs/stom_2025_full_training_progress_checkpoint_15.md new file mode 100644 index 000000000..ce1674697 --- /dev/null +++ b/docs/stom_2025_full_training_progress_checkpoint_15.md @@ -0,0 +1,270 @@ +# STOM 2025 전체 학습 진행 체크포인트 15 + +작성일: 2026-05-14 06:39:31 KST +실행 모드: 읽기 전용 학습 감시 / tokenizer 75% milestone 통과 확인 +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 1. 이번 단계의 목적 + +이번 단계는 장기 실행 중인 **STOM 1초봉 2025 full Kronos-small 파인튜닝**이 밤 사이 계속 진행되었는지 확인하고, tokenizer 75% milestone 통과 여부와 checkpoint/predictor 전환 준비 상태를 문서와 commit으로 고정하기 위한 체크포인트다. + +이번 점검의 핵심 결론: + +1. tokenizer 학습이 **78.7584%**까지 진행되어 **75% milestone을 통과**했다. +2. 전체 both-stage 진행률은 **39.3792%**다. +3. tokenizer checkpoint는 아직 없고 predictor도 아직 시작되지 않았다. +4. 따라서 predictor 고효율 전환은 아직 실행하지 않고, tokenizer 100% 및 checkpoint 생성을 계속 감시한다. + +중요 원칙: + +1. 실행 중인 학습 프로세스를 중단/재시작하지 않는다. +2. DB, CUDA, PyTorch, finetune output은 수정하지 않는다. +3. tokenizer checkpoint가 생성되기 전까지 predictor 최적화 전환을 실행하지 않는다. +4. predictor가 시작되거나 checkpoint가 생성되면 별도 전환 판단을 문서화한다. +5. 현재는 예측 성과/정확도/수익률을 판단하지 않는다. + +## 2. OMX 사용 및 fallback + +OMX 지침에 따라 read-only 점검 경로를 우선 시도했다. + +```text +omx explore --prompt "Read-only: check current STOM 2025 full Kronos training status..." +``` + +결과: + +```text +Error: [explore] the built-in explore harness is not ready on Windows because its allowlist runtime relies on POSIX sh/bash wrappers. +``` + +추가로 `omx sparkshell`도 시도했다. + +```text +omx sparkshell --prompt "Read-only: summarize STOM training status..." +``` + +결과: + +```text +omx sparkshell: program not found +``` + +따라서 실제 검증은 fallback 경로로 수행했다. + +```text +fallback: PowerShell + Python urllib + local dashboard APIs + process list + git +``` + +읽은 API: + +- `http://127.0.0.1:5070/api/training/status` +- `http://127.0.0.1:5070/api/training/gpu` +- `http://127.0.0.1:5070/api/training/artifacts` +- `http://127.0.0.1:5070/api/training/history?limit=12` + +## 3. 현재 학습 상태 + +| 항목 | 현재 값 | +|---|---:| +| Run | `stom_1s_grid_pred60_2025_full_small` | +| 전체 상태 | `running` | +| 현재 stage | `tokenizer` | +| stage 상태 | `running` | +| tokenizer step | `3,703,000 / 4,701,721` | +| tokenizer 진행률 | `78.7584%` | +| 전체 both-stage 진행률 | `39.3792%` | +| samples/sec | `63.3692` | +| 경과 시간 | `64.93 h` | +| tokenizer 남은 시간 | `17.51 h` | +| tokenizer 완료 예상(KST) | `2026-05-15 00:10:12 KST` | +| 현재 설정 유지 시 전체 완료 예상(KST) | `2026-05-18 10:36:35 KST` | +| 최신 loss | `-0.0297` | +| learning rate | `0.000023` | +| 최신 progress 업데이트 | `2026-05-13T21:39:08Z` | + +진행률 바: + +```text +tokenizer stage [████████████████░░░░] 78.76% +전체 both-stage 학습 [████████░░░░░░░░░░░░] 39.38% +predictor stage [░░░░░░░░░░░░░░░░░░░░] 0.00% +예측/성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 4. 직전 체크포인트 대비 증가 + +직전 문서 `stom_2025_full_training_progress_checkpoint_14.md` 기준 값과 비교한다. + +| 항목 | 체크포인트 14 | 체크포인트 15 | 증가 | +|---|---:|---:|---:| +| tokenizer step | `3,116,000` | `3,703,000` | `+587,000` | +| tokenizer 진행률 | `66.2736%` | `78.7584%` | `+12.4848%p` | +| 전체 진행률 | `33.1368%` | `39.3792%` | `+6.2424%p` | +| samples/sec | `61.2087` | `63.3692` | `+2.1605` | + +판단: + +- 학습은 정상적으로 계속 진행 중이다. +- tokenizer 75% milestone은 완료로 기록한다. +- samples/sec는 이전보다 소폭 개선되어 63 samples/sec 구간이다. +- tokenizer 남은 시간이 약 17.5시간으로 감소했다. +- checkpoint는 아직 없으므로 predictor 최적화 전환은 아직 실행하지 않는다. + +## 5. GPU / 시스템 상태 + +| 항목 | 현재 값 | +|---|---:| +| GPU 정보 사용 가능 | `true` | +| 평균 GPU Util | `34.0%` | +| 총 VRAM 사용률 | `18.94%` | +| VRAM 사용량 | `3,101 / 16,376 MiB` | +| 전력 실측 | `null` | +| 전력 제한 | `320.0 W` | + +해석: + +- GPU는 계속 사용 중이다. +- VRAM 여유는 크지만 현재 tokenizer 프로세스는 이미 `batch_size=4`, `num_workers=0`으로 시작되어 있어 실행 중 변경하지 않는다. +- GPU 효율 개선은 tokenizer checkpoint 이후 predictor 단계에서 적용한다. + +## 6. Artifact / checkpoint / predictor 상태 + +| 항목 | 현재 값 | +|---|---:| +| artifact level | `waiting` | +| artifact label | `checkpoint 대기` | +| model weight file count | `0` | +| checkpoint file count | `0` | +| tokenizer checkpoint ready | `false` | +| predictor started | `false` | +| predictor checkpoint ready | `false` | + +stage별 artifact 판단: + +| stage | folder | checkpoint dir | checkpoint ready | progress | +|---|---|---|---:|---:| +| tokenizer | 있음 | `finetune_tokenizer/checkpoints` 있음 | `false` | 있음 | +| predictor | 없음 | 없음 | `false` | 없음 | + +현재 artifact message: + +```text +현재 run에서 tokenizer/predictor checkpoint 또는 model weight 파일이 아직 확인되지 않았습니다. +``` + +판단: + +- tokenizer checkpoint가 아직 없으므로 현재까지 진행분은 프로세스 종료/재부팅에 취약하다. +- predictor는 아직 시작되지 않았다. +- predictor 고효율 전환 준비는 완료되어 있으나 실행 조건은 아직 충족되지 않았다. + +## 7. 프로세스 생존 근거 + +확인된 학습 관련 프로세스: + +| 프로세스 | PID | 의미 | +|---|---:|---| +| `run_stom_1s_finetune.py` | `3944` | 현재 both-stage 부모 프로세스 | +| `train_tokenizer.py` | `70448` | 현재 tokenizer 학습 프로세스 | +| `run_stom_server_live.py` | `15080` | 학습 상태 확인용 로컬 서버 | +| `web.main:app` | `113648` | 웹/API 서버 | +| `webui/run.py` | `147452`, `190596` | 웹 UI 프로세스 | + +판단: + +- 학습은 살아 있다. +- 대화 재시작과 무관하게 별도 Python 학습 프로세스가 계속 실행 중이다. +- checkpoint가 없으므로 PC 재부팅/학습 프로세스 종료/절전 진입은 계속 피한다. + +## 8. predictor 고효율 전환 준비 상태 + +이미 다음 commit으로 predictor 전환 준비를 완료했다. + +```text +989b23b predictor 전환 시점에 GPU 효율 설정을 분리하게 하다 +``` + +준비된 옵션: + +- `--tokenizer-batch-size` +- `--predictor-batch-size` +- `--tokenizer-num-workers` +- `--predictor-num-workers` + +준비 문서: + +```text +docs/stom_predictor_max_efficiency_handoff.md +``` + +현재 판단: + +- tokenizer checkpoint가 아직 없으므로 실제 predictor 최적화 실행은 보류한다. +- tokenizer 100% 이후 validation/checkpoint 저장이 끝나면 predictor 자동 시작 여부를 즉시 확인한다. +- checkpoint가 존재할 때만 predictor-only 고효율 벤치마크 또는 전환을 고려한다. + +## 9. 전체 계획 / 현재 단계 / 남은 단계 + +| 페이지/단계 | 상태 | 완료율 | 비고 | +|---|---|---:|---| +| 1. STOM DB 분석/학습 데이터 변환 | 완료 | 100% | 2025 pred60 학습 데이터 준비 완료 | +| 2. Kronos 공식 방식 tokenizer/predictor 파이프라인 구축 | 완료 | 100% | runner/monitor/dashboard 준비 | +| 3. 웹 학습 모니터/성과 대시보드/KST/한글/테마 | 완료 | 100% | 웹 UI 개선 완료 | +| 4. tokenizer 50% milestone | 완료 | 100% | checkpoint 13 | +| 5. tokenizer 75% milestone | 완료 | 100% | 이번 checkpoint 15 | +| 6. tokenizer 2025 full 학습 | 진행 중 | 78.76% | 현재 핵심 진행 단계 | +| 7. tokenizer checkpoint 생성 확인 | 대기 | 0% | 아직 checkpoint 없음 | +| 8. predictor 고효율 전환 준비 | 완료 | 100% | commit `989b23b` | +| 9. predictor 최적화 벤치마크 | 대기 | 0% | checkpoint 이후 실행 | +| 10. predictor full 학습 | 대기 | 0% | batch/workers 최적화 후보 사용 | +| 11. predictor checkpoint 생성 확인 | 대기 | 0% | 모델 사용 가능 조건 | +| 12. 실제값/예측값 대시보드 검증 | 대기 | 0% | 성과 지표/종목별 비교 | +| 13. 종목별 통계/점수화/추천 활용 검증 | 대기 | 0% | 최종 목적 | + +전체 진행률 요약: + +```text +준비/대시보드 개발 [████████████████████] 100% +tokenizer 학습 [████████████████░░░░] 78.76% +전체 both-stage 학습 [████████░░░░░░░░░░░░] 39.38% +predictor 최적화 준비 [████████████████████] 100% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0% +예측/성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0% +``` + +## 10. 다음 단계 + +다음 단계는 **tokenizer 100% 및 checkpoint 생성 감시**다. + +우선순위: + +1. tokenizer 90% 근처 진행 확인 +2. tokenizer 100% 도달 확인 +3. validation 진행 여부 확인 +4. tokenizer checkpoint 생성 확인 +5. 자동 predictor 시작 여부 확인 +6. checkpoint 존재 시 predictor 고효율 전환/벤치마크 판단 + +권장 확인 주기: + +- 사람이 확인하는 점검: 1~2시간 간격 +- 90% 이후 확인: 30~60분 간격 권장 +- 다음 문서화 타이밍: tokenizer 90% 근처 또는 checkpoint 생성 시점 + +## 11. 다음 권장 OMX 명령 + +현재 `omx explore`와 `omx sparkshell`은 이 Windows 환경에서 바로 사용할 수 없었다. 따라서 먼저 OMX 상태 확인을 권장한다. + +```powershell +omx doctor +``` + +다음 Codex 요청 문구: + +```text +$ralph STOM 2025 full Kronos 학습 상태를 read-only로 점검하고, tokenizer 90% 또는 100% 도달 여부, ETA(KST), checkpoint 생성 여부, predictor 시작 여부를 문서와 commit으로 남겨주세요. checkpoint가 없으면 학습은 절대 건드리지 마세요. +``` + +## 12. 결론 + +현재 작업 방향은 올바르다. tokenizer는 75% milestone을 통과했고, predictor 최적화 전환 준비도 이미 끝나 있다. 그러나 checkpoint가 아직 없으므로 지금은 여전히 **학습 감시 단계**다. 다음 의사결정은 tokenizer 100% 이후 checkpoint 생성 또는 predictor 자동 시작이 관찰될 때 진행한다. diff --git a/docs/stom_2025_tokenizer_validation_oom_recovery.md b/docs/stom_2025_tokenizer_validation_oom_recovery.md new file mode 100644 index 000000000..fa8690680 --- /dev/null +++ b/docs/stom_2025_tokenizer_validation_oom_recovery.md @@ -0,0 +1,204 @@ +# STOM 2025 full tokenizer validation OOM 실패 분석 및 복구 노하우 + +작성일: 2026-05-15 KST +대상 run: `stom_1s_grid_pred60_2025_full_small` +대상 데이터: STOM 1초봉 2025 QlibDataset, pred60, full sequential 계열 + +## 1. 현재 실패 현황 + +| 항목 | 확인 내용 | +| --- | --- | +| 실패 단계 | tokenizer fine-tuning | +| 실패 시각 | 2026-05-15 00:39:03 KST (`2026-05-14T15:39:03Z`) | +| return code | `1` | +| 마지막 기록 진행률 | tokenizer 99.9847%, 전체 49.9923% | +| 마지막 train step 로그 | `4701000 / 4701721` | +| predictor 단계 | 시작하지 않음 | +| tokenizer checkpoint | 없음 | +| 웹 대시보드 | 실패 확인 시점에 `127.0.0.1:5070` 연결 불가 | + +주의: 99.9847%는 마지막으로 파싱된 학습 로그 기준이다. 실제로는 학습 루프가 끝나고 validation 단계로 넘어간 뒤 CUDA OOM으로 실패했으므로, “완료된 모델”이 생성된 것은 아니다. + +## 2. 직접 증거 + +### 2.1 manifest / progress + +- `finetune/outputs/stom_1s_grid_pred60_2025_full_small/tokenizer_run_manifest.json` + - `status: failed` + - `returncode: 1` + - `completed_at: 2026-05-14T15:39:03Z` +- `finetune/outputs/stom_1s_grid_pred60_2025_full_small/logs/tokenizer.progress.json` + - `status: failed` + - `progress.step: 4701000` + - `progress.total_steps: 4701721` + - `stage.percent: 99.9847` + - `metrics.best_model_path: null` +- `finetune/outputs/stom_1s_grid_pred60_2025_full_small/run_manifest.json` + - predictor manifest는 `dry_run` 상태로 남아 있었다. +- `finetune/outputs/stom_1s_grid_pred60_2025_full_small/logs/predictor.progress.json` + - 존재하지 않았다. + +### 2.2 stdout traceback + +`logs/tokenizer.stdout.log` 마지막 구간에 다음 실패가 기록되었다. + +```text +[Rank 0, Epoch 1/1, Step 4701000/4701721] LR 0.000000, Loss: -0.0274 +Traceback (most recent call last): + File "D:\Chanil_Park\Project\Programming\Kronos\finetune\train_tokenizer.py", line 196, in train_model + zs, _, _, _ = model(ori_batch_x) +torch.AcceleratorError: CUDA error: out of memory +``` + +실패 지점은 `finetune/train_tokenizer.py`의 validation loop 내부 forward pass이다. + +## 3. 원인 분석 + +### 3.1 1차 원인: validation forward 중 CUDA OOM + +학습이 끝난 뒤 아래 validation loop에서 GPU 메모리 할당이 실패했다. + +```python +model.eval() +with torch.no_grad(): + for ori_batch_x, _ in val_loader: + ori_batch_x = ori_batch_x.to(device, non_blocking=True) + zs, _, _, _ = model(ori_batch_x) +``` + +이번 run은 tokenizer train batch size가 4였고 validation batch도 같은 값을 사용했다. 장시간 학습 후 validation으로 전환될 때 마지막 train tensor/optimizer gradient/캐시/Windows WDDM 환경의 VRAM 단편화 등이 겹치면 validation 첫 구간 또는 초반 forward에서 OOM이 날 수 있다. + +### 3.2 왜 83시간 가까운 학습분이 모델로 남지 않았나 + +기존 코드에서는 checkpoint 저장이 validation loss 계산 뒤에만 실행되었다. + +흐름: + +1. train loop +2. validation loop +3. validation loss 계산 +4. `checkpoints/best_model` 저장 + +이번 실패는 2번 validation loop에서 발생했기 때문에 4번까지 도달하지 못했다. 따라서 `best_model_path`는 `null`이고 checkpoint 폴더도 비어 있었다. + +### 3.3 predictor 설정은 원인이 아님 + +predictor 고효율 handoff 설정은 predictor 시작 시점에만 적용된다. 이번 실패는 tokenizer validation 단계에서 발생했고 predictor progress 파일도 없으므로 predictor 설정은 실패 원인이 아니다. + +## 4. 개선한 방어 전략 + +이번 개선의 목적은 “OOM을 절대 낼 수 없게 한다”가 아니라, 긴 학습 이후 validation OOM이 발생해도 학습 결과를 잃지 않고 원인을 추적할 수 있게 만드는 것이다. + +### 4.1 validation 전 checkpoint 저장 + +새 설정: + +- `KRONOS_TOKENIZER_SAVE_PRE_VAL_CHECKPOINT=1` +- `KRONOS_TOKENIZER_PRE_VAL_CHECKPOINT_NAME=latest_train_model` + +각 epoch 학습 loop가 끝난 직후, validation 시작 전에 다음 경로에 checkpoint를 저장한다. + +```text +/finetune_tokenizer/checkpoints/latest_train_model +``` + +validation이 실패해도 이 checkpoint는 남는다. + +### 4.2 tokenizer validation batch size 분리 + +새 설정: + +- `KRONOS_TOKENIZER_VAL_BATCH_SIZE` + +기존에는 train batch size와 validation batch size가 동일했다. 이제 tokenizer validation만 별도 batch size를 사용할 수 있다. + +STOM full mode runner 기본값: + +```text +train batch size = 4 +validation batch size = 1 +``` + +즉, full run에서는 validation 속도를 일부 희생해서 VRAM 안정성을 우선한다. + +### 4.3 validation 전 CUDA 메모리 정리 + +새 설정: + +- `KRONOS_TOKENIZER_EMPTY_CACHE_BEFORE_VAL=1` + +validation 전에 다음을 수행한다. + +- 마지막 train tensor 참조 삭제 +- optimizer gradient를 `set_to_none=True`로 정리 +- `torch.cuda.empty_cache()` 호출 +- validation은 `torch.inference_mode()`로 수행 + +### 4.4 validation OOM 실패 artifact 기록 + +validation에서 CUDA OOM이 다시 발생하면 다음 파일을 기록한다. + +```text +/finetune_tokenizer/validation_failure.json +``` + +기록 내용: + +- 실패 stage +- epoch +- error type +- error message +- CUDA OOM 여부 +- pre-validation checkpoint 경로 + +이 파일은 다음 복구 판단에 사용한다. + +## 5. 재실행 권장 명령 + +이번 실패 run과 같은 pred60 2025 full small 목적을 유지하되 validation batch를 안전하게 1로 둔 실행 예시는 다음과 같다. + +```powershell +python finetune/run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage both ` + --sample-stage full_window ` + --dataset-dir D:\Chanil_Park\Project\Programming\Kronos\finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --output-root D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs ` + --run-name stom_1s_grid_pred60_2025_full_small_retry_safe ` + --dataset-sample-mode full_sequential ` + --tokenizer-batch-size 4 ` + --tokenizer-val-batch-size 1 ` + --predictor-batch-size 16 ` + --predictor-num-workers 2 +``` + +주의: 현재 실패 run에는 usable tokenizer checkpoint가 없으므로 기존 run을 이어서 predictor로 넘어가는 것은 불가능하다. 새 안전 설정으로 tokenizer부터 다시 수행해야 한다. + +## 6. 운영 노하우 + +1. 장시간 full 학습에서는 validation 전에 checkpoint가 반드시 필요하다. +2. train batch가 가능하다고 validation batch도 가능한 것은 아니다. +3. progress가 99% 이상이어도 checkpoint가 없으면 실사용 모델은 없는 상태다. +4. predictor 최적화는 tokenizer checkpoint가 생성된 뒤에만 의미가 있다. +5. full validation은 속도보다 안정성을 우선해야 한다. 4080 SUPER 16GB에서는 tokenizer validation batch size 1이 가장 보수적인 선택이다. +6. 다시 실패하면 `validation_failure.json`, `tokenizer.stdout.log`, `tokenizer.progress.json`, checkpoint 폴더를 함께 확인한다. + +## 7. 이번 코드 개선 파일 + +- `finetune/config.py` + - tokenizer validation batch size 및 pre-validation checkpoint 옵션 추가 +- `finetune/train_tokenizer.py` + - validation 전 checkpoint 저장 + - validation batch size 분리 + - validation 전 CUDA 캐시/참조 정리 + - validation OOM artifact 기록 +- `finetune/tokenizer_safety.py` + - torch import 없이 검증 가능한 checkpoint/OOM 기록 helper 분리 +- `finetune/run_stom_1s_finetune.py` + - `--tokenizer-val-batch-size` CLI 추가 + - full mode tokenizer validation batch 기본값 1 적용 +- `tests/test_stom_1s_finetune_runner.py` + - runner env 생성 검증 +- `tests/test_train_tokenizer_safety.py` + - safety helper 단위 테스트 추가 diff --git a/docs/stom_daily_ohlcv_codex_handoff_2026-06-13.md b/docs/stom_daily_ohlcv_codex_handoff_2026-06-13.md new file mode 100644 index 000000000..0011382d6 --- /dev/null +++ b/docs/stom_daily_ohlcv_codex_handoff_2026-06-13.md @@ -0,0 +1,227 @@ +# Kronos 일봉 OHLCV/RL 대시보드 Codex 핸드오프 + +**작성일:** 2026-06-13 KST 관측 기준 +**대상 저장소:** `D:/Chanil_Park/Project/Programming/Kronos` +**주요 화면:** local Flask 실행 후 `/daily-ohlcv` +**목적:** Codex 또는 다른 작업자가 현재까지 구현된 일봉 OHLCV 연구/대시보드 작업을 재검토·재작업·확장할 수 있도록 현재 상태, 변경 파일, 검증 명령, 다음 작업을 정리한다. + +## 1. 핵심 결론 + +현재 구현은 **수익 보장 모델**이나 **실거래 가능한 강화학습 모델**이 아니다. +현재 결론은 명확히 다음과 같다. + +| 항목 | 현재 상태 | +|---|---| +| Daily OHLCV dashboard | 동작 중, D0~D9 연구 증거와 시각화 표시 | +| D0 DB/price basis | `UNKNOWN_CONFIRMED`, 수익률/라벨 신뢰도 blocker 유지 | +| D1 universe | `WATCH_HEURISTIC_UNIVERSE` / 공식·수동 검증 전 WATCH | +| D2 dataset | `PASS` evidence, 단 D0/D1 상위 blocker 영향 유지 | +| D3 예측/Top-K baseline | `WATCH` | +| D4 포트폴리오 RL | `RESEARCH_ONLY` | +| D5 walk-forward gate | `NO-GO` | +| D6/D7 visualization/research lab | evidence viewer / diagnostics 표시 | +| D8/D9 registry/paper-forward | `RESEARCH_ONLY_BLOCKED` | +| `model_build_allowed` | `false` | +| `go_summary_allowed` | `false` | +| `paper_forward_allowed` | `false` | +| `live_broker_order_allowed` | `false` | +| 실거래/브로커/주문 준비 | 아님 | + +현재 모델 빌드가 잠긴 이유: + +1. `D0_PRICE_BASIS_NOT_VERIFIED` / `PRICE_BASIS_UNKNOWN`: 일봉 DB의 가격 보정 기준(adjusted/raw, 분할, 배당)이 확정되지 않았다. +2. `D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED` / `UNIVERSE_WATCH_HEURISTIC`: KOSPI/KOSDAQ 보통주 분류는 적용됐지만 공식 KRX/수동 검증 전까지 WATCH다. +3. `D3_BASELINE_NOT_PROMOTABLE`: D3 baseline은 아직 승격 기준선으로 확정되지 않았다. +4. `D4_IMPLEMENTATION_NOT_UNLOCKED`: D4 RL은 연구용 evidence이며 구현/배포 unlock 근거가 아니다. +5. `D5_WALK_FORWARD_NOT_PASS`: D5는 `NO-GO`이며, no-trade/shuffle/D3 기준선·MDD·turnover·fold consistency gate를 통과하지 못했다. +6. 모든 UI/API/문서는 `NO-GO`/`WATCH`/`RESEARCH_ONLY` guardrail을 그대로 표시한다. + +## 2. 완료된 큰 작업 축 + +| 축 | 완료 내용 | 현재 판정 | +|---|---|---| +| RL 모델 팩토리/게이트 P1~P5 | fill-mode evidence, run registry, walk-forward/fresh validation lock, dashboard readiness panel 구축 | fresh validation 전까지 locked | +| Daily OHLCV D0 | `_database/Stock_Database_ohlcv_1day.db` 분석, table/row/price_basis/quality flag/split-like evidence 표시 | PASS, price_basis unknown | +| Daily OHLCV D1 | ETF/ETN/Q상품/스팩/우선주 등 제외 휴리스틱, KOSPI/KOSDAQ 보통주 universe preview, quarantine evidence | WATCH | +| Daily OHLCV D2 | bounded preview dataset, leakage/split chronology/normalization evidence | PASS | +| Daily OHLCV D3 | no-trade/shuffle/Top-K/supervised baseline, calibration/drawdown/turnover samples | WATCH | +| Daily OHLCV D4 | constrained portfolio RL, observation/state manifest, reward/action/NAV/drawdown/turnover visualization | RESEARCH_ONLY | +| Daily OHLCV D5 | state-aware walk-forward fold gate, shuffle/no-trade/cost controls, D4 manifest consumption | NO-GO | +| Daily OHLCV D6 | full dashboard/API/UI evidence surface | visualization only | +| Daily OHLCV D7 | feature/regime/correlation/failure-analysis diagnostics/fallback cards | research diagnostics only | +| Daily OHLCV D8/D9 | reproducible registry and blocked paper-forward ledger with hashes, drift, decision log, unsafe artifact blocking | RESEARCH_ONLY_BLOCKED | + +## 3. 최신 핵심 파일 + +### 3.1 백엔드/연구 모듈 + +| 파일 | 역할 | +|---|---| +| `stom_rl/daily_ohlcv_db.py` | D0 DB quality/price-basis/split-like evidence | +| `stom_rl/daily_ohlcv_universe.py` | D1 universe/exclusion/quarantine evidence | +| `stom_rl/daily_ohlcv_dataset.py` | D2 feature/label/split/leakage evidence | +| `stom_rl/daily_prediction.py`, `stom_rl/daily_ranker.py` | D3 baseline/ranker evidence | +| `stom_rl/daily_portfolio_env.py`, `stom_rl/daily_rl_train.py` | D4 constrained portfolio RL research artifacts | +| `stom_rl/daily_walk_forward.py` | D5 state-aware walk-forward gate | +| `stom_rl/daily_registry.py` | D8/D9 registry and blocked paper-forward ledger | +| `webui/daily_ohlcv_dashboard.py` | Daily OHLCV read-only adapter, D0~D9 payloads, effective gate | +| `webui/app.py` | Flask API routes | + +### 3.2 주요 API + +| API | 역할 | +|---|---| +| `GET /api/daily-ohlcv/progress` | D0~D9 progress/provenance/lock labels/exact verification commands | +| `GET /api/daily-ohlcv/db-summary` | D0 DB summary/price-basis blocker | +| `GET /api/daily-ohlcv/universe/preview` | D1 universe preview/quarantine | +| `GET /api/daily-ohlcv/dataset/latest` | D2 dataset evidence | +| `GET /api/daily-ohlcv/prediction/latest` | D3 baseline/ranker evidence | +| `GET /api/daily-ohlcv/portfolio/latest` | D4 RL state/reward/action/NAV evidence | +| `GET /api/daily-ohlcv/walk-forward/latest` | D5 gate/fold/cost/control evidence | +| `GET /api/daily-ohlcv/registry/latest` | D8/D9 registry/paper-forward ledger evidence | +| `GET /api/daily-ohlcv/charts/decision-cockpit` | model_build_allowed/go_summary_allowed lock decision cockpit | +| `GET /api/daily-ohlcv/charts/flow` | D0→D9 evidence flow | +| `GET /api/daily-ohlcv/charts/research-diagnostics` | D7 feature/regime/correlation/failure diagnostics | +| `GET /api/daily-ohlcv/charts/equity-overlay` | D3/D4/D5 equity comparison evidence | +| `GET /api/daily-ohlcv/charts/walk-forward-heatmap` | fold × metric heatmap + cost sensitivity | +| `GET /api/daily-ohlcv/charts/run-scatter` | return vs MDD scatter payload | +| `GET /api/daily-ohlcv/charts/universe-breakdown` | type/market/exclusion breakdown | +| `GET /api/daily-ohlcv/charts/symbol/` | leading-zero-preserving symbol chart payload | + +중요 제약: + +- 모두 read-only evidence API다. +- `run=..` 같은 unsafe run id는 400이어야 한다. +- POST/PUT/DELETE는 405여야 한다. +- API가 수익/실거래/브로커/주문 준비를 암시하면 안 된다. + +### 3.3 프론트엔드 + +| 파일 | 역할 | +|---|---| +| `webui/v2_src/src/lib/dailyOhlcvApi.ts` | Daily OHLCV API client/types | +| `webui/v2_src/src/tabs/DailyOhlcvTab.svelte` | Daily 탭 data orchestration | +| `webui/v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte` | D0~D9 progress/provenance/verification matrix | +| `webui/v2_src/src/tabs/dailyOhlcv/DailyVisualLabCard.svelte` | D6/D7/D8/D9 visual lab, registry/paper-forward card | +| `webui/static/v2/dist/*` | `npm run build` 결과물 | + +주요 UI marker: + +- `data-daily-ohlcv-progress` +- `data-daily-d0-d9-provenance-matrix` +- `data-daily-visual-lab-card` +- `data-daily-decision-cockpit` +- `data-daily-flow-map` +- `data-daily-metric-glossary` +- `data-daily-equity-overlay` +- `data-daily-walk-forward-heatmap` +- `data-daily-run-scatter` +- `data-daily-universe-breakdown` +- `data-daily-symbol-chart` +- `data-daily-research-diagnostics` +- `data-daily-registry-paper-forward` + +## 4. 주요 산출 문서 + +| 문서 | 용도 | +|---|---| +| `docs/stom_daily_ohlcv_db_analysis_and_page_plan_2026-06-11.md` | 일봉 DB 분석/페이지 계획 | +| `docs/stom_daily_ohlcv_deeprl_plan_2026-06-11.md` | 일봉 기반 딥러닝/RL 계획 | +| `docs/stom_daily_ohlcv_rl_master_restart_plan_2026-06-13.md` | 일봉 RL 재시작 마스터 문서 | +| `docs/stom_daily_ohlcv_d0_d3_provenance_hardening_result_2026-06-13.md` | D0~D3 provenance/evidence hardening | +| `docs/stom_daily_ohlcv_d4_observation_state_manifest_result_2026-06-13.md` | D4 observation/state manifest gate | +| `docs/stom_daily_ohlcv_d4_training_visualization_result_2026-06-13.md` | D4 training/evaluation visualization | +| `docs/stom_daily_ohlcv_d5_state_aware_walk_forward_result_2026-06-13.md` | D5 state-aware walk-forward gate | +| `docs/stom_daily_ohlcv_d8d9_registry_paper_forward_result_2026-06-13.md` | D8/D9 registry/paper-forward result | + +세션/생성 artifact 예: + +- `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_13_g003_state_visualization/` +- `webui/rl_runs/daily_ohlcv_walk_forward/walk_forward_2026_06_13_g004_state_aware_gate/` +- `webui/rl_runs/daily_ohlcv_visual_lab/visual_lab_2026_06_13_g005_d6_d7_progress/` +- `webui/rl_runs/daily_ohlcv_registry/registry_2026_06_13_g006_paper_forward/` + +## 5. 검증 명령과 관측 결과 + +G006 기준 최신 targeted 검증: + +```powershell +py -3.11 -m py_compile stom_rl/daily_registry.py webui/daily_ohlcv_dashboard.py webui/app.py tests/test_stom_rl_daily_registry.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +# passed + +py -3.11 -m pytest tests/test_stom_rl_daily_registry.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +# 26 passed +``` + +프론트/브라우저/API 검증: + +```powershell +cd webui/v2_src +npm run check +# 0 errors, 4 pre-existing warnings in ForecastWorkbenchTab.svelte and DocsTab.svelte + +npm run build +# 0 errors, same 4 pre-existing warnings; Vite build passed with bundle-size warning +``` + +대시보드 확인: + +- route: `http://127.0.0.1:58198/daily-ohlcv` +- title: `Kronos 대시보드` +- `data-daily-api-error` 없음 +- D0~D9 progress marker 존재 +- D7 diagnostics marker 존재 +- D8/D9 registry marker 존재 +- `NO-GO`, `WATCH`, `RESEARCH_ONLY`, `no live/broker/orders`, `model_build_allowed=false`, `no_live_broker_order_readiness`, D0/D1/D3/D4/D5 effective blocker 문구 표시 +- API: registry/progress/decision-cockpit 200, unsafe registry run id 400, registry POST 405 + +현재 서버 실행 예: + +```powershell +py -3.11 -c "from webui.app import app; print('READY http://127.0.0.1:58198/daily-ohlcv', flush=True); app.run(host='127.0.0.1', port=58198, debug=False, use_reloader=False)" +``` + +## 6. Codex가 재작업할 때 먼저 확인할 것 + +1. `docs/AGENTS.md`, `stom_rl/AGENTS.md`, `webui/AGENTS.md`, `webui/v2_src/AGENTS.md`, `tests/AGENTS.md`를 먼저 읽는다. +2. 기존 미커밋/생성 변경이 많으므로 임의 revert하지 않는다. +3. `webui/daily_ohlcv_dashboard.py`가 import 실패하면 Daily API가 500으로 떨어지므로 `py_compile`부터 확인한다. +4. Daily chart/registry API는 반드시 read-only/GET-only/unsafe path 방어를 유지한다. +5. 프론트 변경 후에는 `npm run check`와 `npm run build`를 모두 실행한다. +6. 대시보드 확인은 API 응답만 보지 말고 실제 `/daily-ohlcv`에서 marker와 API error 부재를 확인한다. + +## 7. 현재 남은 개발 과제 + +| 우선순위 | 작업 | 이유 | 완료 기준 | +|---:|---|---|---| +| 1 | 가격 보정 기준 확정 | `price_basis=unknown`이 가장 큰 연구 신뢰도 리스크 | adjusted/raw, split/dividend 정책 문서화 + DB quality PASS | +| 2 | 공식 유니버스 검증 | 현재 KOSPI/KOSDAQ 보통주 분류는 휴리스틱 WATCH | KRX/공식 메타데이터 기반 include/exclude 재검증 | +| 3 | D3 baseline freeze/강화 | RL은 강한 baseline을 넘어야 의미 있음 | no-trade/shuffle/Top-K/ranker baseline 고정 및 D0/D1 blocker 해소 후 재검증 | +| 4 | D4 RL reward/action 재설계 | 현재 RL 후보가 baseline보다 약함 | 비용 23bp 후 D3 baseline 초과 후보 생성, state manifest 유지 | +| 5 | D5 fresh OOS/forward 재검증 | 선택 후 재검증 없이는 과최적화 가능 | 사전등록 조건으로 fresh validation PASS | +| 6 | Research Lab 확장 | feature/regime 분석은 아직 연구 보조 수준 | feature heatmap, regime/correlation, failed candidate autopsy 추가 | +| 7 | Symbol chart 고도화 | 현재는 OHLCV preview 수준 | candlestick, return, volume, gap, missing/adjustment overlay 추가 | + +## 8. 작업 설명 요약 + +이번 업데이트는 Kronos의 일봉 연구 상태에 맞게 **실패/검증/잠금 중심 UI**와 registry evidence를 확장한 작업이다. + +구현한 핵심은 다음이다. + +- 사용자가 대시보드를 봤을 때 “왜 지금 수익모델 생성이 안 되는지”를 바로 알 수 있도록 effective D0/D1/D3/D4/D5 gate를 만들었다. +- D0 DB → D1 Universe → D2 Dataset → D3 Baseline → D4 RL → D5 Gate → D6 Visualization → D7 Diagnostics → D8 Registry → D9 Paper-forward 흐름을 시각화했다. +- D4는 observation/state contract, reward stack, learning/reward/return/drawdown, action distribution, invalid action, turnover, concentration, portfolio trajectory, frozen D3 comparison을 노출한다. +- D5는 D4 state-aware artifact를 소비하고, fold/purge/embargo/no-OOS-retuning/control/cost sensitivity를 표시한다. +- D8/D9는 config/data/code/source hash, lock reasons, drift, drawdown, decision log, blocked paper-selected rows, `no_live_broker_order_readiness`를 보존한다. +- 모든 API와 UI는 `no profit claim`, `no live/broker/orders`, `RESEARCH_ONLY`, `NO-GO` guardrail을 유지한다. + +## 9. 금지 사항 + +Codex가 이어서 작업할 때 다음을 하지 않는다. + +- D4 RL 또는 D5 fold 일부가 좋아 보인다는 이유로 수익모델 GO라고 쓰지 않는다. +- dashboard visual을 profitability proof로 취급하지 않는다. +- `ts_imb`/rule baseline을 RL이라고 부르지 않는다. +- `_database` 원본 DB를 dashboard/API에서 mutate하지 않는다. +- `model_build_allowed=false`를 UI에서 숨기거나 약하게 표현하지 않는다. +- 23bp 비용, no-trade/shuffle/baseline 비교 없이 우상향/수익 가능성을 주장하지 않는다. diff --git a/docs/stom_daily_ohlcv_d0_d3_provenance_hardening_result_2026-06-13.md b/docs/stom_daily_ohlcv_d0_d3_provenance_hardening_result_2026-06-13.md new file mode 100644 index 000000000..1dc0f56a9 --- /dev/null +++ b/docs/stom_daily_ohlcv_d0_d3_provenance_hardening_result_2026-06-13.md @@ -0,0 +1,74 @@ +# STOM Daily OHLCV D0-D3 Provenance Hardening Result (2026-06-13) + +## Verdict + +`RESEARCH_ONLY` / `MODEL_BUILD_LOCKED`. + +This update hardens the Daily OHLCV D0-D3 progress/provenance surface. It does not claim profitability, live/broker/order readiness, or model-build readiness. + +## Scope + +- Backend progress payload: `webui/daily_ohlcv_dashboard.py` +- Frontend progress card: `webui/v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte` +- API/types: `webui/v2_src/src/lib/dailyOhlcvApi.ts` +- Tests: `tests/test_daily_ohlcv_dashboard_api.py`, `tests/test_daily_ohlcv_dashboard_tab.py` + +## Current truth preserved + +| Stage | Status | Lock/evidence preserved | +|---|---|---| +| D0 DB/price | `PASS` surface with blocker | `price_basis=unknown`, `UNKNOWN_CONFIRMED`, `BLOCKED_UNTIL_PRICE_BASIS_VERIFIED` | +| D1 universe | `WATCH` | `WATCH_HEURISTIC_UNIVERSE`, official/manual metadata required | +| D2 dataset | `PASS` preview | date split, no future leakage, inherited D0/D1 guardrails | +| D3 baseline | `WATCH` | 23bp cost, no-trade/shuffle controls, frozen baseline deltas, `model_build_allowed=false` | + +## Change + +The `/api/daily-ohlcv/progress` payload now exposes: + +- per-stage `lock_labels` for D0-D3; +- per-stage `verification_commands` with exact targeted pytest, `py_compile`, frontend check/build, and browser/e2e expectations; +- `provenance_matrix` for D0-D3 audit display; +- richer D0-D3 evidence text including price-basis status, official metadata status, dataset inherited blockers, D3 shuffle/no-trade/frozen-baseline/cost evidence. + +The Daily OHLCV progress card now displays the D0-D3 provenance matrix, all lock labels, and all exact verification commands per stage. + +## Verification + +```powershell +py -3.11 -m py_compile webui/daily_ohlcv_dashboard.py webui/app.py +``` + +Result: passed. + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_ohlcv_db.py tests/test_stom_rl_daily_ohlcv_universe.py tests/test_stom_rl_daily_ohlcv_dataset.py tests/test_stom_rl_daily_prediction.py tests/test_stom_rl_daily_ranker.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +``` + +Result: `50 passed`. + +```powershell +cd webui/v2_src +npm run check +npm run build +``` + +Result: 0 errors, 4 pre-existing unrelated Svelte warnings in `ForecastWorkbenchTab.svelte` and `DocsTab.svelte`; Vite build succeeded. + +Browser/API check: + +- Route: `http://127.0.0.1:58211/daily-ohlcv` +- API: `GET /api/daily-ohlcv/progress` +- Required evidence present: `[data-daily-ohlcv-progress]`, `[data-daily-d0-d3-provenance-matrix]`, `UNKNOWN_CONFIRMED`, `WATCH_HEURISTIC_UNIVERSE`, `shuffle_control`, `MODEL_BUILD_ALLOWED_FALSE`, `no live/broker/orders`, exact pytest command text. +- Required failure absent: `[data-daily-api-error]`. +- Screenshot: `webui/rl_runs/daily_ohlcv_provenance_g001/provenance_dashboard_after_cleanup.png`. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- The default cost assumption remains 23bp round trip. +- Leading-zero stock codes remain strings. +- `ts_imb` remains an opening-gap RULE baseline, not RL. +- `model_build_allowed=false` remains in force until D0/D1/D3/D5 gates pass. diff --git a/docs/stom_daily_ohlcv_d0_price_basis_confirmation_result_2026-06-14.md b/docs/stom_daily_ohlcv_d0_price_basis_confirmation_result_2026-06-14.md new file mode 100644 index 000000000..6f4757b68 --- /dev/null +++ b/docs/stom_daily_ohlcv_d0_price_basis_confirmation_result_2026-06-14.md @@ -0,0 +1,102 @@ +# STOM Daily OHLCV D0 Price-basis Confirmation Result — 2026-06-14 + +## Verdict + +`UNKNOWN_CONFIRMED` / `WATCH_PRICE_BASIS_UNKNOWN_CONFIRMED`. + +This is a research-only D0 evidence update. It does not claim profitability, live/broker/order readiness, paper-forward permission, or model-build readiness. + +## Scope + +| Item | Value | +|---|---| +| Approved plan | `.gjc/plans/ralplan/2026-06-11-0158-38ea/pending-approval.md` | +| Source DB | `_database/Stock_Database_ohlcv_1day.db` | +| Generated artifact | `webui/rl_runs/daily_ohlcv_db_summary/daily_ohlcv_price_basis_2026_06_14_g001/` | +| Audit JSON | `webui/rl_runs/daily_ohlcv_db_summary/daily_ohlcv_price_basis_2026_06_14_g001/price_basis_audit.json` | +| Representative windows CSV | `webui/rl_runs/daily_ohlcv_db_summary/daily_ohlcv_price_basis_2026_06_14_g001/price_basis_windows.csv` | +| Guardrails | no live/broker/orders, no profit claims, 23bp default cost for later comparisons, no `_database` mutation, leading-zero codes preserved | + +## Finding + +The daily OHLCV DB has broad coverage, but it still does **not** declare whether prices are adjusted or raw. No split factor, dividend, total-return, or corporate-action table exists in the daily DB contract, so decision-grade return labels and model promotion remain blocked. + +| Check | Result | +|---|---:| +| Tables scanned | 4,727 | +| Rows | 14,691,020 | +| Date range | 19860415 ~ 20260612 | +| Quality scan scope | all_tables | +| `price_basis` | `unknown` | +| `price_basis_status` | `UNKNOWN_CONFIRMED` | +| Decision-grade return status | `BLOCKED_UNTIL_PRICE_BASIS_VERIFIED` | +| Split-like tables in scan | 281 | +| Split-like window samples | 326 | + +Component status: + +| Component | Status | +|---|---| +| adjusted price | `not_declared_in_daily_db_schema` | +| raw price | `not_declared_in_daily_db_schema` | +| split adjustment | `not_declared_no_split_factor_or_corporate_action_table` | +| dividend adjustment | `not_declared_no_dividend_or_total_return_field` | + +Representative split-like evidence includes `A000180` on `20260303` with open/previous-close ratio about `4.96`, `A000300` on `20241021` with ratio about `8.23`, and `A000670` on `20250113` with ratio about `0.10`. These windows support the unknown-adjustment blocker but are not corporate-action proof. + +## Required evidence to unlock D0 + +| Required evidence | Meaning | +|---|---| +| `official_or_vendor_field_declaring_adjusted_or_raw_close` | Independent source declares whether the OHLC columns are adjusted or raw. | +| `split_factor_or_corporate_action_reference_for_split_like_windows` | Split-like jumps can be explained or excluded by a dated corporate-action source. | +| `dividend_or_total_return_policy_if_returns_claim_dividend_adjustment` | Dividend/total-return handling is explicit if return labels claim that basis. | +| `dated_audit_artifact_showing_rows_windows_and_downstream_blocker_effect` | The artifact records scan scope, rows/windows, and downstream lock impact. | + +## User-facing usage guidance + +| Section | Can do | Must not do | Next action | +|---|---|---|---| +| D0 summary | Inspect table count, date coverage, OHLC quality flags, and representative split-like windows. | Treat D0 returns as decision-grade labels while `price_basis` is unknown. | Provide independent adjusted/raw and split/dividend policy evidence, or keep the blocker visible. | +| D2/D3 downstream | Build preview datasets and baselines with inherited price-basis warnings. | Freeze or promote baselines without a verified price-basis policy. | Rerun dataset and baseline verification after price-basis status changes. | +| D4-D9 promotion | Use D4-D9 charts as research diagnostics only. | Set `model_build_allowed` or `paper_forward_allowed` from unknown-basis evidence. | Keep `D0_PRICE_BASIS_NOT_VERIFIED` in effective gate blockers until D0 is verified. | + +## Decision + +D0 remains usable as a DB analysis surface, but the model/research decision label remains: + +```text +D0 = PASS but price_basis unknown / UNKNOWN_CONFIRMED +``` + +Blocking implications: + +1. Decision-grade return labels remain blocked until adjusted/raw basis is independently verified. +2. Split-like discontinuities must be flagged or excluded from model decision windows. +3. Dashboard/API must keep `model_build_allowed=false` until price basis and downstream gates pass. +4. D8/D9 must keep `paper_forward_allowed=false` until candidate-specific D5 gate passes. + +## Verification performed + +```powershell +py -3.11 -m py_compile stom_rl/daily_ohlcv_db.py webui/daily_ohlcv_dashboard.py webui/app.py tests/test_stom_rl_daily_ohlcv_db.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +py -3.11 -m pytest tests/test_stom_rl_daily_ohlcv_db.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +``` + +Result: `34 passed`. + +Additional verification: + +```powershell +cd webui/v2_src +npm run check +npm run build +``` + +Result: `0 errors`, 4 pre-existing Svelte warnings in `ForecastWorkbenchTab.svelte` and `DocsTab.svelte`; Vite build passed with bundle-size warning. + +Browser/API evidence: `/daily-ohlcv` showed `data-daily-price-basis-usage`, `UNKNOWN_CONFIRMED`, `model_build_allowed=false`/effective blockers, and no `data-daily-api-error`; `/api/daily-ohlcv/db-summary` returned 200 with D0 blocked uses and user guidance; `PATCH /api/daily-ohlcv/db-summary` returned 405; the walk-forward heatmap remained `model_build_allowed=false` with D0/D1/D3/D5 effective blockers. + +## Interpretation + +This completes the G001 D0 price-basis confirmation pass for the new approved plan by making the blocker more explicit in generated artifacts, API payloads, tests, and UI usage guidance. It does **not** unlock model build, paper-forward continuation, or live/broker/order use. diff --git a/docs/stom_daily_ohlcv_d1_universe_official_validation_result_2026-06-14.md b/docs/stom_daily_ohlcv_d1_universe_official_validation_result_2026-06-14.md new file mode 100644 index 000000000..a1bc8f991 --- /dev/null +++ b/docs/stom_daily_ohlcv_d1_universe_official_validation_result_2026-06-14.md @@ -0,0 +1,88 @@ +# STOM Daily OHLCV D1 Universe Official Validation Result (2026-06-14) + +## Status + +`WATCH_HEURISTIC_UNIVERSE` / `BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW`. + +This is research-only D1 evidence. It is not a live/broker/order surface, not a profit claim, and not model-build readiness. + +## Source and generated artifacts + +| Item | Value | +|---|---| +| Daily DB | `_database/Stock_Database_ohlcv_1day.db` | +| Local metadata | `_database/stock_tick_back.db:stockinfo` | +| Expected official/manual CSV | `_database/krx_listed_products.csv` | +| Generated artifact | `webui/rl_runs/daily_ohlcv_universe/universe_official_watch_2026_06_14_g002/` | +| Manifest | `universe.json` | +| Symbol/exclusion CSVs | `symbols.csv`, `exclusions.csv` | +| Quarantine evidence | `quarantine.csv` | +| Official metadata audit | `official_metadata_audit.json` | + +## Finding + +No official/manual KRX listed-product CSV was present at `_database/krx_listed_products.csv`, so the current universe remains a WATCH preview based on stockinfo plus conservative name/prefix rules. + +The ingestion contract is explicit: + +```text +code,name,market,instrument_type[,source] +``` + +Codes must remain six-character strings. Short numeric codes such as `250` are rejected instead of being silently accepted because they weaken leading-zero provenance. + +## Current counts + +| Metric | Result | +|---|---:| +| Verdict | `WATCH_HEURISTIC_UNIVERSE` | +| Certification | `BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW` | +| Official metadata status | `MISSING` | +| Official metadata coverage | `MISSING` | +| Tables | 4,727 | +| Included symbols | 2,599 | +| Excluded symbols | 2,128 | +| stockinfo matched tables | 4,229 | +| stockinfo unmatched tables | 498 | +| official metadata unmatched tables | 4,727 | +| quarantine rows | 575 | + +## Implemented hardening + +| Area | Result | +|---|---| +| Explicit D1 evidence contract | Added required evidence, allowed uses, blocked uses, and Korean dashboard guidance. | +| Fail-closed certification | Missing/partial official metadata keeps `universe_certification_status=BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW`. | +| Complete official metadata path | Only complete official/manual coverage can emit `OFFICIAL_OR_MANUAL_REVIEWED` / `OFFICIAL_VERIFIED`. | +| Gate compatibility | Effective model gate requires exact D1 verified verdict, official status, and certification status. | +| Dashboard/API | D1 card/API expose coverage status, certification status, required evidence, allowed uses, blocked uses, and user guidance. | +| Generated artifacts | New D1 manifest/audit/quarantine artifacts written under `webui/rl_runs/daily_ohlcv_universe/`. | + +## User interpretation + +| User question | Current answer | +|---|---| +| Can this universe be used for research preview? | Yes, for evidence review, exclusion reason review, quarantine backlog triage, and dashboard navigation. | +| Can it promote model builds or candidates? | No. D1 remains blocked until official/manual coverage is complete. | +| Can it support paper/live readiness claims? | No. Paper/live/broker/order readiness remains explicitly blocked. | +| Does this prove KOSPI/KOSDAQ common-equity coverage? | No. It shows the current heuristic preview and the exact evidence needed to clear D1. | + +## Verification performed + +```powershell +py -3.11 -m py_compile stom_rl/daily_ohlcv_universe.py webui/daily_ohlcv_dashboard.py webui/app.py tests/test_stom_rl_daily_ohlcv_universe.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +# passed + +py -3.11 -m pytest tests/test_stom_rl_daily_ohlcv_universe.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +# 42 passed +``` + +Additional frontend/browser verification is recorded in the Ultragoal G002 quality gate artifacts. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- Leading-zero stock codes remain strings in universe, API, and dashboard evidence. +- `model_build_allowed=false` remains required until D0/D1/D3/D5 gates pass. diff --git a/docs/stom_daily_ohlcv_d2_dataset_refresh_result_2026-06-14.md b/docs/stom_daily_ohlcv_d2_dataset_refresh_result_2026-06-14.md new file mode 100644 index 000000000..12c7e0f19 --- /dev/null +++ b/docs/stom_daily_ohlcv_d2_dataset_refresh_result_2026-06-14.md @@ -0,0 +1,78 @@ +# STOM Daily OHLCV D2 Dataset Refresh Result (2026-06-14) + +## Status + +`PASS` for D2 leakage/split artifact checks, but `DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS` for model readiness. + +This is research-only dataset evidence. It is not training, not RL execution, not live/broker/order readiness, and not a profit claim. + +## Source and generated artifacts + +| Item | Value | +|---|---| +| Daily DB | `_database/Stock_Database_ohlcv_1day.db` | +| D1 universe manifest | `webui/rl_runs/daily_ohlcv_universe/universe_official_watch_2026_06_14_g002/universe.json` | +| Generated D2 artifact | `webui/rl_runs/daily_ohlcv_dataset/dataset_2026_06_14_g003_d2_refresh/` | +| Manifest | `dataset_manifest.json` | +| Panels | `feature_panel.csv`, `label_panel.csv`, `rl_candidate_panel.csv` | +| Split/audit files | `split_assignments.csv`, `normalization_stats.json`, `leakage_report.json`, `blocked_windows.csv` | + +## Current counts + +| Metric | Result | +|---|---:| +| Feature rows | 80,000 | +| Label rows | 80,000 | +| RL candidate rows | 80,000 | +| Eligible rows | 78,880 | +| Blocked windows | 14 | +| Leakage status | `PASS` | +| Split chronology status | `PASS` | +| Price basis | `unknown` / `UNKNOWN_CONFIRMED` | +| D1 universe | `WATCH_HEURISTIC_UNIVERSE` | +| D1 certification | `BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW` | + +## Implemented hardening + +| Area | Result | +|---|---| +| D0/D1 propagation | Dataset manifest/API/UI now carry `upstream_gate_blockers`. | +| Model readiness | D2 no longer reads as promotable while D0 price basis and D1 universe are blocked. | +| Evidence contract | Added required evidence, allowed uses, blocked uses, and user guidance. | +| Stale artifact guardrail | D2 latest/chart/artifact-list surfaces now recompute/union D0/D1 blockers and force blocked readiness instead of trusting stale optimistic manifests. | +| Dashboard | D2 card displays upstream blockers and blocked uses. | +| Generated artifact | New G003 D2 dataset uses the G002 universe manifest SHA/path. | + +## Current upstream blockers + +```text +D0_PRICE_BASIS_NOT_VERIFIED +D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED +``` + +These are expected guardrails. D2 can be inspected as a research dataset preview, but it cannot justify model build, candidate promotion, paper-forward/live readiness, or decision-grade return labels until D0 and D1 clear. + +## Verification performed + +```powershell +py -3.11 -m py_compile stom_rl/daily_ohlcv_dataset.py webui/daily_ohlcv_dashboard.py webui/app.py tests/test_stom_rl_daily_ohlcv_dataset.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +# passed + +py -3.11 -m pytest tests/test_stom_rl_daily_ohlcv_dataset.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +# 32 passed + +cd webui/v2_src +npm run check +# 0 errors, 4 pre-existing warnings +npm run build +# built, 0 errors, 4 pre-existing warnings, existing bundle-size warning +``` + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- Default cost assumption remains 23bp round trip. +- Leading-zero stock codes remain strings in dataset/API/dashboard evidence. +- `model_build_allowed=false` remains required until D0/D1/D3/D5 gates pass. diff --git a/docs/stom_daily_ohlcv_d3_baseline_hardening_result_2026-06-13.md b/docs/stom_daily_ohlcv_d3_baseline_hardening_result_2026-06-13.md new file mode 100644 index 000000000..026daf7f6 --- /dev/null +++ b/docs/stom_daily_ohlcv_d3_baseline_hardening_result_2026-06-13.md @@ -0,0 +1,84 @@ +# STOM Daily OHLCV D3 Baseline Hardening Result (2026-06-13) + +## Verdict + +`WATCH` / research-only. D3 baselines were hardened with an explicit deterministic shuffle control and baseline-delta evidence, but this does **not** unlock RL model building or any live/broker/order workflow. + +Blocking context remains: + +- D0 price basis: `unknown` / `UNKNOWN_CONFIRMED`; decision-grade return labels remain blocked until adjusted/raw/split/dividend basis is independently verified. +- D1 universe: `WATCH_HEURISTIC_UNIVERSE`; official KRX/manual universe evidence is still missing. +- D5 walk-forward gate: existing latest gate remains `NO-GO`. +- `model_build_allowed=false` and `go_summary_allowed=false` remain the correct interpretation. + +## Artifact + +- Run: `prediction_2026_06_13_d3_baseline_hardened` +- Directory: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_13_d3_baseline_hardened/` +- Dataset input: `webui/rl_runs/daily_ohlcv_dataset/dataset_2026_06_12_d2_preview/` +- Evaluation split: `val` + `test` +- Fit policy: supervised ranker/classifier fitted on `train` only (`fit_train_split_only_no_oos_retuning`) +- Cost: 23bp round trip +- Top-K: 20 +- Manifest SHA-256: `ec5368fd1883ab3d8a19bf373513cc7026c75148d8cf8075994c533a54ece75f` +- Baseline delta summary SHA-256: `3c0cc843c4ed69be0308ec468796bff657390b86a9c9e34bdd345d439fff4487` +- Baseline metrics SHA-256: `d142cdc89ea17f4d6f799e15228707079dbdba1bf663590e6a73bd33631d0009` + +Generated evidence files include: + +- `prediction_manifest.json` +- `baseline_metrics.json` +- `baseline_delta_summary.json` +- `model_metrics.json` +- `topk_positions.csv` +- `calibration.csv` +- `turnover.csv` +- `drawdown.csv` +- `predictions.csv` +- `verdict.json` + +## Baseline comparison after 23bp cost + +| strategy | family | total net | hit rate | max DD | mean turnover | delta vs shuffle | +|---|---:|---:|---:|---:|---:|---:| +| `no_trade_cash` | control | 0.00% | 0.00% | 0.00% | 0.0000 | +22.86% | +| `shuffle_control` | control | -22.86% | 46.28% | -27.70% | 0.8010 | 0.00% | +| `equal_weight_topk_momentum` | rule baseline | +31.37% | 52.10% | -17.45% | 0.3367 | +54.23% | +| `vol_adjusted_momentum` | rule baseline | +26.63% | 55.02% | -16.51% | 0.3730 | +49.49% | +| `mean_reversion` | rule baseline | -0.89% | 51.78% | -25.98% | 0.3702 | +21.97% | +| `market_proxy` | rule baseline | -32.11% | 43.04% | -36.35% | 1.0000 | -9.25% | +| `supervised_linear_ranker` | supervised baseline | +4.67% | 48.22% | -28.01% | 0.6508 | +27.53% | +| `supervised_direction_classifier` | supervised baseline | +4.92% | 50.16% | -23.33% | 0.4489 | +27.78% | + +Summary: + +- Best overall/rule baseline: `equal_weight_topk_momentum`. +- Best supervised baseline: `supervised_direction_classifier`. +- Best supervised vs best rule baseline: -26.45 percentage points. +- Best supervised vs shuffle control: +27.78 percentage points. +- Best rule baseline vs shuffle control: +54.23 percentage points. + +## Interpretation + +D3 now has the required no-trade, shuffle/control, Top-K momentum, volatility-adjusted momentum, transparent supervised ranker/classifier, cost-aware hit-rate/MDD/turnover/calibration, and baseline-delta evidence. The supervised baselines beat shuffle but do not beat the strongest rule baseline, so RL remains unjustified as a replacement claim. + +This is evidence for further research triage only. It is not live-trading readiness, not broker readiness, not a profit guarantee, and not a model-build approval. + +## Verification + +Commands run: + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_prediction.py -q +py -3.11 -m pytest tests/test_stom_rl_daily_prediction.py tests/test_stom_rl_daily_ranker.py tests/test_stom_rl_daily_ohlcv_dataset.py tests/test_stom_rl_daily_ohlcv_universe.py tests/test_stom_rl_daily_ohlcv_db.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +py -3.11 -m py_compile stom_rl/daily_prediction.py stom_rl/daily_ranker.py webui/daily_ohlcv_dashboard.py webui/app.py +npm run check && npm run build # from webui/v2_src +``` + +Observed results: + +- `tests/test_stom_rl_daily_prediction.py`: 2 passed. +- Daily D0-D3/dashboard targeted set: 46 passed. +- Python compile: passed. +- Svelte check/build: 0 errors, 4 pre-existing warnings in unrelated files (`ForecastWorkbenchTab.svelte`, `DocsTab.svelte`). +- Browser/API smoke: `/daily-ohlcv` D3 card rendered `WATCH`, `shuffle_control`, `supervised vs shuffle`, `model_build_allowed=false`, and no API error. Screenshot: `.omx/artifacts/daily_ohlcv_d3_baseline_hardened_2026_06_13.png`. diff --git a/docs/stom_daily_ohlcv_d3_baseline_hardening_result_2026-06-14.md b/docs/stom_daily_ohlcv_d3_baseline_hardening_result_2026-06-14.md new file mode 100644 index 000000000..ca2bc8811 --- /dev/null +++ b/docs/stom_daily_ohlcv_d3_baseline_hardening_result_2026-06-14.md @@ -0,0 +1,87 @@ +# STOM Daily OHLCV D3 Baseline Hardening Result (2026-06-14) + +## Verdict + +`WATCH` / `D3_WATCH_RESEARCH_ONLY`. D3 was regenerated from the G003 D2 dataset refresh and hardened with fail-closed dashboard/API handling for stale optimistic baseline artifacts. This does **not** unlock RL model building, paper-forward readiness, live/broker/order workflows, or profit claims. + +Blocking context remains: + +- D0 price basis: `unknown` / `UNKNOWN_CONFIRMED`. +- D1 universe: `WATCH_HEURISTIC_UNIVERSE`; official/manual evidence is still missing. +- D5 walk-forward gate: not pass / still blocks promotion. +- `model_build_allowed=false` and `go_summary_allowed=false` remain required. + +## Artifact + +- Run: `prediction_2026_06_14_g004_d3_baseline_hardened` +- Directory: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_14_g004_d3_baseline_hardened/` +- Dataset input: `webui/rl_runs/daily_ohlcv_dataset/dataset_2026_06_14_g003_d2_refresh/` +- Dataset manifest SHA: `0b3ebec9ef8929ef1e26c8c2399a62fcb092fbd026f8e126e0c5f10f4e37ea74` +- Evaluation split: `val` + `test` +- Fit policy: supervised ranker/classifier fitted on `train` only (`fit_train_split_only_no_oos_retuning`) +- Deterministic shuffle: `sha256(date:code)_ascending` +- Cost: 23bp round trip +- Top-K: 20 +- Baseline metrics SHA-256: `d142cdc89ea17f4d6f799e15228707079dbdba1bf663590e6a73bd33631d0009` +- Baseline delta summary SHA-256: `d7da6c79f09cbf0a0c7c989461766ba37c7e6963b4a8a1af312c1c03fd1770eb` +- Predictions SHA-256: `78ad01d796ae75bccbe87753c7843f256cf2af28cf0ce8b24249c1989daa344a` +- Prediction manifest SHA-256: `b1d4b26d8561444dd826c66bb1fdc092200f52d0dd1d05a0ab6f24b4c0439936` + +Generated evidence files include `prediction_manifest.json`, `baseline_metrics.json`, `baseline_delta_summary.json`, `model_metrics.json`, `topk_positions.csv`, `calibration.csv`, `turnover.csv`, `drawdown.csv`, `predictions.csv`, and `verdict.json`. + +## Baseline comparison after 23bp cost + +| strategy | family | total net | hit rate | max DD | mean turnover | delta vs shuffle | +|---|---:|---:|---:|---:|---:|---:| +| `no_trade_cash` | control | +0.00% | 0.00% | 0.00% | 0.0000 | +22.86% | +| `shuffle_control` | control | -22.86% | 46.28% | -27.70% | 0.8010 | +0.00% | +| `equal_weight_topk_momentum` | rule_baseline | +31.37% | 52.10% | -17.45% | 0.3367 | +54.23% | +| `vol_adjusted_momentum` | rule_baseline | +26.63% | 55.02% | -16.51% | 0.3730 | +49.49% | +| `mean_reversion` | rule_baseline | -0.89% | 51.78% | -25.98% | 0.3702 | +21.97% | +| `market_proxy` | rule_baseline | -32.11% | 43.04% | -36.35% | 1.0000 | -9.25% | +| `supervised_linear_ranker` | supervised | +4.67% | 48.22% | -28.01% | 0.6508 | +27.53% | +| `supervised_direction_classifier` | supervised | +4.92% | 50.16% | -23.33% | 0.4489 | +27.78% | + +Summary: + +- Best overall/rule baseline: `equal_weight_topk_momentum`. +- Best supervised baseline: `supervised_direction_classifier`. +- Best supervised vs best rule baseline: -26.45 percentage points. +- Best supervised vs shuffle control: +27.78 percentage points. + +## D3 blockers and guidance + +```text +D0_PRICE_BASIS_NOT_VERIFIED +D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED +D5_WALK_FORWARD_NOT_PASS +D3_BASELINE_WATCH_RESEARCH_ONLY +``` + +D3 can be used as frozen research baseline evidence for D4 design, but it cannot justify candidate promotion, model build, GO summary, paper-forward/live readiness, or decision-grade return claims. + +## Verification + +```powershell +py -3.11 -m py_compile stom_rl/daily_prediction.py stom_rl/daily_ranker.py webui/daily_ohlcv_dashboard.py webui/app.py tests/test_stom_rl_daily_prediction.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +# passed + +py -3.11 -m pytest tests/test_stom_rl_daily_prediction.py tests/test_stom_rl_daily_ranker.py tests/test_stom_rl_daily_ohlcv_dataset.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +# 38 passed + +cd webui/v2_src +npm run check +npm run build +# 0 errors, 4 pre-existing warnings, existing bundle-size warning +``` + +Browser/API smoke: `/daily-ohlcv` D3 card rendered `WATCH`, D3 blockers, deterministic shuffle freeze, `model_build_allowed=false`, `go_summary_allowed=false`, nested stale optimistic statuses normalized to `WATCH`, and no API error. Screenshot: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_14_g004_d3_baseline_hardened/g004_d3_dashboard_final_current.png`. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, order-routing, or paper-forward readiness is implied. +- No profit claim is made. +- Default cost assumption remains 23bp round trip. +- Leading-zero stock codes remain strings in dataset/prediction/dashboard evidence. +- `model_build_allowed=false` remains required until D0/D1/D3/D5 gates pass. diff --git a/docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_prereg_2026-06-18.md b/docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_prereg_2026-06-18.md new file mode 100644 index 000000000..e95590fec --- /dev/null +++ b/docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_prereg_2026-06-18.md @@ -0,0 +1,110 @@ +# Daily OHLCV D3/D4 Signal-Quality Audit Preregistration — 2026-06-18 + +Date: 2026-06-18 UTC +Status: `PREREGISTERED_RESEARCH_ONLY` +Experiment type: `supervised gate` / `RL experiment` diagnostic audit +Parent result: `docs/stom_daily_ohlcv_d4_trade_quality_filter_result_2026-06-17.md` +Governance index: `docs/stom_daily_ohlcv_research_governance_index_2026-06-17.md` +Default cost: 23bp round trip; scenario/gate outputs must retain 0/23/46bp sensitivity before any promotion discussion. + +## Objective + +The 2026-06-17 D4 trade-quality filters produced useful abstention telemetry but every scenario still underperformed `no_trade_cash` / best D3 after 23bp costs. The next research step is therefore **not** another threshold-tuning pass. It is a preregistered D3/D4 signal-quality audit that asks whether the D3 scores, score margins, confidence buckets, and past-only risk/regime proxies contain enough causal information to justify any future D4 overlay work. + +This document freezes the next diagnostic contract before any new scenario execution. + +## Non-negotiable guardrails + +| Guardrail | Required state | +|---|---| +| Live/broker/order use | Forbidden. | +| Profit claim | Forbidden. All returns are research diagnostics only. | +| Model build / paper-forward | Forbidden unless a later fresh D5 gate passes under an approved workflow. | +| Default cost | 23bp round trip in all primary comparisons. | +| Cost sensitivity | Keep 0/23/46bp sensitivity in scenario or gate manifests; do not promote a lane without 46bp stress evidence. | +| D5 status | `NO-GO` until a fresh gate passes. | +| Future labels | `future_return_1d`, realized fold outcomes, and post-action rewards may not enter score buckets, margin buckets, confidence buckets, or risk/regime proxy construction. They are evaluation labels only. | +| Baselines | no-trade cash, shuffle/control, equal-weight top-k, and frozen D3 baseline remain mandatory. | +| Generated artifacts | Generated evidence belongs under `webui/rl_runs/` or `artifacts/`; durable decisions belong under `docs/`. | +| Leading-zero codes | Stock codes must stay strings. | + +## Frozen research questions + +| ID | Question | Required evidence | Failure interpretation | +|---|---|---|---| +| `Q1_SCORE_MONOTONICITY` | Do higher D3 score-magnitude buckets have better next-day research returns or hit rates? | bucketed score table across train/val/test and folds | If not monotonic or only one split works, D3 score scale is not reliable enough for D4 filters. | +| `Q2_MARGIN_QUALITY` | Does a larger top-1 minus top-2 score margin identify cleaner opportunities? | margin bucket return/hit-rate/turnover table | If margin buckets do not separate outcomes, margin-based abstention is weak. | +| `Q3_CONFIDENCE_QUALITY` | Does the confidence bucket used by D4 trade-quality filters map to better realized outcomes? | confidence bucket table and calibration diagnostics | If confidence bucket does not improve outcome separation, confidence abstention should remain diagnostic only. | +| `Q4_RISK_PROXY` | Can past-only daily OHLCV or generated-artifact proxies identify regimes where D3/D4 fails? | volatility/drawdown/breadth/score-dispersion/turnover-pressure regime table | If proxies are stale, future-dependent, or not separative, do not add them to D4 state. | +| `Q5_OVERLAY_READINESS` | Is there enough causal signal to justify a future D4 overlay action schema? | baseline-relative summary with D3/no-trade/shuffle controls | If D3/no-trade controls still dominate, keep D4/model-build/paper-forward `NO-GO`. | + +## Causal bucket contract + +| Bucket / proxy | Source timing | Frozen construction | Forbidden shortcut | +|---|---|---|---| +| `score_magnitude_bucket` | current date candidate panel before action | frozen absolute top-score thresholds `(0.001, 0.005, 0.02)` with missing/zero in bucket 0; no quantile fitting in this preregistered lane | no fold-retuning; no future return lookup; no ad hoc threshold search | +| `score_sign_bucket` | current date candidate panel before action | negative / zero / positive top D3 score sign | no filtering based on later realized direction | +| `score_margin_bucket` | current date candidate panel before action | top-1 minus top-2 D3 score bucketed with frozen absolute thresholds `(0.001, 0.005, 0.02)` matching action-induction v2 state | no train/val/test retuning; no use of top-1 future return or fold outcome | +| `d3_confidence_bucket` | current date candidate panel before action | absolute top D3 score bucketed with frozen thresholds `(0.001, 0.005, 0.02)`; if the top-score source is missing, emit `MISSING_D3_CONFIDENCE_SOURCE` and fail the scenario closed | no realized label, D5 verdict, or fallback threshold injection | +| `candidate_count_bucket` | current date candidate panel before action | number of current date candidates after score sorting and candidate-limit truncation | no post-hoc removal of losing symbols | +| `score_dispersion_bucket` | current date candidate panel before action | cross-sectional dispersion among current D3 scores | no future-return dispersion | +| `recent_score_volatility_bucket` | `t-1` lookback before action | rolling volatility of prior top D3 scores | no current/future label leakage | +| `past_return_volatility_bucket` | `t-1` lookback before action if daily OHLCV artifacts are available | rolling past-only volatility of returns/prices; if unavailable, mark `MISSING_PAST_OHLCV_PROXY` | no using next-day returns | +| `drawdown_bucket` | `t-1` equity/state only | rolling drawdown from past research equity or D3 baseline path | no using current test-fold final equity to classify earlier dates | +| `breadth_proxy_bucket` | current/past candidate universe before action | share of candidate scores positive or breadth from available past-only OHLCV universe fields | no future label aggregation | +| `turnover_pressure_bucket` | current/past positions/candidate changes before action | expected turnover pressure from prior holdings and current candidate replacement rate | no post-action realized turnover to decide action | + +Frozen threshold policy: this audit does **not** permit train-set threshold search, train-only quantile selection, or post-hoc bucket count changes. A later quantile/calibration experiment requires a new preregistration. This lane uses the absolute thresholds above so D3 calibration is evaluated, not tuned. + +## Frozen diagnostic tables + +| Artifact | Required fields | Purpose | +|---|---|---| +| `signal_quality_bucket_metrics.csv` | split, fold, bucket_name, bucket_value, count, mean_future_return_1d, median_future_return_1d, hit_rate, mean_score, mean_margin, cost_bp | score/margin/confidence outcome separation | +| `signal_quality_rank_correlations.csv` | split, fold, score_field, spearman_rank_corr, pearson_corr, n | rank/linear relation between D3 scores and labels | +| `risk_proxy_bucket_metrics.csv` | split, fold, proxy_name, bucket_value, count, policy_delta_vs_d3, future_return_mean, mdd_proxy, turnover_proxy, cost_bp | past-only regime separability | +| `signal_quality_leakage_audit.json` | feature_name, timing, source_artifact, future_label_used, verdict | prove no decision-time future-label leakage | +| `signal_quality_manifest.json` | run_id, source_hashes, input_artifacts, split policy, thresholds, costs, baselines, guardrails | reproducibility and provenance | +| `scenario_batch_manifest.json` | scenario IDs, statuses, cost_sensitivity_bp, artifact paths, blockers | batch governance | +| `abstention_reasons.csv` | split, date, action/filter/proxy reason, entry_abstained_by_filter, future_label_exposed | required for any follow-up D4 filter/overlay scenario; if a pure diagnostic scenario has no abstention action, emit an explicit `not_applicable` manifest reason | +| `*_result_YYYY-MM-DD.md` | commands, result tables, limitations, next action, data governance | durable decision record | + +## Frozen scenario matrix + +| Scenario ID | Diagnostic focus | Required controls | Promotion rule | +|---|---|---|---| +| `score_magnitude_audit_v1` | score magnitude/sign bucket separability | no-trade, shuffle, equal-weight top-k, frozen D3 | diagnostic only; no promotion | +| `score_margin_audit_v1` | top-1/top-2 margin quality | same controls | diagnostic only; no promotion | +| `confidence_bucket_audit_v1` | D3/D4 confidence bucket calibration | same controls | diagnostic only; no promotion | +| `risk_proxy_audit_v1` | past-only volatility/drawdown/breadth/dispersion/turnover proxy usefulness | same controls | diagnostic only; no promotion | +| `combined_signal_proxy_audit_v1` | whether score + margin + confidence + past-only proxy jointly explain failures | same controls plus fold consistency | diagnostic only; no promotion | + +## Acceptance criteria + +A completed result may be accepted as **research evidence only** if all are true: + +1. Every bucket/proxy row identifies its source timing (`t/current/pre_action` or `t-1/lookback/pre_action`). +2. `future_return_1d` is used only as an evaluation label after bucket/proxy construction. +3. Score, margin, confidence, and risk-proxy buckets use the frozen preregistered thresholds/policies above; no train-set threshold search, quantile search, bucket-count change, or OOS retuning is allowed. +4. Tables cover train/val/test and available folds, or explicitly mark missing folds as blockers. +5. no-trade, shuffle, equal-weight top-k, and frozen D3 comparisons remain visible. +6. 23bp default cost is primary; 0/23/46bp sensitivity remains in scenario/gate manifests. +7. Leading-zero stock codes remain string values in all artifacts. +8. `model_build_allowed=false`, `go_summary_allowed=false`, `paper_forward_allowed=false`, and `live_broker_order_allowed=false` remain enforced. +9. A dated result document and governance-index update are written under `docs/`. +10. Any future D4 filter/overlay candidate that uses this audit must keep or newly emit `abstention_reasons.csv`; pure diagnostics may mark it not-applicable only with an explicit manifest reason. + +## Planned executable next step + +Implement a bounded signal-quality audit lane: + +1. Add or reuse a diagnostic runner that reads existing D3/D4 generated artifacts and emits the frozen signal-quality tables above. +2. Add leakage tests proving no bucket/proxy uses `future_return_1d` before evaluation and that no train-set threshold/quantile search changes the frozen bucket policy. +3. Run a small preregistered scenario/audit batch with the five scenario IDs above. +4. Publish `docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_result_YYYY-MM-DD.md` and update the governance index. + +## Current promotion status + +`NO-GO_RESEARCH_ONLY`. + +This preregistration approves diagnostics only. It does not approve model build, paper-forward, live trading, broker integration, order placement, or profit/readiness claims. diff --git a/docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_result_2026-06-18.md b/docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_result_2026-06-18.md new file mode 100644 index 000000000..f18c6492e --- /dev/null +++ b/docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_result_2026-06-18.md @@ -0,0 +1,141 @@ +# Daily OHLCV D3/D4 Signal-Quality Audit Result — 2026-06-18 + +Date: 2026-06-18 UTC +Status: `WATCH_DIAGNOSTIC_ONLY` / promotion `NO-GO_RESEARCH_ONLY` +Experiment type: `supervised gate` / `RL experiment` diagnostic audit +Parent preregistration: `docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_prereg_2026-06-18.md` +Parent result: `docs/stom_daily_ohlcv_d4_trade_quality_filter_result_2026-06-17.md` +Default cost: 23bp round trip; generated metrics carry 0/23/46bp sensitivity rows. + +## Verdict + +The D3/D4 signal-quality audit is implemented and reproducible as a research-only diagnostic lane. It fixes the prior D4 trade-quality follow-up problem by moving upstream: score magnitude, score margin, confidence, and past-only/generated-artifact risk proxies are now visible with fold/split metadata, source timing, baseline controls, and no-future-label provenance. + +The result does **not** unlock D5/model-build/paper-forward/live trading. The evidence is mixed and remains diagnostic: some folds show positive score calibration, but other folds are weak or negative, and `no_trade_cash` remains an active comparator. The correct promotion status is still `NO-GO_RESEARCH_ONLY`. + +## Exact commands and observed output + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_signal_quality.py -q +``` + +Observed: + +```text +... [100%] +3 passed in 0.44s +``` + +```powershell +py -3.11 -m stom_rl.daily_signal_quality --run-id signal_quality_audit_2026_06_18_001 +``` + +Observed: + +```json +{"run_id": "signal_quality_audit_2026_06_18_001", "status": "COMPLETED_RESEARCH_ONLY", "promotion_status": "NO-GO_RESEARCH_ONLY", "row_counts": {"predictions": 872, "bucket_metrics": 204, "rank_correlations": 7, "risk_proxy_metrics": 219, "baseline_control_metrics": 84, "leakage_audit": 7}, "output_dir": "webui\\rl_runs\\daily_ohlcv_signal_quality\\signal_quality_audit_2026_06_18_001"} +``` + +```powershell +py -3.11 -m stom_rl.daily_signal_quality_batch --plan artifacts/scenario_batch_signal_quality_audit_001_plan.json --batch-id scenario_batch_signal_quality_audit_001 --overwrite +``` + +Observed: + +```json +{"batch_id": "scenario_batch_signal_quality_audit_001", "status": "COMPLETED_RESEARCH_ONLY", "scenario_count": 5, "completed_count": 5, "failed_count": 0, "gate_status_counts": {"WATCH": 5}} +``` + +## Durable artifacts + +| Artifact | Path | +|---|---| +| Preregistration | `docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_prereg_2026-06-18.md` | +| Scenario plan | `artifacts/scenario_batch_signal_quality_audit_001_plan.json` | +| Audit manifest | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/signal_quality_manifest.json` | +| Score/margin/confidence buckets | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/signal_quality_bucket_metrics.csv` | +| Rank correlations | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/signal_quality_rank_correlations.csv` | +| Past-only risk proxy metrics | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/risk_proxy_bucket_metrics.csv` | +| Baseline controls | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/baseline_control_metrics.csv` | +| Leakage audit | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/signal_quality_leakage_audit.json` | +| Batch manifest | `webui/rl_runs/daily_ohlcv_signal_quality_batches/scenario_batch_signal_quality_audit_001/scenario_batch_manifest.json` | + +## Scenario batch summary + +| Scenario | Focus | Status | Promotion | +|---|---|---|---| +| `score_magnitude_audit_v1` | score magnitude/sign bucket separability | `WATCH` | `NO-GO_RESEARCH_ONLY` | +| `score_margin_audit_v1` | top-1/top-2 margin quality | `WATCH` | `NO-GO_RESEARCH_ONLY` | +| `confidence_bucket_audit_v1` | D3/D4 confidence calibration | `WATCH` | `NO-GO_RESEARCH_ONLY` | +| `risk_proxy_audit_v1` | past-only/generated-artifact risk proxies | `WATCH` | `NO-GO_RESEARCH_ONLY` | +| `combined_signal_proxy_audit_v1` | joint signal/proxy diagnostics | `WATCH` | `NO-GO_RESEARCH_ONLY` | + +Batch status: `completed_count=5`, `failed_count=0`, `gate_status_counts={"WATCH": 5}`. `WATCH` means the diagnostic artifacts are usable for analysis, not that a model or trading lane is approved. + +## Main diagnostic observations + +### Rank correlation by split/fold + +| Split | Fold | Spearman | Pearson | n | Interpretation | +|---|---:|---:|---:|---:|---| +| train | FULL | 0.0108 | 0.1052 | 576 | weak relation; not enough for promotion | +| val | F01 | 0.1562 | -0.0382 | 64 | weak/mixed | +| val | F02 | 0.1295 | 0.0485 | 64 | weak positive | +| val | F03 | 0.0974 | 0.0655 | 24 | weak positive | +| test | F03 | -0.1167 | -0.0972 | 40 | negative OOS fold | +| test | F04 | 0.0310 | -0.0059 | 64 | near zero | +| test | F05 | 0.4233 | 0.3012 | 40 | favorable OOS fold, not consistent enough alone | + +### 23bp baseline-control aggregate across val+test + +These are research diagnostics from frozen candidate-panel selections, not deployable strategy results. + +| Baseline control | Days | Mean net return/day @23bp | Mean turnover proxy | Status | +|---|---:|---:|---:|---| +| `no_trade_cash` | 37 | 0.0000 | 0.0000 | active comparator | +| `shuffle_control` | 37 | -0.00084 | 0.9459 | weak negative after cost | +| `equal_weight_topk` | 37 | -0.00278 | 0.4797 | negative after cost | +| `frozen_d3_baseline` | 37 | -0.00560 | 0.6486 | negative after cost | + +## What changed technically + +| Area | Change | Governance effect | +|---|---|---| +| Causal buckets | `score_magnitude_bucket`, `score_sign_bucket`, `score_margin_bucket`, and `d3_confidence_bucket` use frozen absolute thresholds `(0.001, 0.005, 0.02)` | No threshold search, no quantile fitting, no OOS retune | +| Risk proxies | `score_dispersion`, `recent_score_volatility`, `breadth`, `turnover_pressure`, and lagged generated `drawdown.csv` path proxies are emitted | Previous future-label-derived proxy blocker removed | +| Row provenance | Bucket/proxy CSV rows include source timing, source artifact, and future-label flags | Acceptance criterion 1 is directly auditable | +| Cost sensitivity | Bucket, risk-proxy, and baseline metrics emit 0/23/46bp rows | 23bp primary and 46bp stress remain visible | +| Baselines | `baseline_control_metrics.csv` measures no-trade, shuffle, equal-weight top-k, and frozen D3 controls | Baseline controls are no longer manifest-only labels | +| Batch governance | Five preregistered scenarios run through a manifest-backed batch | Scenario automation remains reproducible | + +## Data governance record + +| Governance area | Evidence | +|---|---| +| Source provenance | `signal_quality_manifest.json` records hashes for `predictions.csv`, `fold_assignments.csv`, and lagged `drawdown.csv`. | +| Artifact provenance | Run and batch manifests record all generated artifact paths under `webui/rl_runs/`. | +| Cost accounting | Primary cost is 23bp; metric artifacts carry 0/23/46bp rows. | +| Split integrity | Manifest records splits `train`, `val`, `test` and folds `F01`-`F05`; no threshold retune was performed. | +| Label leakage | `signal_quality_leakage_audit.json` and CSV flags mark future labels as evaluation-only. | +| Baseline controls | `baseline_control_metrics.csv` includes no-trade, shuffle, equal-weight top-k, and frozen D3 controls. | +| Generated vs durable separation | Generated evidence is under `webui/rl_runs/`; decisions are in this dated `docs/` report. | +| Leading-zero codes | Loader preserves stock codes with `zfill(6)`; focused tests use string codes such as `000001`. | +| Failure visibility | Promotion remains `NO-GO_RESEARCH_ONLY`; no D5/model-build/paper-forward/live readiness is implied. | + +## Limitations + +1. The signal relation is not fold-consistent: test F05 is favorable, but test F03 is negative and F04 is near zero. +2. Baseline controls are diagnostic selections from the existing candidate panel, not a full independent portfolio optimizer. +3. Lagged `drawdown.csv` proxies come from generated research artifacts; they are past-only in this audit, but they are not a substitute for a fully validated adjusted OHLCV market-regime dataset. +4. The current lane does not produce `abstention_reasons.csv` because it is pure diagnostics; the manifest explicitly marks that requirement as not applicable for this audit. +5. No D5 promotion gate is opened. + +## Current promotion status + +`NO-GO_RESEARCH_ONLY`. + +This run does not approve model build, paper-forward, live trading, broker integration, order placement, or any profit/readiness claim. + +## Next allowed research action + +Recommended next lane: a preregistered **past-only market-regime data quality audit** before any new D4 overlay. It should validate adjusted/raw price basis, universe breadth, volatility/drawdown proxies, and whether those proxies are stable enough to use in D4 state. Do not tune D4 thresholds against this result without a new preregistration. diff --git a/docs/stom_daily_ohlcv_d4_action_induction_v2_prereg_2026-06-16.md b/docs/stom_daily_ohlcv_d4_action_induction_v2_prereg_2026-06-16.md new file mode 100644 index 000000000..9c79ab53b --- /dev/null +++ b/docs/stom_daily_ohlcv_d4_action_induction_v2_prereg_2026-06-16.md @@ -0,0 +1,116 @@ +# Daily OHLCV D4 Action-Induction v2 Preregistration — 2026-06-16 + +Date: 2026-06-16 KST +Status: `PREREGISTERED_RESEARCH_ONLY` +Experiment type: `RL experiment` +Parent result: `docs/stom_daily_ohlcv_d4_no_trade_diagnostic_result_2026-06-16.md` +Default cost: 23bp round trip + +## Objective + +Continue the Daily OHLCV D4 RL research by testing whether a policy can learn non-trivial actions without using future labels in state/action selection. + +The immediate failure to address is: + +> Current D4 tabular-Q policies collapse to all-hold/no-trade even when candidate breadth, max positions, and training length change. + +This preregistration freezes the next research lane before further code or scenario tuning. + +## Non-negotiable guardrails + +| Guardrail | Required state | +|---|---| +| Live/broker/order use | Forbidden. | +| Profit claim | Forbidden. | +| Paper-forward/model-build promotion | Forbidden until D0/D1/D3/D4/D5 gates pass in a later approved workflow. | +| Default cost | 23bp round trip, with explicit 0bp/46bp sensitivity where applicable. | +| D5 status | `NO-GO` unless a fresh gate run passes. | +| Future labels | May appear in labels/reward after the decision and in post-policy diagnostics only; never in current state/action selection. | +| Leading-zero stock codes | Preserve as strings. | +| Generated artifacts | Write under `webui/rl_runs/`; durable conclusions under `docs/`. | + +## Hypothesis + +A D4 policy may stop collapsing to no-trade if the state contains richer **decision-time causal context** and the action-selection experiment explicitly measures whether buy/add/sell/reduce become reachable without hindsight leakage. + +This is a failure-analysis hypothesis, not an alpha or trading-readiness claim. + +## Frozen research variants + +| Variant | Change | Rationale | Expected failure mode to test | +|---|---|---|---| +| `state_margin_bucket_v1` | Add score-margin bucket between top-1 and top-2 candidates. | The policy may need confidence separation, not only top-score sign. | Still no-trade if score sign is too weak. | +| `candidate_count_bucket_v1` | Add candidate-count bucket. | Sparse/low-candidate days may need different action preference. | Over-generalization across unlike days. | +| `recent_volatility_bucket_v1` | Add past-only realized-volatility bucket from available historical bars/features. | High-volatility regimes may explain avoided buys. | Drawdown/concentration failure. | +| `d3_confidence_bucket_v1` | Add D3 prediction-confidence bucket using current D3 score distribution only. | D4 needs D3 confidence context to decide whether to trust a candidate. | D3-underperformance persists. | +| `action_prior_exploration_v1` | Add explicit exploration/action-prior diagnostics without changing OOS thresholds. | Current Q-table tie behavior favors hold. Need to measure action reachability. | Forced actions raise turnover/cost without D3 outperformance. | + +## State/action/reward/environment contract + +| RL element | v2 requirement | Forbidden shortcut | +|---|---|---| +| State | Use only information available at decision time: position count, top score bucket, score margin bucket, candidate count bucket, past-only volatility/regime bucket, D3 confidence bucket. | Do not expose `future_return_1d`, future NAV, or fold outcome in state. | +| Action | Keep constrained actions: hold, buy, add, sell, reduce. Record valid mask and invalid reason per row. | Do not hide invalid actions or silently coerce action results without telemetry. | +| Reward | Keep actual net return after cost separate from shaping/diagnostic terms. | Do not report shaping reward as realized return. | +| Environment | Daily OHLCV research environment only, no broker/order simulation. | No live fills, market orders, or broker integration. | +| Policy | Current lane may remain tabular-Q or introduce an explicitly labelled experimental policy; policy type must be emitted in manifests. | Do not call a rule baseline or forced action script an RL policy. | +| Diagnostics | Keep no-trade opportunity diagnostics post-policy only. | Do not use hindsight diagnostics to choose actions inside the same evaluation. | + +## Required artifacts for each v2 run + +| Artifact | Required? | Purpose | +|---|---:|---| +| `rl_manifest.json` | yes | Top-level D4 lineage, guardrails, hashes, status. | +| `observation_manifest.json` | yes | State contract and leakage checks. | +| `state_observations.csv` | yes | Decision-time state rows, no future labels. | +| `action_distribution.csv` | yes | Whether actions moved beyond all-hold. | +| `invalid_actions.csv` | yes | Valid-mask and invalid-action telemetry. | +| `reward_breakdown.csv` | yes | Actual reward/accounting terms. | +| `policy_baseline_comparison.csv` | yes | Frozen D3/no-trade/shuffle comparisons under 23bp. | +| `policy_nav.csv` | yes | Research-only NAV diagnostics. | +| `no_trade_opportunity_diagnostics.csv` | yes | Post-policy missed/avoided opportunity analysis. | +| `no_trade_opportunity_summary.json` | yes | Aggregated no-trade diagnostic summary. | +| `scenario_batch_manifest.json` | yes for batch | Scenario-level comparison and blocker summary. | +| `*_result_YYYY-MM-DD.md` | yes | Durable human-readable research report in `docs/`. | + +## Data governance requirements + +| Governance area | Requirement | +|---|---| +| Lineage | Every result document must link parent preregistration, scenario plan, batch manifest, per-run manifests, and verification commands. | +| Versioning | Material changes to state/action/reward/schema require a new dated preregistration document. Do not overwrite a prior result to soften `NO-GO`. | +| Reproducibility | Record CLI command, run id, seed, split, folds, purge/embargo, top-k/candidate limits, max positions, episodes, and cost assumptions. | +| Schema control | Add schema/field lists to manifests and tests when introducing new artifacts. | +| Hash/provenance | Keep source/artifact hashes in generated manifests; report drift as blocker, not cosmetic noise. | +| Access/safety | Dashboard/API surfaces remain read-only; no broker/live/order side effects. | +| Retention | Generated files stay under `webui/rl_runs/`; durable decision history stays under `docs/`. | +| Discoverability | Update or create dated docs using prefixes `stom_daily_ohlcv_d4_*_prereg_*`, `*_result_*`, or `*_handoff_*`. | +| Auditability | Each report must include verdict, blockers, exact command, verification result, and next allowed research action. | +| Data mutation | Do not mutate `_database` or prior generated evidence to improve results. New evidence requires a new run id. | + +## Acceptance criteria for the next implementation + +A v2 run can be considered a successful **research diagnostic** only if all are true: + +1. State artifacts prove future labels are not used in action selection. +2. Action distribution shows whether non-hold actions are reachable and why. +3. Net return after cost remains separate from shaping/diagnostic rewards. +4. Policy is compared against no-trade, deterministic shuffle, and frozen D3 baselines. +5. D5 remains `NO-GO` unless a separate fresh OOS gate passes. +6. A dated result document is created under `docs/` after the run. +7. All model/paper/live/broker flags remain false. + +## Planned executable next step + +Implement **D4 action-induction v2** as a bounded research lane: + +1. Add decision-time state buckets to the D4 observation contract. +2. Emit schema and tests proving no future label leakage. +3. Run a small scenario batch with v2 state/action diagnostics. +4. Publish a dated result document and update the governance index. + +## Current promotion status + +`NO-GO_RESEARCH_ONLY`. + +This preregistration approves research diagnostics only. It does not approve model build, paper-forward, live trading, broker integration, or profit claims. diff --git a/docs/stom_daily_ohlcv_d4_action_induction_v2_result_2026-06-17.md b/docs/stom_daily_ohlcv_d4_action_induction_v2_result_2026-06-17.md new file mode 100644 index 000000000..5e0e5b436 --- /dev/null +++ b/docs/stom_daily_ohlcv_d4_action_induction_v2_result_2026-06-17.md @@ -0,0 +1,95 @@ +# Daily OHLCV D4 Action-Induction v2 Result — 2026-06-17 + +Date: 2026-06-17 UTC +Status: `NO-GO_RESEARCH_ONLY` +Experiment type: `RL experiment` +Parent preregistration: `docs/stom_daily_ohlcv_d4_action_induction_v2_prereg_2026-06-16.md` +Parent diagnostic: `docs/stom_daily_ohlcv_d4_no_trade_diagnostic_result_2026-06-16.md` +Default cost: 23bp round trip + +## Verdict + +D4 action-induction v2 is useful as a **failure-analysis diagnostic** because it made non-hold actions reachable and produced richer state/action telemetry. It is not a model-build, paper-forward, live, broker, order, or profit claim. + +All three scenarios remain `NO-GO` under the D5 research gate. The best D3/no-trade baseline for this bounded batch is `no_trade_cash` at 0.00% total net return, while every v2 RL policy lost money on `val+test` after 23bp costs. + +## What changed in this research lane + +| Element | v2 implementation | Governance note | +|---|---|---| +| State | `position_count`, `top_score_bucket`, `score_margin_bucket`, `candidate_count_bucket`, `recent_score_volatility_bucket`, `d3_confidence_bucket` | Current decision-time score/position features only. `recent_score_volatility_bucket` is a past-score volatility proxy because raw OHLCV volatility is not present in prediction artifacts. | +| Action | Existing constrained actions: hold, buy, add, sell, reduce | Valid masks and invalid-action reasons remain emitted. | +| Policy | `tabular_q` and diagnostic `tabular_q_action_prior_v2` | Entry prior is policy-selection telemetry only; it does not alter realized reward or gates. | +| Reward | Existing net-return-after-cost accounting with penalties | Reward/accounting stayed separate from post-policy diagnostics. | +| Diagnostics | No-trade opportunity summary, state key, policy values, action priors, action scores | Future labels are used only after policy decision for reward/diagnostic analysis. | + +## Exact commands + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_scenario_runner.py -q +py -3.11 -m stom_rl.daily_scenario_batch --plan artifacts/scenario_batch_d4_action_induction_v2_001_plan.json --batch-id scenario_batch_d4_action_induction_v2_001 --overwrite +``` + +Verification result: + +```text +20 passed in 1.41s +scenario_count=3, completed_count=3, failed_count=0, gate_status_counts={"NO-GO": 3} +``` + +## Durable artifacts + +| Artifact | Path | +|---|---| +| Scenario plan | `artifacts/scenario_batch_d4_action_induction_v2_001_plan.json` | +| Batch manifest | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_d4_action_induction_v2_001/scenario_batch_manifest.json` | +| Batch research summary | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_d4_action_induction_v2_001/action_induction_v2_research_summary.json` | +| State-only portfolio manifest | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_action_induction_v2_001__v2_state_only_top10_pos3/rl_manifest.json` | +| Entry-prior portfolio manifest | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_action_induction_v2_001__v2_entry_prior_top10_pos3/rl_manifest.json` | +| Single-slot entry-prior portfolio manifest | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_action_induction_v2_001__v2_entry_prior_single_slot_top5/rl_manifest.json` | + +## Scenario results (`val+test`) + +| Scenario | State/action setting | D5 status | RL total net return | NAV | MDD | Turnover | Best D3 baseline | Delta vs best D3 | Action reachability | +|---|---|---|---:|---:|---:|---:|---|---:|---| +| `v2_state_only_top10_pos3` | v2 state, no prior | `NO-GO` | -11.29% | 0.8871 | -15.09% | 10.81% | `no_trade_cash` 0.00% | -11.29% | buy=4, add=2, sell=4, reduce=2, flat no-trade=20 | +| `v2_entry_prior_top10_pos3` | v2 state, `entry_bias_v1=0.0005` | `NO-GO` | -12.53% | 0.8747 | -14.88% | 24.32% | `no_trade_cash` 0.00% | -12.53% | buy=7, add=7, sell=6, flat no-trade=5 | +| `v2_entry_prior_single_slot_top5` | v2 state, one slot, `entry_bias_v1=0.0005` | `NO-GO` | -11.77% | 0.8823 | -17.77% | 27.03% | `no_trade_cash` 0.00% | -11.77% | buy=5, sell=5, flat no-trade=12 | + +## No-trade diagnostic movement + +| Scenario | `val+test` no-trade rate | Missed positive no-trade count | Risk avoided no-trade count | Interpretation | +|---|---:|---:|---:|---| +| `v2_state_only_top10_pos3` | 54.05% | 10 | 10 | v2 state alone reduced the previous all-hold collapse but still traded into losses. | +| `v2_entry_prior_top10_pos3` | 13.51% | 2 | 3 | Entry prior strongly induced actions, but turnover/exposure rose and returns worsened. | +| `v2_entry_prior_single_slot_top5` | 32.43% | 7 | 5 | One-slot constraint reduced action complexity but did not control drawdown/cost enough. | + +## Interpretation + +1. The original all-hold/no-trade collapse is no longer the only blocker: v2 can make actions reachable. +2. Once actions are reachable, the next blocker becomes **trade quality**: the policy increases exposure/turnover without beating the no-trade/D3 baseline. +3. The action prior is not sufficient as a research direction by itself. It proves reachability, not alpha. +4. D5 remains `NO-GO`. No paper-forward/model-build/live/broker/order path is opened. + +## Data governance record + +| Governance area | Evidence | +|---|---| +| Lineage | Parent preregistration and no-trade diagnostic are linked above. Batch manifest and per-run manifests are listed in durable artifacts. | +| Reproducibility | Scenario plan stores defaults, overrides, seeds, folds, purge/embargo, cost assumptions, candidate limits, max positions, and episodes. | +| Schema control | New state fields are declared in `observation_manifest.json` and tested in `tests/test_stom_rl_daily_portfolio_env.py` / `tests/test_stom_rl_daily_rl_gate.py`. | +| Leakage control | Tests prove current reward-label changes do not change v2 state; manifests state future labels are excluded from current observation/action mask. | +| Retention | Generated evidence stays under `webui/rl_runs/`; this dated decision record stays under `docs/`. | +| Safety | `model_build_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false` remain enforced. | + +## Next allowed research action + +Do **not** tune the action prior upward to chase a favorable curve. The next recommended research is a new preregistered **trade-quality filter / uncertainty abstention** lane: + +- keep v2 causal state fields, +- remove or freeze the entry prior as diagnostic-only, +- add explicit D3 confidence/margin abstention tests, +- evaluate whether trades can be limited to days with stronger current-score separation, +- compare against no-trade, shuffle, equal-weight top-k, and frozen D3 baselines under 23bp/46bp sensitivity. + +Promotion status remains `NO-GO_RESEARCH_ONLY` until a fresh D5 gate passes. diff --git a/docs/stom_daily_ohlcv_d4_no_trade_diagnostic_result_2026-06-16.md b/docs/stom_daily_ohlcv_d4_no_trade_diagnostic_result_2026-06-16.md new file mode 100644 index 000000000..f20a041fe --- /dev/null +++ b/docs/stom_daily_ohlcv_d4_no_trade_diagnostic_result_2026-06-16.md @@ -0,0 +1,91 @@ +# Daily OHLCV D4 No-Trade Opportunity Diagnostic Result — 2026-06-16 + +Date: 2026-06-16 KST +Status: `NO-GO_RESEARCH_ONLY` +Experiment type: `RL experiment` / post-policy diagnostic +Default cost: 23bp round trip +Primary artifact: `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_no_trade_diag_001/no_trade_diagnostic_research_summary.json` + +## Guardrail snapshot + +| Surface | State | Meaning | +|---|---|---| +| Live/broker/orders | `blocked` | No live trading, broker connection, order routing, or paper-forward promotion. | +| Profit/model claim | `blocked` | This result is diagnostic evidence only, not a deployable model or profit claim. | +| D4 policy | `RESEARCH_ONLY` | Tabular-Q D4 remains a failure-analysis lane. | +| D5 gate | `NO-GO` | Model build and paper-forward remain blocked. | +| Future labels | post-policy diagnostic only | `future_return_1d` is not exposed in training state/action selection; it is used after the policy decision to explain no-trade behavior. | + +## Research question + +The prior D4 reward/action/environment stress matrix showed that all tested tabular-Q policies collapsed to all-hold/no-trade. This run asks: + +> Did the policy skip many positive top-candidate opportunities, or did it mostly avoid negative candidates? + +This question is diagnostic only. It does not change the trading policy, does not retune on OOS, and does not unlock model build. + +## What changed + +| Area | Change | +|---|---| +| D4 artifact contract | Added `no_trade_opportunity_diagnostics.csv` and `no_trade_opportunity_summary.json` to every portfolio run. | +| Training leakage control | Kept `future_label_used_for_training_state=false`; future labels are only emitted in a post-policy diagnostic artifact. | +| Manifest lineage | Added row counts and artifact hashes for the new diagnostic outputs. | +| Test coverage | Added focused assertions for diagnostic-only future-label usage and generated artifacts. | + +## Executed command + +```powershell +py -3.11 -m stom_rl.daily_scenario_batch --plan artifacts/scenario_batch_d4_action_reward_001_plan.json --batch-id scenario_batch_no_trade_diag_001 --overwrite +``` + +## Verification command + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_scenario_runner.py tests/test_stom_rl_daily_portfolio_env.py -q +``` + +Observed result: + +```text +18 passed in 1.02s +``` + +## Scenario result table + +| Scenario | Gate status | Val+Test action distribution | No-trade rows | Missed positive top-candidate rows | Avoided negative top-candidate rows | Diagnostic artifact | +|---|---|---:|---:|---:|---:|---| +| `control_top10_pos3_ep3` | `NO-GO` | hold 37 | 37 | 17 | 20 | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_no_trade_diag_001__control_top10_pos3_ep3/no_trade_opportunity_summary.json` | +| `single_slot_top5_pos1` | `NO-GO` | hold 37 | 37 | 16 | 21 | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_no_trade_diag_001__single_slot_top5_pos1/no_trade_opportunity_summary.json` | +| `wider_candidates_top20_pos5` | `NO-GO` | hold 37 | 37 | 17 | 20 | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_no_trade_diag_001__wider_candidates_top20_pos5/no_trade_opportunity_summary.json` | +| `longer_training_top10_ep12` | `NO-GO` | hold 37 | 37 | 17 | 20 | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_no_trade_diag_001__longer_training_top10_ep12/no_trade_opportunity_summary.json` | + +## Interpretation + +The D4 tabular-Q policy still selected only `hold` in val+test across all four stress scenarios. The new diagnostic shows a mixed no-trade profile: + +- There were missed positive top-candidate days. +- There were also avoided negative top-candidate days. +- Therefore, a simple hindsight penalty or forced-buy rule would be unsafe and could create leakage or overfitting. + +The next research must improve decision-time state/action learning, not claim success from hindsight labels. + +## Data governance record + +| Governance item | Current record | +|---|---| +| Durable report | This file under `docs/` is the human-readable dated result. | +| Generated summary | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_no_trade_diag_001/no_trade_diagnostic_research_summary.json` | +| Batch manifest | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_no_trade_diag_001/scenario_batch_manifest.json` | +| Source code | `stom_rl/daily_rl_train.py` | +| Tests | `tests/test_stom_rl_daily_rl_gate.py`, `tests/test_stom_rl_daily_scenario_runner.py`, `tests/test_stom_rl_daily_portfolio_env.py` | +| Cost assumption | 23bp round trip, recorded in artifacts. | +| Mutability | Generated artifacts are evidence; do not edit them to change verdicts. Create a new dated run/report instead. | +| Provenance | Portfolio manifests include source hashes and generated artifact hashes. | +| Safety flags | `model_build_allowed=false`, `go_summary_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false`. | + +## Final verdict + +`NO-GO_RESEARCH_ONLY`. + +The result explains the current all-hold failure mode better, but it does not make D4 usable. The next recommended research is **D4 action-induction v2** with causal, decision-time state features and explicit exploration/action-prior tests. diff --git a/docs/stom_daily_ohlcv_d4_observation_state_manifest_result_2026-06-13.md b/docs/stom_daily_ohlcv_d4_observation_state_manifest_result_2026-06-13.md new file mode 100644 index 000000000..7c3194569 --- /dev/null +++ b/docs/stom_daily_ohlcv_d4_observation_state_manifest_result_2026-06-13.md @@ -0,0 +1,70 @@ +# STOM Daily OHLCV D4 Observation/State Manifest Gate Result (2026-06-13) + +## Verdict + +`PASS_RESEARCH_ONLY_STATE_CONTRACT` for the D4 environment/state manifest gate. + +This is **not** a usable trading model, not live/broker/order readiness, and not a profit claim. It only confirms that D4 has an explicit observation/state contract separate from reward/action telemetry. + +## Artifact + +- Run: `env_inspection_2026_06_13_g002_state_manifest` +- Directory: `webui/rl_runs/daily_ohlcv_portfolio_env/env_inspection_2026_06_13_g002_state_manifest/` +- Source D3 candidate panel: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_13_d3_baseline_hardened/` +- Cost: 23bp round trip +- Fill assumption: `close_to_next_close_research_label; no live/broker/order fill is inferred from daily OHLCV` +- Status: research-only; `model_build_allowed=false` + +Generated files: + +- `env_manifest.json` +- `observation_manifest.json` +- `state_observations.csv` +- `reward_breakdown.csv` +- `action_masks.csv` +- `positions.csv` + +## Manifest coverage + +| Required area | Current contract | +|---|---| +| Feature timing | D3 score columns are current-date candidate features before action; `future_return_1d` availability must not filter the pre-action candidate/state/mask universe and is reward-only after action. | +| Holdings identity | Codes remain six-digit strings via `zfill(6)` and are used for masks, positions, and accounting. | +| Cash/exposure | `cash_fraction` and `exposure_fraction` derive from current `position_count / max_positions`; `state_observations.csv` records both before action. | +| Candidate rank/score | Candidates are sorted by current score descending within date before candidate limit truncation; `top_score_bucket` is the compact state feature. | +| Horizon alignment | Daily rebalance step with one-day future return reward label; close-to-next-close research label only. | +| Action mask semantics | `hold/buy/add/sell/reduce` validity is documented and tested. | +| Leakage checks | Future labels are excluded from state and action mask; missing future labels remain eligible candidates and are zero-filled only during post-action reward accounting. | +| Frozen D3 comparison | Required baselines are declared: no-trade, shuffle, equal-weight momentum, volatility-adjusted momentum, supervised ranker, supervised classifier. | + +## Guardrail that remains locked + +Reward/action telemetry alone is explicitly insufficient for D4 promotion: + +```text +reward_action_telemetry_sufficient_for_d4=false +``` + +D4 remains `RESEARCH_ONLY`; D5 OOS/walk-forward/shuffle/no-trade gates and D0/D1 blockers still prevent model build. + +## Verification + +Commands run: + +```powershell +py -3.11 -m py_compile stom_rl/daily_portfolio_env.py stom_rl/daily_rl_train.py tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_rl_gate.py +py -3.11 -m pytest tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_rl_gate.py -q +``` + +Observed result: + +- Python compile: passed. +- Focused D4 env/RL tests: `9 passed`. + +## Research-only constraints + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing path was added. +- No profit, deployable-model, or broker readiness claim is made. +- Leading-zero codes remain string-preserved. +- `model_build_allowed=false` remains in force until D0/D1/D3/D5 gates pass. diff --git a/docs/stom_daily_ohlcv_d4_rl_visualization_result_2026-06-14.md b/docs/stom_daily_ohlcv_d4_rl_visualization_result_2026-06-14.md new file mode 100644 index 000000000..86d69beb8 --- /dev/null +++ b/docs/stom_daily_ohlcv_d4_rl_visualization_result_2026-06-14.md @@ -0,0 +1,104 @@ +# STOM Daily OHLCV D4 RL Environment/Reward/Visualization Result (2026-06-14) + +## Verdict + +`RESEARCH_ONLY` / `D4_RESEARCH_ONLY_DIAGNOSTICS`. D4 now consumes the hardened G004 D3 frozen baseline artifact and emits a constrained daily portfolio RL diagnostic run with learning/reward/NAV/drawdown/turnover/action/state visual evidence. It does **not** unlock model building, paper-forward readiness, live/broker/order workflows, or profit claims. + +Blocking context remains: + +- D0 price basis: `unknown` / `UNKNOWN_CONFIRMED`. +- D1 universe: `WATCH_HEURISTIC_UNIVERSE`; official/manual evidence is still missing. +- D3 baseline: `WATCH` / `D3_WATCH_RESEARCH_ONLY`. +- D5 walk-forward gate: not pass / still blocks promotion. +- `model_build_allowed=false`, `go_summary_allowed=false`, `paper_forward_allowed=false`, and `live_broker_order_allowed=false` remain required. + +## Artifact + +- Run: `portfolio_2026_06_14_g005_d4_visualization` +- Directory: `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_14_g005_d4_visualization/` +- Prediction input: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_14_g004_d3_baseline_hardened/` +- Prediction manifest SHA-256: `b1d4b26d8561444dd826c66bb1fdc092200f52d0dd1d05a0ab6f24b4c0439936` +- RL manifest SHA-256: `5f103d446be2a84833e381f721ce7b388c090dd6e88889d166fd0b65ab659ff6` +- Upstream predictions SHA-256: `78ad01d796ae75bccbe87753c7843f256cf2af28cf0ce8b24249c1989daa344a` +- Upstream baseline metrics SHA-256: `d142cdc89ea17f4d6f799e15228707079dbdba1bf663590e6a73bd33631d0009` +- Upstream verdict SHA-256: `634e12e4fe40a36ef151eef4f5bb65a50d6df0c380ad85fe89e8cbac9d62205c` +- Upstream hash mismatches: `[]` +- Cost: 23bp round trip +- Fill/reward assumption: close-to-next-close research label only; no broker/order fill is inferred. +- Policy: `tabular_q_constrained_daily_portfolio_rl` +- Episodes: 12 +- Seed: 7 +- Max positions: 5 +- Candidate limit: 20 + +Generated evidence files include `rl_manifest.json`, `training_manifest.json`, `observation_manifest.json`, `policy_metrics.json`, `episode_metrics.csv`, `learning_curve.csv`, `reward_breakdown.csv`, `reward_component_summary.json`, `action_distribution.csv`, `invalid_actions.csv`, `turnover.csv`, `drawdown.csv`, `policy_baseline_comparison.csv`, `policy_nav.csv`, `policy_evaluation_manifest.json`, `baseline_comparison.json`, and `verdict.json`. + +## D4 diagnostic outcome after 23bp cost + +| Metric | Value | +|---|---:| +| D4 status | `RESEARCH_ONLY` | +| Readiness | `D4_RESEARCH_ONLY_DIAGNOSTICS` | +| Gate dependency | `D3_WATCH_D5_NOT_RUN` | +| Policy total net return | -42.12% | +| Policy NAV | 0.5788 | +| Policy max drawdown | -81.91% | +| Policy mean turnover | 0.2000 | +| Invalid action rate | 0.00% | +| Best D3 baseline | `equal_weight_topk_momentum` | +| Best D3 total net return | +31.37% | +| Delta vs best D3 | -73.49 percentage points | + +This is a useful RL environment/telemetry and failure-analysis artifact, not a tradable model. The policy underperforms no-trade, shuffle, and the best D3 rule baseline after the current research accounting. + +## Visualization/usage surfaces + +| Surface | Evidence | +|---|---| +| Learning graph | `learning_curve.csv`, dashboard `data-daily-rl-learning-curve` | +| Reward/return curve | `reward_breakdown.csv`, dashboard `data-daily-rl-reward-return-curve` | +| Reward stack | `reward_component_summary.json`, dashboard `data-daily-rl-reward-components` | +| NAV / portfolio trajectory | `policy_nav.csv`, dashboard `data-daily-rl-portfolio-trajectory` | +| Drawdown | `drawdown.csv`, dashboard `data-daily-rl-turnover-drawdown` | +| Turnover/cost | `turnover.csv`, reward breakdown cost/slippage columns | +| Action distribution | `action_distribution.csv`, dashboard `data-daily-rl-action-distribution` | +| Invalid action evidence | `invalid_actions.csv`, `invalid_action_rate=0.0` | +| State contract | `observation_manifest.json`, `state_observations.csv` | +| Frozen D3 overlay | `policy_baseline_comparison.csv`, dashboard `data-daily-rl-policy-baseline-comparison` | + +## Provenance hardening + +| Evidence | SHA-256 | +|---|---| +| `policy_metrics.json` | `bf1f88bab622463e1a496c2cc502e0b109886fd99dc3900c0aee27ba69ff7c18` | +| `policy_nav.csv` | `cdc631abf56c463d95c9c4b46ca33dcc810c4254882dcb9c08b505c79b7f2268` | +| `policy_baseline_comparison.csv` | `cff6d20b5d16dc01e299ec3d44d7bfe110bedce9cdf4f156d44577f574e4e5e6` | +| `learning_curve.csv` | `01de8fcff71791b4cc065492982cec5fa3b65e5274d58d90221a0b9e54444f58` | + +The Flask D4 portfolio API now fail-closes stale/optimistic generated artifacts, including nested `training_manifest.json` and `policy_evaluation_manifest.json`, to `RESEARCH_ONLY` and `D4_RESEARCH_ONLY_DIAGNOSTICS`, with `model_build_allowed=false`, `go_summary_allowed=false`, `paper_forward_allowed=false`, and `live_broker_order_allowed=false`. The dashboard D4 card displays upstream and D4 artifact hashes through `data-daily-rl-provenance-hashes`, and regression tests cover declared upstream hash mismatches. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, order-routing, or paper-forward readiness is implied. +- No profit claim is made. +- Default cost assumption remains 23bp round trip. +- Leading-zero stock codes remain strings in dataset/prediction/portfolio evidence. +- D4 is an RL experiment/diagnostic, but it remains `RESEARCH_ONLY` and under D5 gate control. +- `model_build_allowed=false` remains required until D0/D1/D3/D5 gates pass. + +## Verification + +Current focused verification after D4 hardening: + +```powershell +py -3.11 -m py_compile stom_rl/daily_rl_train.py webui/daily_ohlcv_dashboard.py tests/test_stom_rl_daily_rl_gate.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +# PASS + +py -3.11 -m pytest tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_prediction.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +# 42 passed + +cd webui/v2_src +npm run build +# svelte-check 0 errors, 4 pre-existing warnings; Vite build PASS +``` diff --git a/docs/stom_daily_ohlcv_d4_trade_quality_filter_prereg_2026-06-17.md b/docs/stom_daily_ohlcv_d4_trade_quality_filter_prereg_2026-06-17.md new file mode 100644 index 000000000..27826184a --- /dev/null +++ b/docs/stom_daily_ohlcv_d4_trade_quality_filter_prereg_2026-06-17.md @@ -0,0 +1,94 @@ +# Daily OHLCV D4 Trade-Quality Filter Preregistration — 2026-06-17 + +Date: 2026-06-17 UTC +Status: `PREREGISTERED_RESEARCH_ONLY` +Experiment type: `RL experiment` / diagnostic filter research +Parent result: `docs/stom_daily_ohlcv_d4_action_induction_v2_result_2026-06-17.md` +Default cost: 23bp round trip + +## Objective + +The D4 action-induction v2 run proved that non-hold actions can be made reachable, but all v2 policies still underperformed the best D3/no-trade baseline on `val+test`. The next research lane must therefore test **trade quality and abstention**, not more action forcing. + +This preregistration freezes the next hypothesis before further scenario execution. + +## Non-negotiable guardrails + +| Guardrail | Required state | +|---|---| +| Live/broker/order use | Forbidden. | +| Profit claim | Forbidden. | +| Paper-forward/model-build promotion | Forbidden until D0/D1/D3/D4/D5 gates pass in a later approved workflow. | +| Default cost | 23bp round trip; include 46bp stress before any promotion claim. | +| D5 status | `NO-GO` unless a fresh gate run passes. | +| Future labels | Current/future labels may not enter state/action/filter selection. They are allowed only after decision for reward and diagnostics. | +| Action prior | Freeze as diagnostic-only or disable; do not tune upward to chase a curve. | +| Generated artifacts | Write generated evidence under `webui/rl_runs/`; durable conclusions under `docs/`. | + +## Hypothesis + +A D4 policy/filter may improve trade quality if it abstains from weak D3 signals and trades only when **decision-time confidence and margin** are high enough. The expected success condition is not action frequency; it is whether fewer, higher-quality actions reduce drawdown/turnover and improve comparison against no-trade, shuffle, and frozen D3 baselines. + +## Frozen research variants + +| Variant | Change | Rationale | Failure mode to test | +|---|---|---|---| +| `confidence_abstain_v1` | Permit buy/add only when `d3_confidence_bucket` is above a frozen threshold. | v2 traded too often without enough signal strength. | May revert to no-trade or miss positive days. | +| `margin_abstain_v1` | Permit buy/add only when `score_margin_bucket` is above a frozen threshold. | Top candidate must be meaningfully separated from alternatives. | May overfit score scale and still lose. | +| `confidence_margin_joint_v1` | Require both confidence and margin thresholds. | Tests strict quality filter versus action reachability. | Lower turnover may still not beat no-trade. | +| `risk_regime_abstain_v1` | Block new entries when past-only `recent_score_volatility_bucket` is high. | v2 drawdowns suggest unstable regimes may be harmful. | Proxy may not represent price risk. | +| `prior_disabled_control_v1` | Keep v2 state but disable entry prior. | Separates filter effects from action-forcing effects. | Actions may become sparse again. | + +## State/action/reward/environment contract + +| RL element | Requirement | Forbidden shortcut | +|---|---|---| +| State | Keep v2 causal fields: `position_count`, `top_score_bucket`, `score_margin_bucket`, `candidate_count_bucket`, `recent_score_volatility_bucket`, `d3_confidence_bucket`. | Do not expose current/future `future_return_1d` or fold outcomes. | +| Action | Keep hold/buy/add/sell/reduce with valid masks. Filter may block buy/add and must record the exact reason. | Do not silently coerce blocked actions without telemetry. | +| Reward | Keep actual net return after 23bp cost separate from shaping/filter diagnostics. | Do not report filter reward as realized return. | +| Environment | Daily OHLCV research environment only. | No live fills, broker, orders, or market simulation claims. | +| Policy/filter | Label as `D4 trade-quality filter diagnostic`; compare to tabular-Q v2 and D3 baselines. | Do not call filter pass a deployable RL policy. | +| Diagnostics | Emit action reachability, abstention reason counts, no-trade opportunity diagnostics, and baseline deltas. | Do not use hindsight diagnostics to choose actions in the same run. | + +## Required artifacts for each run + +| Artifact | Purpose | +|---|---| +| `rl_manifest.json` | D4 lineage, guardrails, status, hashes. | +| `observation_manifest.json` | State/filter contract and leakage checks. | +| `state_observations.csv` | Decision-time state rows with no future labels. | +| `action_distribution.csv` | Whether actions remain reachable. | +| `invalid_actions.csv` | Valid mask and blocked-action telemetry. | +| `abstention_reasons.csv` | New required filter decision reasons. | +| `reward_breakdown.csv` | Actual accounting terms. | +| `policy_baseline_comparison.csv` | no-trade/shuffle/frozen D3 comparison under 23bp. | +| `no_trade_opportunity_summary.json` | Post-policy missed/avoided opportunity analysis. | +| `scenario_batch_manifest.json` | Scenario matrix status and blockers. | +| `*_result_YYYY-MM-DD.md` | Durable result report under `docs/`. | + +## Acceptance criteria + +A result is acceptable as research evidence only if all are true: + +1. State/filter artifacts prove current/future labels are not used in action selection. +2. Action distribution and abstention reasons are emitted for every split. +3. Net return after cost remains separate from filter/shaping diagnostics. +4. Comparisons include no-trade, shuffle, equal-weight top-k, and frozen D3 baselines. +5. D5 remains `NO-GO` unless a separate fresh gate passes. +6. A dated result document is created under `docs/`. +7. `model_build_allowed=false`, `paper_forward_allowed=false`, and `live_broker_order_allowed=false` remain true in manifests. + +## Planned executable next step + +Implement a bounded `D4 trade-quality filter` scenario lane: + +1. Add filter/abstention telemetry without changing existing reward accounting. +2. Add tests for filter no-leakage, blocked-action reasons, and baseline manifest fields. +3. Run a small preregistered scenario batch comparing confidence, margin, joint, regime, and control variants. +4. Publish `docs/stom_daily_ohlcv_d4_trade_quality_filter_result_YYYY-MM-DD.md` and update the governance index. + +## Current promotion status + +`NO-GO_RESEARCH_ONLY`. + +This preregistration approves diagnostics only. It does not approve model build, paper-forward, live trading, broker integration, or profit claims. diff --git a/docs/stom_daily_ohlcv_d4_trade_quality_filter_result_2026-06-17.md b/docs/stom_daily_ohlcv_d4_trade_quality_filter_result_2026-06-17.md new file mode 100644 index 000000000..574359f6f --- /dev/null +++ b/docs/stom_daily_ohlcv_d4_trade_quality_filter_result_2026-06-17.md @@ -0,0 +1,100 @@ +# Daily OHLCV D4 Trade-Quality Filter Result — 2026-06-17 + +Date: 2026-06-17 UTC +Status: `NO-GO_RESEARCH_ONLY` +Experiment type: `RL experiment` / diagnostic filter research +Parent preregistration: `docs/stom_daily_ohlcv_d4_trade_quality_filter_prereg_2026-06-17.md` +Parent result: `docs/stom_daily_ohlcv_d4_action_induction_v2_result_2026-06-17.md` +Default cost: 23bp round trip; D5 scenario manifests record 0/23/46bp cost sensitivity, while the result table below reports 23bp net-return figures. + +## Verdict + +The D4 trade-quality filter lane implemented the preregistered confidence/margin/risk-regime abstention telemetry and produced `abstention_reasons.csv` for every scenario. The feature is useful as a **diagnostic and governance artifact**, but it does not improve the research verdict. + +All five scenarios remain `NO-GO` under the D5 research gate. The best comparator in this bounded batch is still `no_trade_cash` at 0.00% total net return on `val+test`; every D4 filter/control variant lost after 23bp costs. No model-build, paper-forward, live, broker, order, or profit claim is opened. + +## What changed in this research lane + +| Element | Implementation | Governance note | +|---|---|---| +| State | Kept v2 causal state: `position_count`, `top_score_bucket`, `score_margin_bucket`, `candidate_count_bucket`, `recent_score_volatility_bucket`, `d3_confidence_bucket` | No current/future `future_return_1d` in state/filter selection. | +| Action | Existing hold/buy/add/sell/reduce masks plus decision-time buy/add filter | Filter blocks only new entries and records exact reasons; it does not silently coerce without telemetry. | +| Policy/filter | `tabular_q_trade_quality_filter_v1` for confidence/margin/joint/risk variants; `tabular_q` for prior-disabled control | Filter is diagnostic-only, not deployable RL. | +| Reward | Existing net-return-after-cost reward accounting remains unchanged | Filter diagnostics are separate from realized return. | +| Diagnostics | New `abstention_reasons.csv` plus existing state observations, invalid actions, reward breakdown, no-trade diagnostics, baseline comparison | Future labels remain post-action reward/diagnostic inputs only. | + +## Exact commands + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_scenario_runner.py -q +py -3.11 -m stom_rl.daily_scenario_batch --plan artifacts/scenario_batch_d4_trade_quality_filter_001_plan.json --batch-id scenario_batch_d4_trade_quality_filter_001 --overwrite +``` + +Observed output: + +```text +21 passed in 1.46s +scenario_count=5, completed_count=5, failed_count=0, gate_status_counts={"NO-GO": 5} +``` + +## Durable artifacts + +| Artifact | Path | +|---|---| +| Scenario plan | `artifacts/scenario_batch_d4_trade_quality_filter_001_plan.json` | +| Batch manifest | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_d4_trade_quality_filter_001/scenario_batch_manifest.json` | +| Batch research summary | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_d4_trade_quality_filter_001/trade_quality_filter_research_summary.json` | +| Confidence filter portfolio artifacts | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__confidence_abstain_v1/` | +| Margin filter portfolio artifacts | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__margin_abstain_v1/` | +| Joint confidence+margin portfolio artifacts | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__confidence_margin_joint_v1/` | +| Risk-regime portfolio artifacts | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__risk_regime_abstain_v1/` | +| Prior-disabled control artifacts | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__prior_disabled_control_v1/` | +| Representative source-hash artifact | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__confidence_abstain_v1/source_hashes.json` (same `source_hashes.json` contract per scenario portfolio directory) | + +Each portfolio directory includes `rl_manifest.json`, `observation_manifest.json`, `source_hashes.json`, `state_observations.csv`, `abstention_reasons.csv`, `action_distribution.csv`, `reward_breakdown.csv`, `policy_baseline_comparison.csv`, `no_trade_opportunity_summary.json`, and `verdict.json`. + +## Scenario results (`val+test`) + +| Scenario | Filter setting | D5 status | RL total net return | NAV | MDD | Turnover | Best D3 baseline | Delta vs best D3 | Abstention / action behavior | +|---|---|---|---:|---:|---:|---:|---|---:|---| +| `confidence_abstain_v1` | buy/add require `d3_confidence_bucket >= 3` | `NO-GO` | -10.11% | 0.8989 | -11.84% | 9.91% | `no_trade_cash` 0.00% | -10.11% | 30 blocked-entry rows; buy=5, add=1, sell=4, reduce=1, hold=26 | +| `margin_abstain_v1` | buy/add require `score_margin_bucket >= 3` | `NO-GO` | -11.99% | 0.8801 | -23.53% | 2.70% | `no_trade_cash` 0.00% | -11.99% | 35 blocked-entry rows; buy=1, add=1, reduce=1, hold=34 | +| `confidence_margin_joint_v1` | require both confidence and margin thresholds | `NO-GO` | -11.99% | 0.8801 | -23.53% | 2.70% | `no_trade_cash` 0.00% | -11.99% | 35 blocked-entry rows; same action profile as margin filter | +| `risk_regime_abstain_v1` | block entries only when past-score volatility bucket is above threshold | `NO-GO` | -12.53% | 0.8747 | -14.88% | 24.32% | `no_trade_cash` 0.00% | -12.53% | 0 blocked rows in this bounded run; buy=7, add=7, sell=6, hold=17 | +| `prior_disabled_control_v1` | v2 state, no prior, no filter | `NO-GO` | -11.29% | 0.8871 | -15.09% | 10.81% | `no_trade_cash` 0.00% | -11.29% | control: buy=4, add=2, sell=4, reduce=2, hold=25 | + +## Interpretation + +1. Abstention telemetry works: the confidence/margin/joint filters produced explicit `abstention_reasons.csv` rows and blocked buy/add decisions without using future labels. +2. The best filter by return was `confidence_abstain_v1`, but it still lost -10.11% versus 0.00% no-trade/best D3 and therefore remains `NO-GO`. +3. Margin and joint filters lowered turnover sharply, but drawdown worsened in this bounded run; lower turnover alone did not create a usable policy. +4. The risk-regime proxy did not block entries in this bounded matrix, so the current past-score-volatility proxy is not a useful risk gate as configured. +5. The prior-disabled control still lost, confirming that richer v2 state alone is not enough. + +## Data governance record + +| Governance area | Evidence | +|---|---| +| Lineage | Parent preregistration/result are linked above; batch plan and batch manifest are listed in durable artifacts. | +| Reproducibility | Scenario plan stores frozen defaults, overrides, seeds, folds, purge/embargo, candidate limits, max positions, episodes, action prior, and filter mode; the executed batch manifest records `cost_sensitivity_bp=[0,23,46]`, generated artifact paths, and per-scenario statuses. | +| Schema control | `observation_manifest.json` declares `trade_quality_filter`; `rl_manifest.json` records `action_filter_mode` and thresholds. | +| Leakage control | Tests cover filter no-leakage and `abstention_reasons.csv` rows set `future_label_exposed=false`; reward labels remain post-action diagnostics only. | +| Baseline controls | Batch includes D5 gate output and frozen baseline comparison against no-trade, shuffle, equal-weight top-k, and D3 strategies. | +| Generated vs durable separation | Generated evidence stays under `webui/rl_runs/`; this dated result record stays under `docs/`. | +| Safety locks | `model_build_allowed=false`, `go_summary_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false` remain enforced. | + +## Current promotion status + +`NO-GO_RESEARCH_ONLY`. + +D5 remains blocked. This run does not approve model build, paper-forward, live trading, broker integration, order placement, or any profit/readiness claim. + +## Next allowed research action + +Do **not** tune thresholds against this result without a new preregistration. The next useful research should move upstream to a preregistered D3/D4 signal-quality audit: + +- verify whether D3 score magnitude and score margin are calibrated enough to support abstention, +- add a better past-only risk/regime proxy if available from daily OHLCV artifacts, +- keep no-trade, shuffle, equal-weight top-k, and frozen D3 baselines mandatory, +- keep `abstention_reasons.csv` and no-leakage tests as required artifacts, +- keep D5/model-build/paper-forward status `NO-GO` until a fresh gate passes. diff --git a/docs/stom_daily_ohlcv_d4_training_visualization_result_2026-06-13.md b/docs/stom_daily_ohlcv_d4_training_visualization_result_2026-06-13.md new file mode 100644 index 000000000..4b1bcb774 --- /dev/null +++ b/docs/stom_daily_ohlcv_d4_training_visualization_result_2026-06-13.md @@ -0,0 +1,79 @@ +# STOM Daily OHLCV D4 Training/Evaluation Visualization Result (2026-06-13) + +## Verdict + +`RESEARCH_ONLY` visualization wiring is complete for the D4 daily portfolio RL evidence surface. + +This is not a live-trading model, not broker/order readiness, and not a profit claim. The dashboard now visualizes training/evaluation diagnostics and the state contract while preserving `model_build_allowed=false`. + +## Artifact + +- Portfolio run: `portfolio_2026_06_13_g003_state_visualization` +- Directory: `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_13_g003_state_visualization/` +- Source D3 run: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_13_d3_baseline_hardened/` +- Screenshot: `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_13_g003_state_visualization/g003_dashboard_state_visualization.png` +- Final screenshot after final build: `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_13_g003_state_visualization/g003_dashboard_state_visualization_final.png` +- Cost: 23bp round trip +- Status: `RESEARCH_ONLY`; `go_summary_allowed=false`; `model_build_allowed=false` + +Generated/wired files now include: + +- `observation_manifest.json` +- `state_observations.csv` +- `learning_curve.csv` +- `reward_breakdown.csv` +- `reward_component_summary.json` +- `action_distribution.csv` +- `invalid_actions.csv` +- `turnover.csv` +- `drawdown.csv` +- `policy_baseline_comparison.csv` +- `policy_nav.csv` + +## Page coverage + +| Required visualization | Current surface | +|---|---| +| State contract | `data-daily-rl-state-contract` shows `D4_OBSERVATION_STATE_MANIFEST`, validation status, model/go locks, telemetry insufficiency, and observation fields. | +| State observations | `data-daily-rl-state-observations` shows cash, exposure, current position count, top candidate, and `future_label_exposed=false`. | +| Leakage checks | `data-daily-rl-leakage-checks` shows required leakage checks and fail-closed wording for missing/duplicate/failing checks. | +| Learning curve | `data-daily-rl-learning-curve` shows episode reward, rolling mean reward, and best reward. | +| Reward/return curve | `data-daily-rl-reward-return-curve` shows date/action gross return, reward, equity, and missing label count. | +| Reward stack | `data-daily-rl-reward-components` shows gross/cost/exposure/concentration/invalid/churn/drawdown/reward stack by split. | +| Action distribution | `data-daily-rl-action-distribution` shows split/action/invalid/action-rate. | +| Invalid actions | `data-daily-rl-invalid-actions` shows action, invalid flag, and action mask string. | +| Turnover/drawdown | `data-daily-rl-turnover-drawdown` shows turnover and current drawdown. | +| Portfolio trajectory | `data-daily-rl-portfolio-trajectory` shows policy NAV, drawdown, concentration, and turnover. | +| Frozen D3 comparison | `data-daily-rl-policy-baseline-comparison` shows policy vs no-trade/shuffle/rule/supervised baselines. | + +## Backend/API wiring + +- `/api/daily-ohlcv/portfolio/latest` now exposes `observation_manifest`, `observation_manifest_validation`, and `samples.state_observations`. +- `/api/daily-ohlcv/charts/portfolio` now exposes `observation_manifest`, `observation_manifest_validation`, `state_observations`, `invalid_actions`, `portfolio_trajectory`, and `reward_stack` alongside existing learning/action/reward/turnover/drawdown/baseline series. +- `DailyPortfolioResponse` and `DailyModelChartResponse` frontend types include the new fields. + +## Verification + +Commands run: + +```powershell +py -3.11 -m py_compile stom_rl/daily_rl_train.py stom_rl/daily_portfolio_env.py webui/daily_ohlcv_dashboard.py webui/app.py tests/test_stom_rl_daily_rl_gate.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +py -3.11 -m pytest tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_rl_gate.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +cd webui/v2_src; npm run check +cd webui/v2_src; npm run build +``` + +Observed results: + +- Python compile: passed. +- Focused Python tests: `25 passed`. +- `npm run check`: 0 errors, 4 pre-existing unrelated warnings. +- `npm run build`: Vite build succeeded with the same warnings. +- Browser/API smoke at `/daily-ohlcv`: all D4 state/learning/reward/action/trajectory/baseline markers were present; screenshot saved. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing path was added. +- No profit or deployable-model claim is made. +- D4 remains `RESEARCH_ONLY` and cannot unlock model build without D0/D1/D3/D5 gates. diff --git a/docs/stom_daily_ohlcv_d4a_env_inspector_result_2026-06-13.md b/docs/stom_daily_ohlcv_d4a_env_inspector_result_2026-06-13.md new file mode 100644 index 000000000..62d03d925 --- /dev/null +++ b/docs/stom_daily_ohlcv_d4a_env_inspector_result_2026-06-13.md @@ -0,0 +1,84 @@ +# STOM Daily OHLCV D4-A Environment Inspector Result (2026-06-13) + +## Verdict + +`RESEARCH_ONLY`. This is an environment/accounting inspection result, not a strategy result, not a trained policy, and not live/broker/order readiness. + +The D4-A daily close-to-close / swing portfolio RL environment now exposes an explicit contract for state, constrained actions, action masks, fill assumption, reward components, 23bp turnover cost, drawdown/concentration/churn/invalid-action penalties, and generated inspection artifacts. + +## Artifact + +- Run: `env_inspection_2026_06_13_d4a_restart` +- Directory: `webui/rl_runs/daily_ohlcv_portfolio_env/env_inspection_2026_06_13_d4a_restart/` +- Input comparator/candidate source: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_13_d3_baseline_hardened/` +- Fill assumption: `close_to_next_close_research_label; no live/broker/order fill is inferred from daily OHLCV` +- Cost: 23bp round trip +- Status: `RESEARCH_ONLY` +- `model_build_allowed=false` + +Generated files: + +- `env_manifest.json` +- `reward_breakdown.csv` +- `action_masks.csv` +- `positions.csv` + +## Environment contract + +| Area | Contract | +|---|---| +| State | `(position_count, top_score_bucket)`; current holdings + current candidate score bucket only | +| Lookahead policy | `future_return_1d` labels are consumed only after action for reward accounting | +| Actions | `hold`, `buy`, `add`, `sell`, `reduce` | +| Action mask | buy/add/sell/reduce are valid only when position/candidate constraints allow them | +| Fill | close-to-next-close research label only; no broker/order fill claim | +| Accounting | equity, peak equity, current drawdown, turnover, exposure, concentration | +| Reward | gross return minus cost/risk/invalid/churn/drawdown penalties | + +Reward formula: + +```text +reward = gross_return + - turnover_cost + - exposure_penalty + - concentration_penalty + - invalid_action_penalty + - churn_penalty + - drawdown_penalty +``` + +## Inspection summary + +The generated inspection used a deterministic scripted action sequence to exercise the environment. It is intentionally **not** a candidate strategy. + +| Item | Value | +|---|---:| +| Steps | 309 | +| Invalid actions | 0 | +| Final equity | 0.1122517356 | +| Current drawdown | -0.8981224543 | + +The poor final equity is acceptable for D4-A because the artifact is a stress/contract inspection of the environment path, not a trained policy. It confirms that reward breakdown, action masks, position logging, drawdown accounting, and guardrail metadata are generated. + +## Verification + +Commands run: + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_rl_gate.py -q +py -3.11 -m py_compile stom_rl/daily_portfolio_env.py stom_rl/daily_rl_train.py +``` + +Observed result: + +- D4 environment/RL gate targeted tests: `8 passed`. +- Python compile: passed. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- D4 remains `RESEARCH_ONLY`. +- `model_build_allowed=false` remains in force until D0/D1/D3/D5 gates pass. +- Leading-zero stock codes are preserved in environment candidates and generated positions. diff --git a/docs/stom_daily_ohlcv_d4b_training_telemetry_result_2026-06-13.md b/docs/stom_daily_ohlcv_d4b_training_telemetry_result_2026-06-13.md new file mode 100644 index 000000000..8e755a32a --- /dev/null +++ b/docs/stom_daily_ohlcv_d4b_training_telemetry_result_2026-06-13.md @@ -0,0 +1,84 @@ +# STOM Daily OHLCV D4-B Training Telemetry Result (2026-06-13) + +## Verdict + +`RESEARCH_ONLY`. This is daily portfolio RL training telemetry and visualization evidence, not a profit result, not a deployable model, and not live/broker/order readiness. + +## Artifact + +- Run: `portfolio_2026_06_13_d4b_telemetry` +- Directory: `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_13_d4b_telemetry/` +- Input D3 run: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_13_d3_baseline_hardened/` +- Cost: 23bp round trip +- Status: `RESEARCH_ONLY` +- `model_build_allowed=false` remains controlled by D5 and is not unlocked by this telemetry. + +Generated telemetry files: + +- `training_manifest.json` +- `episode_metrics.csv` +- `learning_curve.csv` +- `reward_breakdown.csv` +- `reward_component_summary.json` +- `action_distribution.csv` +- `invalid_actions.csv` +- `turnover.csv` +- `drawdown.csv` + +Compatibility files retained for existing D4 readers: + +- `rl_manifest.json` +- `policy_metrics.json` +- `positions.csv` +- `baseline_comparison.json` +- `verdict.json` + +## Telemetry contract + +| Area | Contract | +|---|---| +| Learning curve | Episode reward, rolling mean reward, best reward, final equity, invalid-action rate | +| Reward stack | Gross return, 23bp turnover cost, exposure, concentration, invalid-action, churn, drawdown, final reward | +| Action distribution | Split/action counts and action rates; all-hold outcomes are shown rather than hidden | +| Turnover/cost | Per-date turnover, turnover cost, churn penalty, reward, equity | +| Drawdown | Per-date equity, current drawdown, drawdown penalty, reward before/after drawdown | +| Dashboard payload | `/api/daily-ohlcv/portfolio/latest` and `/api/daily-ohlcv/charts/portfolio` expose telemetry samples/cards | +| Frontend | D4 card shows training status, telemetry stack, learning curve, action distribution, reward components, turnover/drawdown | + +The current deterministic policy evaluates mostly/all hold on the latest artifact. That is acceptable and must be visible: it is evidence of the constrained research policy behavior, not a failure to hide or a profit claim. + +## Visualization stack decision + +- Canonical source of truth: generated CSV/JSON artifacts under `webui/rl_runs/`. +- Dashboard transport: Flask read-only JSON payloads. +- Frontend display: Svelte evidence cards with tabular/series summaries. +- Research plotting compatibility: the CSV series are Plotly-compatible. +- TensorBoard/SB3 Monitor logs are marked not emitted for this dependency-free tabular-Q run; they can be added only when a real SB3 training lane exists. + +## Verification + +Commands run: + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_rl_gate.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +py -3.11 -m py_compile stom_rl/daily_rl_train.py webui/daily_ohlcv_dashboard.py +cd webui/v2_src +npm run check +npm run build +``` + +Observed result: + +- Targeted Python/API/frontend marker tests: `14 passed`. +- Python compile: passed. +- Svelte check: passed with 0 errors and 4 pre-existing warnings in unrelated files (`ForecastWorkbenchTab.svelte`, `DocsTab.svelte`). +- Svelte check/build: passed with 0 errors and 4 pre-existing warnings in unrelated files (`ForecastWorkbenchTab.svelte`, `DocsTab.svelte`). + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- D4 remains `RESEARCH_ONLY`. +- `model_build_allowed=false` remains in force until strict D5/fresh forward gates pass. +- Dashboard APIs are GET-only evidence payloads. diff --git a/docs/stom_daily_ohlcv_d4c_policy_evaluation_result_2026-06-13.md b/docs/stom_daily_ohlcv_d4c_policy_evaluation_result_2026-06-13.md new file mode 100644 index 000000000..66ca18860 --- /dev/null +++ b/docs/stom_daily_ohlcv_d4c_policy_evaluation_result_2026-06-13.md @@ -0,0 +1,85 @@ +# STOM Daily OHLCV D4-C Policy Evaluation Result (2026-06-13) + +## Verdict + +`RESEARCH_ONLY`. This compares the constrained daily portfolio RL policy against frozen D3 baselines after the 23bp default cost. It is not a profit result, not a deployable model, and not live/broker/order readiness. + +## Artifact + +- Run: `portfolio_2026_06_13_d4c_policy_eval` +- Directory: `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_13_d4c_policy_eval/` +- Input D3 run: `prediction_2026_06_13_d3_baseline_hardened` +- Cost: 23bp round trip +- Status: `RESEARCH_ONLY` + +New policy-evaluation files: + +- `policy_evaluation_manifest.json` +- `policy_baseline_comparison.csv` +- `policy_nav.csv` + +Required frozen D3 comparison set: + +- `no_trade_cash` +- `shuffle_control` +- `equal_weight_topk_momentum` +- `vol_adjusted_momentum` +- `supervised_linear_ranker` +- `supervised_direction_classifier` + +## Result summary + +The current deterministic constrained policy is effectively an all-hold/no-position policy on val+test: + +- policy NAV: `1.0` +- policy total net return: `0.0` +- policy MDD: `0.0` +- policy mean turnover: `0.0` +- policy mean concentration: `0.0` + +This beats the negative shuffle control but ties no-trade cash and underperforms the positive D3 rule/supervised baselines. Therefore it remains `RESEARCH_ONLY` and cannot unlock model build. + +## Comparison contract + +| Field | Meaning | +|---|---| +| `policy_nav` | 1 + policy total net return on val+test | +| `baseline_nav` | 1 + frozen D3 baseline total net return | +| `baseline_delta_total_net_return` | policy total net return minus baseline total net return | +| `policy_max_drawdown` | policy MDD from D4 evaluation | +| `policy_mean_turnover` | policy turnover after constraints/cost | +| `policy_mean_concentration` | policy holdings concentration diagnostic | +| `comparison_status` | `POLICY_BEATS_BASELINE` or `POLICY_UNDERPERFORMS_OR_TIES` | + +## Dashboard/API surface + +- `/api/daily-ohlcv/portfolio/latest` exposes `policy_evaluation`, `samples.policy_baseline_comparison`, and `samples.policy_nav`. +- `/api/daily-ohlcv/charts/portfolio` exposes `policy_baseline_comparison`, `policy_nav`, and the policy-evaluation row counts. +- The D4 dashboard card shows frozen-baseline deltas and policy NAV/MDD/turnover while preserving `RESEARCH_ONLY` framing. + +## Verification + +Commands run: + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_rl_gate.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +py -3.11 -m py_compile stom_rl/daily_rl_train.py webui/daily_ohlcv_dashboard.py +cd webui/v2_src +npm run check +npm run build +``` + +Observed result: + +- Targeted Python/API/frontend marker tests: `14 passed`. +- Python compile: passed. +- Svelte check/build: passed with 0 errors and 4 pre-existing warnings in unrelated files (`ForecastWorkbenchTab.svelte`, `DocsTab.svelte`). + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- D4 remains `RESEARCH_ONLY`. +- `model_build_allowed=false` remains in force until strict D5/fresh forward gates pass. +- The dashboard must show underperformance against D3 baselines instead of presenting the RL policy as usable. diff --git a/docs/stom_daily_ohlcv_d5_fresh_oos_walk_forward_gate_result_2026-06-14.md b/docs/stom_daily_ohlcv_d5_fresh_oos_walk_forward_gate_result_2026-06-14.md new file mode 100644 index 000000000..5b41c8ebf --- /dev/null +++ b/docs/stom_daily_ohlcv_d5_fresh_oos_walk_forward_gate_result_2026-06-14.md @@ -0,0 +1,122 @@ +# STOM Daily OHLCV D5 Fresh OOS Walk-forward Gate Result (2026-06-14) + +## Verdict + +`NO-GO`. The D5 artifact is fresh OOS/walk-forward evidence for the daily OHLCV research track. It is not a profit proof, not a deployable model, not paper-forward approval, and not live/broker/order readiness. + +## Artifact + +| Field | Value | +|---|---| +| Run | `walk_forward_2026_06_14_g006_d5_fresh_oos_gate` | +| Directory | `webui/rl_runs/daily_ohlcv_walk_forward/walk_forward_2026_06_14_g006_d5_fresh_oos_gate/` | +| D3 input | `prediction_2026_06_14_g004_d3_baseline_hardened` | +| D4 input | `portfolio_2026_06_14_g005_d4_visualization` | +| Selected policy | `equal_weight_topk_momentum` preregistered baseline | +| Folds | 5 | +| Purge/embargo | 5/5 days | +| Cost | 23bp round trip, plus 0/23/46bp stress rows | +| Readiness | `D5_NO_GO_RESEARCH_ONLY_GATE` | +| model_build_allowed | `false` | +| go_summary_allowed | `false` | +| paper_forward_allowed | `false` | +| live_broker_order_allowed | `false` | +| no_live_broker_order_readiness | `true` | + +Generated files: + +- `walk_forward_manifest.json` +- `gate_verdict.json` +- `d4_state_contract.json` +- `folds.csv` +- `fold_assignments.csv` +- `fold_metrics.csv` +- `shuffle_control.csv` +- `cost_sensitivity.csv` +- `rl_fold_metrics.csv` + +## Provenance hashes + +| Artifact | SHA-256 | +|---|---| +| D5 `walk_forward_manifest.json` | `3f1e9a84f4b75343f725ab084ea6e89ea6c49197cf3d1fe8cbb7c61b1c0be905` | +| D5 `gate_verdict.json` | `dd74b12c6dd56a7143376d83f45c1ad89d05596906ea0d23132c75770b6d95b9` | +| D5 `fold_metrics.csv` | `1de2740ebbd2ee90877040e163a6eb97a8d44fa4aa905a35351099de2ac018ad` | +| D5 `cost_sensitivity.csv` | `7afe7cbf5e1d5cb6c8982912a5278dc7de5d1807fc1863b78b932efba6b68131` | +| D5 `rl_fold_metrics.csv` | `74fe3fe0352d6f1f16828a3535a0a764491e6193b5c12f9cd69eef47c0333886` | +| D4 `rl_manifest.json` input | `5f103d446be2a84833e381f721ce7b388c090dd6e88889d166fd0b65ab659ff6` | +| D4 `state_observations.csv` input | `f3a5400611ab7c12bf6556fca27daa821369845f7e5e84a07ef7327d9ee68002` | +| D3 `prediction_manifest.json` input | `b1d4b26d8561444dd826c66bb1fdc092200f52d0dd1d05a0ab6f24b4c0439936` | +| D3 `predictions.csv` input | `78ad01d796ae75bccbe87753c7843f256cf2af28cf0ce8b24249c1989daa344a` | + +## Gate reasons + +- `RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM` +- `FORWARD_FOLDS_COMPLETE_NO_OOS_RETUNING` +- `D4_OBSERVATION_STATE_MANIFEST_CONSUMED` +- `PRICE_BASIS_UNKNOWN` +- `UNIVERSE_WATCH_HEURISTIC` +- `RL_POLICY_UNDERPERFORMS_D3_BASELINE` +- `D4_RL_RESEARCH_ONLY_LOCK` + +## Main metrics + +| Metric | Value | +|---|---:| +| Selected fold net-return sum | `0.3025311454569617` | +| Shuffled fold net-return sum | `-0.10855269295505843` | +| RL fold net-return sum | `0.159625021888819` | +| RL delta vs best D3 total net return | `-0.7349004887361696` | +| Positive selected folds | `3 / 5` | +| Folds beating no-trade | `3 / 5` | +| Folds beating shuffle | `4 / 5` | +| Worst fold max drawdown | `-0.16996776547953385` | +| Mean fold turnover | `0.34576943416181916` | +| D4 state observation rows consumed | `1098` | + +Interpretation: although the selected preregistered D3 baseline has positive folds and beats shuffle on four folds, the D4 RL policy still underperforms the hardened D3 baseline, while D0 price basis and D1 official/manual universe evidence remain unresolved. Therefore promotion stays blocked. + +## Dashboard/API changes + +| Surface | Evidence exposed | +|---|---| +| `/api/daily-ohlcv/walk-forward/latest` | Fail-closed `NO-GO`, D5 readiness, false model/go/paper/live flags, D3/D4 provenance hashes, fold samples | +| `/api/daily-ohlcv/charts/walk-forward` | D5 chart data plus `D5_NO_GO_RESEARCH_ONLY_GATE`, upstream hashes, D5 artifact hashes, D4 state contract | +| `/api/daily-ohlcv/charts/walk-forward-heatmap` | Fail-closed model/go/paper/live flags and effective blockers | +| Daily OHLCV model card | D5 readiness line, provenance hash block, no-OOS-retuning/fold/cost/RL fold evidence | + +Stale optimistic D5 artifacts that claim `PASS`, `GO`, `READY`, `LIVE_READY`, or any other loaded non-NO-GO status are normalized back to `NO-GO` with model/go/paper/live flags set to `false`. Fresh D5 generation also emits `NO-GO/D5_NO_GO_RESEARCH_ONLY_GATE` on favorable, blocked, and missing-evidence paths until the broader D0/D1/D3/D5 promotion contract is actually satisfied and explicitly redesigned. + +## Verification + +Commands run: + +```powershell +py -3.11 -m py_compile stom_rl/daily_walk_forward.py webui/daily_ohlcv_dashboard.py tests/test_stom_rl_daily_walk_forward.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +py -3.11 -m pytest tests/test_stom_rl_daily_walk_forward.py -q +py -3.11 -m pytest tests/test_stom_rl_daily_walk_forward.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +cd webui/v2_src +npm run build +browser/API check at http://127.0.0.1:58242/daily-ohlcv +``` + +Observed results: + +- Python compile: passed. +- D5 unit tests: `11 passed`. +- Focused D5/API/frontend marker tests: `41 passed`. +- Svelte check/build: 0 errors, 4 pre-existing warnings in unrelated files (`ForecastWorkbenchTab.svelte`, `DocsTab.svelte`), Vite build passed. +- Browser/API surface check: passed; `browser_api_report.json` and `g006_d5_dashboard_verified.png` were written under the D5 artifact directory. + +Additional verification still required before Ultragoal G006 checkpoint: architect review and executor QA/red-team lane. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- `model_build_allowed=false` remains in force. +- `paper_forward_allowed=false` remains in force. +- `live_broker_order_allowed=false` remains in force. +- Price basis remains `unknown`; model build stays blocked until independently verified. +- Universe remains `WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW`; official/manual evidence is still required. diff --git a/docs/stom_daily_ohlcv_d5_rl_walk_forward_gate_result_2026-06-13.md b/docs/stom_daily_ohlcv_d5_rl_walk_forward_gate_result_2026-06-13.md new file mode 100644 index 000000000..750e57f42 --- /dev/null +++ b/docs/stom_daily_ohlcv_d5_rl_walk_forward_gate_result_2026-06-13.md @@ -0,0 +1,77 @@ +# STOM Daily OHLCV D5 RL Walk-forward Gate Result (2026-06-13) + +## Verdict + +`NO-GO`. This is a read-only walk-forward/gate artifact for the daily RL candidate, not a profit result, not a deployable model, and not live/broker/order readiness. + +## Artifact + +- Run: `walk_forward_2026_06_13_d5_rl_gate_hardened` +- Directory: `webui/rl_runs/daily_ohlcv_walk_forward/walk_forward_2026_06_13_d5_rl_gate_hardened/` +- Portfolio input: `portfolio_2026_06_13_d4c_policy_eval` +- Selected preregistered D3 baseline: `equal_weight_topk_momentum` +- Cost: 23bp round trip, plus 0bp/23bp/46bp cost-sensitivity rows +- Folds: 5 +- Purge/embargo: 5/5 days +- `model_build_allowed=false` +- `go_summary_allowed=false` + +Generated files: + +- `walk_forward_manifest.json` +- `gate_verdict.json` +- `folds.csv` +- `fold_assignments.csv` +- `fold_metrics.csv` +- `shuffle_control.csv` +- `cost_sensitivity.csv` +- `rl_fold_metrics.csv` + +## Gate reasons + +- `RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM` +- `FORWARD_FOLDS_COMPLETE_NO_OOS_RETUNING` +- `PRICE_BASIS_UNKNOWN` +- `UNIVERSE_WATCH_HEURISTIC` +- `RL_POLICY_UNDERPERFORMS_D3_BASELINE` +- `D4_RL_RESEARCH_ONLY_LOCK` + +## Dashboard integration + +The existing D5 dashboard surfaces load this run as the latest walk-forward artifact: + +- `/api/daily-ohlcv/walk-forward/latest` +- `/api/daily-ohlcv/gate/latest` +- `/api/daily-ohlcv/charts/walk-forward` +- `/api/daily-ohlcv/charts/walk-forward-heatmap` +- `/api/daily-ohlcv/charts/decision-cockpit` +- `/api/daily-ohlcv/charts/flow` + +The dashboard shows fold metrics, shuffled controls, cost sensitivity, RL fold metrics, fold assignments, heatmap cells, decision-cockpit blockers, and the locked model-build state. + +## Verification + +Commands run: + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_walk_forward.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +py -3.11 -m py_compile stom_rl/daily_walk_forward.py webui/daily_ohlcv_dashboard.py +cd webui/v2_src +npm run check +npm run build +``` + +Observed result: + +- Targeted walk-forward/API/frontend marker tests: `14 passed`. +- Python compile: passed. +- Svelte check/build: passed with 0 errors and 4 pre-existing warnings in unrelated files (`ForecastWorkbenchTab.svelte`, `DocsTab.svelte`). + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- D5 remains `NO-GO`. +- `model_build_allowed=false` remains in force. +- No OOS retuning is allowed; the selected D3 baseline is preregistered rather than selected by favorable OOS fold results. diff --git a/docs/stom_daily_ohlcv_d5_state_aware_walk_forward_result_2026-06-13.md b/docs/stom_daily_ohlcv_d5_state_aware_walk_forward_result_2026-06-13.md new file mode 100644 index 000000000..b7dc213e3 --- /dev/null +++ b/docs/stom_daily_ohlcv_d5_state_aware_walk_forward_result_2026-06-13.md @@ -0,0 +1,94 @@ +# STOM Daily OHLCV D5 State-Aware Walk-Forward Gate Result (2026-06-13) + +## Verdict + +`NO-GO` is preserved. D5 now consumes the revised D4 observation/state manifest artifacts before evaluating forward folds, and it still keeps `model_build_allowed=false` and `go_summary_allowed=false`. + +This is research-only gate evidence, not a live-trading model, broker/order readiness, deployable-model claim, or profit claim. + +## Artifact + +- Walk-forward run: `walk_forward_2026_06_13_g004_state_aware_gate` +- Directory: `webui/rl_runs/daily_ohlcv_walk_forward/walk_forward_2026_06_13_g004_state_aware_gate/` +- Source D3 run: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_13_d3_baseline_hardened/` +- Source D4 run: `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_13_g003_state_visualization/` +- Cost ladder: `0bp / 23bp / 46bp` +- Purge/embargo: `5 / 5` days +- Status: `NO-GO`; `model_build_allowed=false`; `go_summary_allowed=false` + +Generated/wired files include: + +- `walk_forward_manifest.json` +- `gate_verdict.json` +- `d4_state_contract.json` +- `folds.csv` +- `fold_assignments.csv` +- `fold_metrics.csv` +- `shuffle_control.csv` +- `cost_sensitivity.csv` +- `rl_fold_metrics.csv` + +## Gate coverage + +| Requirement | Current surface | +|---|---| +| D4 state-aware artifact consumption | `d4_state_contract.json` and manifest fields show `D4_OBSERVATION_STATE_MANIFEST`, validation `PASS`, 1,098 state rows, no missing artifacts, and `reward_action_telemetry_sufficient_for_d4=false`. | +| Forward-only folds | `folds.csv` has 5 folds, `forward_only=true`, `retuned_on_oos=false`, and explicit purge/embargo windows. | +| No OOS retuning | Gate reason includes `FORWARD_FOLDS_COMPLETE_NO_OOS_RETUNING`; verdict field `no_oos_retuning=true`. | +| Shuffle/no-trade controls | `fold_metrics.csv` includes selected, no-trade, and shuffled-score rows with deltas vs no-trade and shuffled control. | +| Cost sensitivity | `cost_sensitivity.csv` and API chart expose 0/23/46bp results. | +| MDD/turnover/fold consistency | `fold_consistency` includes positive/negative folds, folds beating no-trade/shuffle, worst fold drawdown, and mean fold turnover. | +| D4 RL lock | `RL_POLICY_UNDERPERFORMS_D3_BASELINE` and `D4_RL_RESEARCH_ONLY_LOCK` remain explicit reasons. | +| Dashboard visibility | `/api/daily-ohlcv/walk-forward/latest` and `/api/daily-ohlcv/charts/walk-forward` expose D4 state contract, fold windows, controls, cost sensitivity, and reasons; `DailyModelResultsCard.svelte` renders the new D5 boxes. | + +## Result numbers + +- Selected strategy: `equal_weight_topk_momentum` +- Folds: `5` +- Positive/negative folds: `3 / 2` +- Folds beating no-trade/shuffle: `3 / 4` +- Selected total net return sum: `0.3025311454569617` +- Shuffled total net return sum: `-0.10855269295505843` +- RL total net return sum: `0.0` +- RL delta vs best D3 total net return: `-0.31367982153964924` +- Worst fold drawdown: `-0.16996776547953385` +- Mean fold turnover: `0.34576943416181916` + +## NO-GO reasons + +- `RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM` +- `FORWARD_FOLDS_COMPLETE_NO_OOS_RETUNING` +- `D4_OBSERVATION_STATE_MANIFEST_CONSUMED` +- `PRICE_BASIS_UNKNOWN` +- `UNIVERSE_WATCH_HEURISTIC` +- `RL_POLICY_UNDERPERFORMS_D3_BASELINE` +- `D4_RL_RESEARCH_ONLY_LOCK` + +## Verification + +Commands run: + +```powershell +py -3.11 -m py_compile stom_rl/daily_walk_forward.py webui/daily_ohlcv_dashboard.py tests/test_stom_rl_daily_walk_forward.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +py -3.11 -m pytest tests/test_stom_rl_daily_walk_forward.py -q +py -3.11 -m pytest tests/test_stom_rl_daily_walk_forward.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +cd webui/v2_src; npm run check +cd webui/v2_src; npm run build +browser smoke at http://127.0.0.1:60084/daily-ohlcv +``` + +Observed results: + +- Python compile: passed. +- Walk-forward unit tests before final cleanup fixes: `3 passed`. +- Final focused walk-forward/dashboard/API/tab tests: `25 passed`. +- `npm run check`: 0 errors, 4 pre-existing unrelated warnings. +- `npm run build`: Svelte check passed and Vite build succeeded with the same warnings plus bundle-size warning. +- Browser smoke: D5 chart, D4 state contract, controls, 0/23/46bp sensitivity, fold windows, `D4_OBSERVATION_STATE_MANIFEST_CONSUMED`, `no_oos_retuning`, `NO-GO`, `model_build_allowed=false`, `RESEARCH_ONLY`, and `no live/broker/orders` were visible. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing path was added. +- No profit/deployable-model claim is made. +- D5 remains `NO-GO`; model build stays locked until D0/D1/D3/D5 blockers are resolved with decision-grade evidence. diff --git a/docs/stom_daily_ohlcv_d6_d7_dashboard_usage_result_2026-06-14.md b/docs/stom_daily_ohlcv_d6_d7_dashboard_usage_result_2026-06-14.md new file mode 100644 index 000000000..43015c470 --- /dev/null +++ b/docs/stom_daily_ohlcv_d6_d7_dashboard_usage_result_2026-06-14.md @@ -0,0 +1,48 @@ +# Daily OHLCV D6/D7 Dashboard Usage Result (2026-06-14) + +## Verdict + +`PASS` for D6/D7 usage-guide hardening as a read-only evidence surface. + +This is not a trading-readiness, profit, live, broker, order, or deployable-model claim. The Daily OHLCV track remains research-only with `model_build_allowed=false` until D0/D1/D3/D5 gates pass. + +## What changed + +| Area | Result | Guardrail | +|---|---|---| +| D0-D9 progress guide | API now exposes per-page `usage_guide`, `can_do`, `must_not`, and `next_action` fields. | WATCH/NO-GO rows stay visible. | +| D6 visual lab | Dashboard shows a Korean D6/D7 usage panel explaining what can be read, what is forbidden, and the next evidence required. | Visual curves are evidence views, not profit proof. | +| D7 research diagnostics | Feature, regime, correlation/concentration, and failure-analysis cards expose allowed use, blocked use, how to read, and current artifact gap. | `PLACEHOLDER_READY` is not alpha/profit evidence. | +| Metric glossary | Added learning curve, action distribution, portfolio trajectory, and symbol drilldown explanations. | Graphs must be interpreted with controls and gates. | +| Symbol drilldown | Symbol OHLCV chart response and UI expose usage guidance. | Individual symbol view is not buy/sell recommendation. | + +## Current user capabilities + +| Page/table | Users can do | Users must not do | Next evidence | +|---|---|---|---| +| D6 Decision Cockpit | Read D3/D4/D5 lock status, blockers, and model-build lock reasons. | Treat `LOCKED`/`NO-GO` cards as promotion or readiness. | Resolve D0/D1/D3/D5 blockers. | +| D6 Evidence Flow | See D0-D9 dependency order and which stage blocks downstream work. | Skip failed upstream evidence. | Re-run stage checks after upstream fixes. | +| D6 Metric Glossary | Interpret MDD, turnover, shuffle, learning curve, action distribution, portfolio trajectory. | Use one metric or a rising curve as profitability proof. | Compare against no-trade/shuffle/D3 controls. | +| D6 Visual charts | Inspect overlays, heatmap, scatter, universe breakdown, symbol OHLCV preview. | Hide failed folds or call charts trading signals. | Keep provenance hashes and gate labels visible. | +| D7 Research Diagnostics | Plan feature/regime/correlation/failure attribution artifacts. | Use placeholders or explanation views as alpha claims. | Generate `feature_importance_by_fold.csv`, `regime_bucket_metrics.csv`, `correlation_cluster_summary.csv`, `failure_reason_attribution.csv`. | +| Symbol drilldown | Inspect leading-zero code preservation, range, OHLCV bars, and price-basis warning. | Treat a selected symbol as buy/sell guidance. | Confirm price basis before decision-grade returns. | + +## Verification + +| Command / evidence | Result | +|---|---| +| `py -3.11 -m py_compile webui/daily_ohlcv_dashboard.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py` | PASS | +| `py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q` | `30 passed` | +| `cd webui/v2_src && npm run check && npm run build` | PASS, 0 errors; 4 pre-existing warnings in `ForecastWorkbenchTab.svelte`/`DocsTab.svelte` | +| Browser/API check at `/daily-ohlcv` | PASS: D6/D7 usage guide visible, no API error, progress guide count 10, D7 diagnostics guidance present, symbol usage guide visible | +| Screenshot | `webui/rl_runs/daily_ohlcv_visual_lab/visual_lab_2026_06_14_g007_d6_d7_usage/g007_d6_d7_usage_verified.png` | +| API report | `webui/rl_runs/daily_ohlcv_visual_lab/visual_lab_2026_06_14_g007_d6_d7_usage/browser_api_report.json` | + +## Remaining blocked states + +- D0 price basis remains not independently verified. +- D1 universe remains WATCH until official/manual KRX review. +- D3 remains WATCH/research baseline evidence. +- D5 remains NO-GO. +- D8/D9 remain research-only / paper-forward locked. +- No live/broker/orders and no profit claim. diff --git a/docs/stom_daily_ohlcv_d8d9_registry_paper_forward_result_2026-06-13.md b/docs/stom_daily_ohlcv_d8d9_registry_paper_forward_result_2026-06-13.md new file mode 100644 index 000000000..e30713318 --- /dev/null +++ b/docs/stom_daily_ohlcv_d8d9_registry_paper_forward_result_2026-06-13.md @@ -0,0 +1,123 @@ +# STOM Daily OHLCV D8/D9 Registry / Paper-forward Result — 2026-06-13 + +## Verdict + +`RESEARCH_ONLY_BLOCKED` / `NO-GO`. + +D8/D9 records a reproducible daily RL candidate registry and paper-forward planning ledger from the current D4/D5 evidence contract. It remains blocked from model build, paper-forward promotion, live trading, broker integration, and order generation. This is an evidence surface, not a profit claim. + +## Scope + +- Source module: `stom_rl/daily_registry.py` +- Backend dashboard loader/API: `webui/daily_ohlcv_dashboard.py`, `webui/app.py` +- Frontend dashboard surface: `webui/v2_src/src/lib/dailyOhlcvApi.ts`, `webui/v2_src/src/tabs/DailyOhlcvTab.svelte`, `webui/v2_src/src/tabs/dailyOhlcv/DailyVisualLabCard.svelte`, `webui/v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte` +- Tests: `tests/test_stom_rl_daily_registry.py`, `tests/test_daily_ohlcv_dashboard_api.py`, `tests/test_daily_ohlcv_dashboard_tab.py` +- Generated registry run: `webui/rl_runs/daily_ohlcv_registry/registry_2026_06_13_g006_paper_forward/` + +## Generated artifacts + +| Artifact | Purpose | +|---|---| +| `registry_manifest.json` | Registry metadata, run ids, hashes, row counts, effective D0/D1/D3/D4/D5 blockers, guardrails. | +| `candidate_registry.json` | Candidate id, source D4/D5 runs, config/data/code/source hashes, promotion status, lock reasons. | +| `paper_selected.csv` | Paper-only selection list. Current row is blocked, not a trade list. | +| `realized_returns.csv` | Read-only policy NAV-derived realized-return series from generated D4 artifact. | +| `drift.csv` | Price-basis, universe, D5, effective model gate, model-build, and hash drift/status checks. | +| `drawdown.csv` | Paper-forward drawdown series derived from policy NAV. | +| `decision_log.jsonl` | Registry creation, promotion block, and live/broker/order block decision log. | + +## Current registry state + +| Field | Value | +|---|---| +| Run | `registry_2026_06_13_g006_paper_forward` | +| Status | `RESEARCH_ONLY_BLOCKED` | +| Promotion status | `BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER` | +| D4 source | `portfolio_2026_06_13_g003_state_visualization` | +| D5 source | `walk_forward_2026_06_13_g004_state_aware_gate` | +| Cost assumption | 23bp round trip | +| `model_build_allowed` | `false` | +| `paper_forward_allowed` | `false` | +| `live_broker_order_allowed` | `false` | +| `no_live_broker_order_readiness` | `true` | +| Rows | paper selected 1, realized returns 309, drift 8, drawdown 309, decision log 3 | +| Source hashes | 9 tracked source files | + +## Effective gate blockers + +The registry now recomputes an effective cross-stage promotion gate instead of trusting optimistic artifact flags. + +- `D0_PRICE_BASIS_NOT_VERIFIED` +- `D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED` +- `D3_BASELINE_NOT_PROMOTABLE` +- `D4_IMPLEMENTATION_NOT_UNLOCKED` +- `D5_WALK_FORWARD_NOT_PASS` + +## Blocking reasons preserved + +- `D0_PRICE_BASIS_NOT_VERIFIED` +- `D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED` +- `D3_BASELINE_NOT_PROMOTABLE` +- `D4_IMPLEMENTATION_LOCKED_RESEARCH_ONLY` +- `D4_IMPLEMENTATION_NOT_UNLOCKED` +- `D4_OBSERVATION_STATE_MANIFEST_CONSUMED` +- `D4_RL_RESEARCH_ONLY_LOCK` +- `D5_WALK_FORWARD_NOT_PASS` +- `FORWARD_FOLDS_COMPLETE_NO_OOS_RETUNING` +- `MODEL_BUILD_LOCKED_BY_D5_GATE` +- `NO_LIVE_BROKER_ORDER_SURFACE` +- `PRICE_BASIS_UNKNOWN` +- `RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM` +- `RL_POLICY_UNDERPERFORMS_D3_BASELINE` +- `UNIVERSE_WATCH_HEURISTIC` + +## Dashboard behavior + +The Daily OHLCV visual lab includes `data-daily-registry-paper-forward` with: + +- model build, paper-forward, and live/broker/order locks; +- explicit `no_live_broker_order_readiness=true` meaning “explicitly not broker/order ready”; missing or false would be unsafe; +- config/data/code hash display plus source hashes for backend and frontend registry surfaces; +- drift cards for price basis, universe review, D5 status, model-build lock, effective D0/D1/D3/D4/D5 gate, and hashes; +- table rows for blocked paper selections, realized returns, drawdown, and decision log entries; +- explicit no-live/broker/orders and no-profit/no-deployable-readiness guardrail text; +- artifact invariant validation that forces unsafe/stale registry files back to `BLOCKED_UNSAFE_REGISTRY_ARTIFACT`; +- strict numeric evidence handling that marks malformed policy NAV/return/drawdown rows as `BLOCKED_NUMERIC_EVIDENCE` instead of rendering fake zeroes. + +API endpoint: + +```text +GET /api/daily-ohlcv/registry/latest?limit=15 +``` + +The endpoint is read-only and returns generated artifact samples only. + +## Verification + +```powershell +py -3.11 -m py_compile stom_rl/daily_registry.py webui/daily_ohlcv_dashboard.py webui/app.py tests/test_stom_rl_daily_registry.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py +# passed + +py -3.11 -m pytest tests/test_stom_rl_daily_registry.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q +# 26 passed +``` + +```powershell +cd webui/v2_src +npm run check +# 0 errors, 4 pre-existing warnings in ForecastWorkbenchTab.svelte and DocsTab.svelte + +npm run build +# 0 errors, same 4 pre-existing warnings; Vite build passed with bundle-size warning +``` + +Browser/API check: + +- Route: `http://127.0.0.1:58198/daily-ohlcv` +- Result: `data-daily-registry-paper-forward`, D0-D9 progress, D7 diagnostics, `NO-GO`, `RESEARCH_ONLY`, `model_build_allowed=false`, `no_live_broker_order_readiness`, and all D0/D1/D3/D4/D5 effective blocker text were visible. +- API result: `/api/daily-ohlcv/registry/latest` 200, `/api/daily-ohlcv/progress` 200, `/api/daily-ohlcv/charts/decision-cockpit` 200, unsafe `run=../bad` 400, registry POST 405. +- Evidence artifacts: `browser_verification.json`, `registry_api_snapshot.json`, `g006_registry_dashboard_final.png`, `verification_transcript.txt`, `adversarial_report.txt`, `cleanup_report.txt` under `webui/rl_runs/daily_ohlcv_registry/registry_2026_06_13_g006_paper_forward/`. + +## Interpretation + +This completes the registry/paper-forward planning surface required before any future daily RL candidate can be tracked reproducibly. It does **not** unlock model build or trading. The current candidate remains blocked because the price basis is unknown, the universe is still heuristic/WATCH, the D4 policy underperforms frozen D3 baselines, D4 implementation remains research-only, and D5 remains `NO-GO`. diff --git a/docs/stom_daily_ohlcv_d8d9_registry_paper_forward_result_2026-06-14.md b/docs/stom_daily_ohlcv_d8d9_registry_paper_forward_result_2026-06-14.md new file mode 100644 index 000000000..a8e0bfa98 --- /dev/null +++ b/docs/stom_daily_ohlcv_d8d9_registry_paper_forward_result_2026-06-14.md @@ -0,0 +1,71 @@ +# Daily OHLCV D8/D9 Registry / Paper-forward Result (2026-06-14) + +## Verdict + +`PASS` for D8/D9 registry and paper-forward evidence hardening as a research-only, fail-closed surface. + +The current registry result is still `RESEARCH_ONLY_BLOCKED` / `BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER`. This is not a model-build, paper-forward promotion, profit, live, broker, order, or deployable-readiness claim. + +## Generated run + +| Field | Value | +|---|---| +| Run | `registry_2026_06_14_g008_paper_forward` | +| Artifact root | `webui/rl_runs/daily_ohlcv_registry/registry_2026_06_14_g008_paper_forward/` | +| D4 source | `portfolio_2026_06_14_g005_d4_visualization` | +| D5 source | `walk_forward_2026_06_14_g006_d5_fresh_oos_gate` | +| Cost assumption | `23bp` round trip | +| Status | `RESEARCH_ONLY_BLOCKED` | +| Promotion status | `BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER` | +| model_build_allowed | `false` | +| paper_forward_allowed | `false` | +| live_broker_order_allowed | `false` | +| no_live_broker_order_readiness | `true` | + +## Effective blockers + +| Blocker | Meaning | +|---|---| +| `D0_PRICE_BASIS_NOT_VERIFIED` | price_basis remains unverified/unknown. | +| `D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED` | KRX/manual universe validation is not complete. | +| `D3_BASELINE_NOT_PROMOTABLE` | D3 baseline is not promotable to model build. | +| `D4_IMPLEMENTATION_NOT_UNLOCKED` | D4 RL remains research-only. | +| `D5_WALK_FORWARD_NOT_PASS` | D5 fresh OOS/walk-forward gate is NO-GO. | + +## Artifact contract + +| Artifact | Purpose | Guardrail | +|---|---|---| +| `registry_manifest.json` | Registry status, hashes, row counts, effective blockers, source paths. | False flags and blocker labels are first-class evidence. | +| `candidate_registry.json` | Candidate row with config/data/code/source hashes and promotion status. | Candidate cannot enable paper/live/order while gates block. | +| `paper_selected.csv` | Blocked paper-selection row. | `BLOCKED_BY_D5_NO_GO`, not orders. | +| `realized_returns.csv` | Research policy NAV-derived returns. | Source is policy artifact, not live account. | +| `drawdown.csv` | Research policy NAV drawdown rows. | Source is `research_policy_nav_not_live_account`. | +| `drift.csv` | price basis, universe, D5, model gate, and hash drift rows. | Used to compare future continuations before any paper-forward run. | +| `decision_log.jsonl` | Registry creation, promotion-status, live-broker-order-blocked events. | Audit trail, no side effects. | + +## Dashboard/API changes + +| Surface | Result | +|---|---| +| `/api/daily-ohlcv/registry/latest` | Exposes effective blockers, invariant errors, hashes, drift, drawdown, decision logs, and read-only note. | +| D8/D9 visual card | Shows effective gate blockers, invariant errors, drawdown rows, source labels, and no-live/broker/order cards. | +| Progress D8/D9 | D8 remains `RESEARCH_ONLY_BLOCKED`; D9 remains `BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER`. | +| Unsafe artifact handling | Optimistic, malformed JSON/JSONL, wrong-column/invalid CSV evidence, missing/empty evidence, or non-canonical D0/D1 generated registry artifacts fail closed to `BLOCKED_UNSAFE_REGISTRY_ARTIFACT`. | + +## Verification + +| Evidence | Result | +|---|---| +| `py -3.11 -m py_compile stom_rl/daily_registry.py webui/daily_ohlcv_dashboard.py tests/test_stom_rl_daily_registry.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py` | PASS | +| `py -3.11 -m pytest tests/test_stom_rl_daily_registry.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q` | `43 passed` | +| `cd webui/v2_src && npm run check && npm run build` | PASS, 0 errors; 4 pre-existing warnings in `ForecastWorkbenchTab.svelte`/`DocsTab.svelte` | +| Browser/API check at `/daily-ohlcv` | PASS: registry blocked status, false flags, 64-char hashes, effective blockers, paper-selected block, drawdown rows, visual effective-gate panel, no order guardrail. | +| Registry API direct load | PASS: `invariant_errors=[]`, row evidence present for paper-selected/returns/drift/drawdown/decision-log, D0/D1/D3/D4/D5 effective blockers preserved. | +| Browser/API report | `webui/rl_runs/daily_ohlcv_registry/registry_2026_06_14_g008_paper_forward/browser_api_report.json` | +| API snapshot | `webui/rl_runs/daily_ohlcv_registry/registry_2026_06_14_g008_paper_forward/registry_api_snapshot.json` | +| Screenshot | `webui/rl_runs/daily_ohlcv_registry/registry_2026_06_14_g008_paper_forward/g008_registry_dashboard_verified.png` | + +## Interpretation + +D8/D9 now gives a reproducible blocked registry/paper-forward ledger for the current D0-D5 evidence chain. It is useful for audit, drift/hash comparison, and future continuation planning. It does not authorize model build, paper-forward promotion, live broker readiness, or orders. diff --git a/docs/stom_daily_ohlcv_dashboard_first_research_platform_adr_2026-06-18.md b/docs/stom_daily_ohlcv_dashboard_first_research_platform_adr_2026-06-18.md new file mode 100644 index 000000000..ad8fef0bc --- /dev/null +++ b/docs/stom_daily_ohlcv_dashboard_first_research_platform_adr_2026-06-18.md @@ -0,0 +1,166 @@ +# Daily OHLCV Dashboard-first Research Platform ADR — 2026-06-18 + +Date: 2026-06-18 UTC +Status: `ACCEPTED_FOR_RESEARCH_PLATFORM_EXECUTION` / implementation guardrail `RESEARCH_ONLY` +Decision scope: Kronos Dashboard workflow UX, research job-intent contract, CLI demotion, and no-live product boundary +Related plan: `.gjc/plans/ralplan/2026-06-11-0158-38ea/stage-357-final.md` / `.gjc/plans/ralplan/2026-06-11-0158-38ea/pending-approval.md` + +## Decision + +Kronos Daily OHLCV/RL work will continue as a **dashboard-first research and verification platform**, not a live-trading product. + +The accepted implementation direction is: + +1. Remove 실거래/live trading from the platform goal. +2. Keep broker integration, order submission, live account connectivity, paper-forward unlock, model-build unlock, and profit claims out of scope. +3. Make `Kronos Dashboard` / `Kronos 대시보드` the primary user surface for seeing, inspecting, configuring, requesting, and monitoring research-only workflows. +4. Treat CLI commands as backend/internal provenance or maintainer/admin detail only, not the public user workflow. +5. Allow dashboard POST behavior only for immutable, approval-linked research **job-intent records**; POST routes must not execute arbitrary shell, spawn workers synchronously, submit orders, unlock paper-forward, or start model builds. +6. Add a falsifiable hypothesis rejection / early-dropout audit lane to test whether research hypotheses are rejected too often or too early. + +## Non-goals + +| Excluded goal | Status | +|---|---| +| Live trading | Removed from goal set | +| Broker/order integration | Forbidden | +| Paper-forward unlock | Forbidden until a future fresh gate explicitly changes it | +| Model-build unlock | Forbidden until a future fresh gate explicitly changes it | +| Profitability claim | Forbidden | +| Arbitrary CLI/shell execution from dashboard | Forbidden | +| Reversing old `NO-GO` verdicts by dashboard presentation | Forbidden | + +## Decision drivers + +1. **User-facing workflow must be dashboard-first.** Users should not need to copy CLI commands to understand or request research workflows. +2. **Governance locks must remain fail-closed.** D0 price basis, D1 universe, D5 NO-GO, model/paper/live locks, 23bp cost, and 0/23/46bp sensitivity remain visible and enforced. +3. **Research quality must be measured, not marketed.** Rejection/false-negative analytics can improve hypothesis triage, but cannot promote models without new preregistered gates. + +## Job-intent contract v1 + +A job intent is an immutable generated record under `webui/rl_runs/` or `artifacts/`. It is a request to run a research workflow after approval validation; it is not the run itself. + +### Allowed workflow ids + +Initial allowlist: + +- `D0_D1_DATA_GOVERNANCE_REVIEW` +- `D3_D4_SIGNAL_QUALITY_AUDIT` +- `PAST_ONLY_MARKET_REGIME_AUDIT` +- `D4_RL_OVERLAY_ABLATION` +- `SCENARIO_BATCH_RESEARCH_ONLY` +- `HYPOTHESIS_REJECTION_AUDIT` + +Unknown workflow ids must fail closed. + +### Required fields + +| Field | Requirement | +|---|---| +| `schema_version` | `daily_ohlcv_research_job_intent.v1` | +| `intent_id` | Safe generated id | +| `workflow_id` | One of the allowlisted ids | +| `approval_ref` | Dated prereg doc, approved `.gjc` plan/goal reference, or approved dashboard approval record | +| `approval_ref_sha256` | Hash of the approval reference | +| `requested_by` | Requesting local/operator identity | +| `requested_at_utc` | UTC timestamp | +| `idempotency_key` | Duplicate-control key | +| `plan_hash` | Hash of submitted plan/config preview | +| `config_hash` | Hash of normalized config | +| `source_governance_index_path` | Governance index in force | +| `default_cost_bp` | `23` | +| `cost_sensitivity_bp` | `[0, 23, 46]` | +| `baseline_controls` | Includes no-trade, shuffle, frozen-D3 controls where applicable | +| `no_retune` | `true` | +| `model_build_allowed` | `false` | +| `paper_forward_allowed` | `false` | +| `live_broker_order_allowed` | `false` | +| `artifact_root` | `webui/rl_runs` or `artifacts` only | +| `status` | Initial `INTENT_RECORDED` | +| `guardrails` | Research-only, no live/broker/orders, no profit claims, no paper/model unlock, no arbitrary shell | + +### Forbidden fields + +A dashboard/API request must reject fields named or semantically equivalent to: + +`command`, `shell`, `argv`, `cwd`, `env`, `broker`, `account`, `order`, `symbol_order`, `live`, `paper_forward_unlock`, `model_build_unlock`, `profit_target`, `arbitrary_path`. + +### Idempotency + +- Same `idempotency_key` + same `plan_hash` + same `config_hash` returns the existing intent. +- Same `idempotency_key` with different `plan_hash` or `config_hash` fails with conflict. +- Duplicate intents must not create duplicate worker execution. + +### Lifecycle + +Allowed statuses: + +```text +INTENT_RECORDED -> VALIDATED -> QUEUED -> RUNNING -> COMPLETED_RESEARCH_ONLY +INTENT_RECORDED -> VALIDATION_FAILED +QUEUED/RUNNING -> FAILED_RESEARCH_ONLY +QUEUED -> CANCELLED_BEFORE_RUN +``` + +No transition may set model, paper, live, broker/order, or profit readiness to true. + +### POST guarantee + +The POST route may write or return an intent record only. It must not: + +- run arbitrary shell, +- spawn a worker synchronously, +- submit broker/order/live actions, +- unlock paper-forward, +- start model-build, +- create profit claims. + +## CLI demotion rule + +Existing CLI/quick-start command text may remain only as collapsed backend provenance or maintainer/admin detail. The public user workflow must be: + +```text +workflow catalog -> inspector -> safe config -> approval blocker or intent creation -> ledger -> artifact review +``` + +## Hypothesis rejection / early-dropout contract + +The platform must measure whether hypotheses are rejected too often or too early with a falsifiable audit, not with hindsight selection. + +Required generated artifacts: + +- `gate_funnel_metrics.csv/json` +- `rejection_reason_taxonomy.csv/json` +- `calibration_metrics.csv` +- `threshold_sensitivity.csv` +- `false_negative_candidates.csv` +- `audit_manifest.json` + +False-negative candidates are review-only. They cannot reverse `NO-GO`, cannot unlock model/paper/live, and require a new preregistered hypothesis before any follow-up research. + +## Consequences + +| Consequence | Effect | +|---|---| +| Dashboard can become more interactive | Only through safe configs and job-intent records | +| CLI remains usable internally | But no longer the public workflow path | +| More generated evidence | Must remain under `webui/rl_runs/` or `artifacts/` | +| More docs | Durable decisions/prereg/results stay under `docs/` | +| Rejection analysis becomes possible | But cannot be used for promotion without new gates | +| Live readiness remains 0% | By design | + +## Verification requirements for implementation + +Future implementation must prove: + +1. Public UI exposes dashboard-first workflow controls without CLI-first command copy. +2. Job-intent API rejects forbidden command/live/broker/order/model/paper fields. +3. Path traversal and unsafe artifact roots fail closed. +4. Missing/stale approval fails closed. +5. Idempotency behavior is deterministic. +6. All payloads preserve `model_build_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false`. +7. Rejection audit artifacts include denominator, timing, independent-evidence, and review-only fields. + +## Current status after this ADR + +This ADR freezes the decision and contract. It does **not** report a trading result, does **not** claim profitability, and does **not** unlock D5/model-build/paper-forward/live trading. diff --git a/docs/stom_daily_ohlcv_dashboard_first_research_platform_completion_result_2026-06-18.md b/docs/stom_daily_ohlcv_dashboard_first_research_platform_completion_result_2026-06-18.md new file mode 100644 index 000000000..2897dd9ce --- /dev/null +++ b/docs/stom_daily_ohlcv_dashboard_first_research_platform_completion_result_2026-06-18.md @@ -0,0 +1,93 @@ +# Daily OHLCV Dashboard-First Research Platform Completion Result — 2026-06-18 + +Date: 2026-06-18 UTC +Status: `IMPLEMENTED_RESEARCH_ONLY_DASHBOARD_PLATFORM` +Scope: Daily OHLCV research/verification dashboard, workflow inspection, approval-gated research intents, rejection analytics, and completion reporting +Default cost assumption: 23bp round trip +Live trading objective: removed / explicitly out of scope + +## Executive verdict + +The non-live dashboard-first research platform objective is implemented for the approved scope: `100%` for dashboard/research verification surfaces and `0%` for live trading, model-build, and paper-forward readiness. + +This is **not** a live-trading, broker-order, paper-trading, model-production, or profitability result. It is a research evidence viewer and workflow/guardrail platform that lets a user inspect what exists, what is blocked, what can be reviewed, and what cannot be unlocked from the browser. + +## Completion scorecard + +| Surface | Status | Evidence | +|---|---:|---| +| Workflow center | `100%` | `GET /api/daily-ohlcv/research-workflows`, dashboard marker `data-daily-rl-workflow-center` | +| Workflow inspector / safe config preview | `100%` | `GET /api/daily-ohlcv/research-workflows/`, marker `data-daily-rl-workflow-inspector`, marker `data-daily-rl-workflow-safe-config-preview` | +| Approval-gated immutable intent ledger | `100%` | `POST /api/daily-ohlcv/research-workflows//job-intents`, `GET /api/daily-ohlcv/research-jobs`, marker `data-daily-rl-intent-ledger` | +| Hypothesis rejection / early-dropout analytics | `100%` | `GET /api/daily-ohlcv/rejection-analytics`, marker `data-daily-rl-rejection-analytics` | +| Final dashboard completion report | `100%` | `dashboard_first_completion_report` in `GET /api/daily-ohlcv/rl-env-guide`, marker `data-daily-rl-final-completion-report` | +| Durable governance docs | `100%` | ADR, preregistration, this result, and governance index | +| Live trading readiness | `0%` | Explicit locks: no broker/order/account/live execution | +| Model-build readiness | `0%` | Existing D5/model gates remain `NO-GO` / blocked | +| Paper-forward readiness | `0%` | Explicit lock: paper-forward remains blocked | + +## What the platform can do now + +| Capability | Current behavior | User-facing proof | +|---|---|---| +| See research workflows | Shows six research workflows, statuses, blockers, prerequisites, artifacts, and next actions. | Daily RL Guide workflow center | +| Inspect a workflow safely | Displays blocker lists, artifact dependencies, guardrails, approval requirements, and a safe config preview. | Workflow inspector panel | +| Prepare a research intent without execution | Creates immutable `intent.json` records only after approval hash/idempotency validation. | Approval trigger surface + intent ledger | +| Reject unsafe request fields | Rejects command/shell/env/cwd/broker/account/order/live/paper/model/arbitrary path fields. | API tests for forbidden fields and fail-closed behavior | +| Track generated research intents | Shows intent count, approval status, workflow id, config hash, and immutable artifact path. | Job/artifact ledger panel | +| Review over-rejection and early dropout | Shows gate funnel metrics, rejection taxonomy, calibration, threshold sensitivity, and false-negative candidates. | Rejection analytics panel | +| Keep false-negative candidates review-only | Candidates remain `REVIEW_ONLY`, `promotion_allowed=false`, and require new preregistration. | API response + dashboard guardrail | +| Report completion honestly | Shows non-live completion at `100%`, while live/model/paper remain `0%`. | Final completion panel | + +## What remains blocked + +| Blocked item | Status | Reason | +|---|---|---| +| Live trading / broker orders | `0%`, blocked | Removed from product goal; no broker/order/account/live route is allowed. | +| Paper-forward unlock | `0%`, blocked | D5 and model-readiness gates remain `NO-GO`; browser cannot unlock paper behavior. | +| Model-build unlock | `0%`, blocked | Existing Daily OHLCV evidence remains research-only and does not pass promotion gates. | +| Profitability claim | blocked | Dashboard visuals are evidence/diagnostics only, not profit proof. | +| Arbitrary browser shell execution | blocked | POST creates intent records only; it does not execute shell, spawn workers, or start model/paper/live behavior. | + +## Implementation summary + +| Area | Files / routes | Result | +|---|---|---| +| Backend guide integration | `webui/daily_ohlcv_dashboard.py` | Adds `dashboard_first_completion_report` to `load_daily_rl_env_guide()` with completion percentages, locks, completed surfaces, can/cannot lists, source docs, and guardrail text. | +| Flask routes | `webui/app.py` | Workflow catalog/detail, approval-gated intent creation, intent ledger/detail, and rejection analytics routes remain integrated. | +| Frontend API contract | `webui/v2_src/src/lib/dailyOhlcvApi.ts` | Adds typed `dashboard_first_completion_report` field to the Daily RL Guide response. | +| Frontend UI | `webui/v2_src/src/tabs/DailyRlGuideTab.svelte` | Adds `data-daily-rl-final-completion-report` panel with 100% non-live completion and 0% live/model/paper readiness. | +| API regression tests | `tests/test_daily_ohlcv_dashboard_api.py` | Asserts final completion report schema, 100% non-live completion, 0% live/model/paper readiness, and lock flags. | +| UI/source marker tests | `tests/test_daily_ohlcv_dashboard_tab.py` | Asserts final completion marker, status token, percentage fields, and Korean guardrail text. | +| Docs/governance | `docs/stom_daily_ohlcv_dashboard_first_research_platform_completion_result_2026-06-18.md`, governance index | Freezes final research-only completion result and keeps live/model/paper blocked. | + +## Durable docs and generated evidence + +| Artifact | Path | Meaning | +|---|---|---| +| Dashboard-first ADR | `docs/stom_daily_ohlcv_dashboard_first_research_platform_adr_2026-06-18.md` | Freezes no-live, dashboard-first, CLI-internal, approval-gated intent-record-only contract. | +| Rejection audit preregistration | `docs/stom_daily_ohlcv_hypothesis_rejection_audit_prereg_2026-06-18.md` | Freezes falsifiable gate-funnel, early-dropout, false-negative review-only analytics contract. | +| Rejection analytics run | `webui/rl_runs/daily_ohlcv_rejection_audit/hypothesis_rejection_audit_2026_06_18_001/audit_manifest.json` | Generated artifact manifest with hashes, row counts, guardrails, and locks false. | +| Follow-up evidence manifest | `webui/rl_runs/daily_ohlcv_rejection_audit/hypothesis_rejection_audit_2026_06_18_001/follow_up_review_evidence_manifest.json` | Separately hashed follow-up evidence for false-negative review-only candidate. | +| Final completion browser screenshot | `artifacts/dashboard_first_g005_completion_report.png` | Non-uniform screenshot of the final completion panel. | +| Final full-page browser screenshot | `artifacts/dashboard_first_g005_full_page.png` | Full dashboard page evidence including the final completion report. | + +## Verification + +| Check | Command / surface | Result | +|---|---|---| +| Focused backend/frontend regression | `py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py tests/test_v2_route.py -q` | `55 passed in 10.39s` | +| Frontend build/check | `npm run build` in `webui/v2_src` | `0 errors`, `4 existing warnings` in unrelated `ForecastWorkbenchTab.svelte` / `DocsTab.svelte`; Vite build succeeded. | +| Browser evidence | `/daily-rl-guide?verify=g005-completion-report` | Final completion panel visible; `NON_LIVE_RESEARCH_PLATFORM_COMPLETE`, `100%` non-live, and `0%` live/model/paper verified. | + +## Answer to the platform question + +Yes: within the research-only scope, this is now a dashboard-centered platform for inspecting multiple RL/rule/research experiments, blockers, gate outcomes, generated artifacts, workflow prerequisites, approval-gated research intents, and hypothesis rejection/early-dropout behavior. + +No: it is not a platform for live trading, live broker orders, browser-started workers, paper-forward deployment, model promotion, or profit claims. Those surfaces deliberately remain locked at `0%` readiness. + +## Final verdict + +`IMPLEMENTED_RESEARCH_ONLY_DASHBOARD_PLATFORM` + +The approved non-live, user-friendly, visual, dashboard-first research verification objective is complete for the scoped functionality. The excluded live-trading objective remains excluded and blocked. \ No newline at end of file diff --git a/docs/stom_daily_ohlcv_dashboard_scenario_generator_result_2026-06-18.md b/docs/stom_daily_ohlcv_dashboard_scenario_generator_result_2026-06-18.md new file mode 100644 index 000000000..3bbcc23d7 --- /dev/null +++ b/docs/stom_daily_ohlcv_dashboard_scenario_generator_result_2026-06-18.md @@ -0,0 +1,94 @@ +# Daily OHLCV Dashboard Scenario Generator Result — 2026-06-18 + +Date: 2026-06-18 UTC +Status: `IMPLEMENTED_RESEARCH_ONLY_DASHBOARD` / promotion `NO-GO_RESEARCH_ONLY` +Scope: Daily RL Guide dashboard usability and research-governance visibility +Default cost: 23bp round trip; displayed scenario drafts retain 0/23/46bp sensitivity. +Guardrails: no live trading, no broker/orders, no profit claims, no paper-forward/model-build unlock. + +## Verdict + +The Daily RL Guide now supports the requested priority 1-5 dashboard roadmap as a read-only research platform surface: + +1. Dashboard Scenario Generator, +2. Signal-quality result integration, +3. Past-only market-regime audit readiness, +4. AI-readable improvement queue, +5. Scenario comparison and page maturity reporting. + +This is a dashboard/research-governance improvement, not a trading-result improvement. It makes assumptions, scenario drafts, limitations, artifacts, and next actions easier to inspect. It does **not** execute scenarios from the browser and does **not** unlock D5/model-build/paper-forward/live trading. + +## What changed + +| Priority | Dashboard feature | Status | Numeric completion | +|---:|---|---|---:| +| 1 | Read-only scenario generator with fixed JSON plan drafts for D3/D4 signal quality, market-regime data quality, and D4 RL overlay ablation | `IMPLEMENTED_READ_ONLY` | 100% | +| 2 | Latest 2026-06-18 D3/D4 signal-quality audit summary with row counts, cost sensitivity, baselines, artifact links, and limitations | `IMPLEMENTED_ARTIFACT_BACKED` | 100% | +| 3 | Past-only market-regime audit readiness section with required inputs, blocked gates, readiness checks, and AI-readable guidance | `IMPLEMENTED_AS_READINESS` | 100% | +| 4 | AI-readable improvement queue mapping each limitation to next action, artifacts, acceptance gates, and blockers | `IMPLEMENTED_FIXED_FORMAT` | 100% | +| 5 | Scenario comparison cards plus numeric page/research/live readiness maturity report | `IMPLEMENTED_NUMERIC` | 100% | + +## Maturity metrics exposed on the page + +| Metric | Value | Meaning | +|---|---:|---| +| Implementation completion | 100% | Requested priority 1-5 dashboard features are present. | +| Page maturity | 88% | The page is usable as a research-process viewer and scenario-planning surface. | +| Scenario-platform maturity | 86% | Scenario draft/comparison/artifact linkage is strong, but browser execution remains intentionally disabled. | +| Research readiness | 74% | Enough evidence exists to plan the next diagnostic research, not enough for promotion. | +| Data-governance maturity | 72% | Signal-quality provenance is visible, but D0 price-basis and D1 universe blockers remain. | +| Live-trading readiness | 0% | Live/broker/order/paper-forward/model-build remain blocked by design. | + +These values are not hand-scored trading claims. The API derives them from explicit `page_maturity_report.score_inputs`: priority feature evidence, signal-quality artifact availability, scenario batch completion, market-regime readiness checks, and NO-GO caps. The 86% scenario-platform and 74% research-readiness caps remain active because promotion is still `NO-GO_RESEARCH_ONLY`; live/model/paper readiness is therefore forced to 0%. + +## Dashboard API payloads + +The `/api/daily-ohlcv/rl-env-guide` response now includes: + +- `scenario_generator`, +- `signal_quality_audit_summary`, +- `market_regime_audit_readiness`, +- `improvement_queue`, +- `scenario_comparison`, +- `page_maturity_report`. + +The frontend renders these under `/daily-rl-guide` with stable markers: + +- `data-daily-rl-scenario-generator`, +- `data-daily-rl-signal-quality-integration`, +- `data-daily-rl-market-regime-readiness`, +- `data-daily-rl-improvement-queue`, +- `data-daily-rl-page-maturity-report`. + +## Artifact-backed signal-quality data shown + +| Evidence | Value | +|---|---| +| Latest run | `signal_quality_audit_2026_06_18_001` | +| Run manifest | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/signal_quality_manifest.json` | +| Batch manifest | `webui/rl_runs/daily_ohlcv_signal_quality_batches/scenario_batch_signal_quality_audit_001/scenario_batch_manifest.json` | +| Scenario count | 5 | +| Completed / failed | 5 / 0 | +| Gate counts | `WATCH: 5` | +| Promotion | `NO-GO_RESEARCH_ONLY` | +| Row counts | predictions 872, bucket metrics 204, rank correlations 7, risk proxy metrics 219, baseline controls 84, leakage audit 7 | + +## Limitations + +1. Browser scenario generation is intentionally draft-only; execution must stay in preregistered CLI/batch workflows. +2. Page maturity is not trading readiness. Live trading readiness remains 0%. +3. The market-regime section is readiness for the next preregistration, not completed market-regime validation. +4. D0 price basis, D1 universe, and D5 NO-GO blockers still prevent promotion. +5. The dashboard can guide AI agents, but AI must still use dated docs, manifests, and exact commands before running new research. + +## Next recommended research + +Create and execute a preregistered **past-only market-regime data quality audit**. The audit should validate adjusted/raw/split/dividend price basis, universe breadth/missingness, and stable past-only volatility/drawdown/breadth/dispersion proxies before any D4 overlay tuning. + +## Data governance notes + +- Durable decision: this document under `docs/`. +- Generated evidence remains under `webui/rl_runs/`. +- Dashboard payload remains read-only. +- Scenario drafts include 23bp default cost, 0/23/46bp sensitivity, baseline controls, no-retune/no-live guardrails, and blocked promotion flags. +- Leading-zero stock-code handling is unchanged. diff --git a/docs/stom_daily_ohlcv_db_analysis_and_page_plan_2026-06-11.md b/docs/stom_daily_ohlcv_db_analysis_and_page_plan_2026-06-11.md new file mode 100644 index 000000000..f63422fc6 --- /dev/null +++ b/docs/stom_daily_ohlcv_db_analysis_and_page_plan_2026-06-11.md @@ -0,0 +1,339 @@ +# STOM Daily OHLCV DB Analysis and Development Page Plan (2026-06-11) + +## Status + +Planning / pending execution approval. + +User-provided DB: + +```text +_database/Stock_Database_ohlcv_1day.db +``` + +Supporting metadata DB inspected: + +```text +_database/stock_tick_back.db:stockinfo +``` + +This document is research/planning only. It does not claim profitability, live/broker/order readiness, or completed RL model creation. + +## 1. DB inspection summary + +### 1.1 Daily OHLCV DB shape + +| Item | Observed value | +|---|---:| +| SQLite tables | 4,727 | +| `A` tables | 4,166 | +| numeric `A######` tables | 3,823 | +| alphanumeric `A*` tables | 343 | +| `Q` tables | 561 | +| total OHLCV rows | 14,691,020 | +| min row count/table | 3 | +| median row count/table | 1,966 | +| max row count/table | 10,488 | +| global first date | 1986-04-15 | +| global latest date | 2026-06-12 | +| tables reaching latest date | 4,287 | + +All inspected tables share one schema: + +| Column | Type | Meaning | +|---|---|---| +| `date` | INTEGER | YYYYMMDD trading date | +| `open` | INTEGER | daily open | +| `high` | INTEGER | daily high | +| `low` | INTEGER | daily low | +| `close` | INTEGER | daily close | +| `volume` | INTEGER | daily volume | +| `상장주식수` | INTEGER | listed shares | +| `외국인주문한도수량` | INTEGER | foreign order limit qty | +| `외국인현보유수량` | INTEGER | foreign holding qty | +| `외국인현보유비율` | REAL | foreign holding ratio | +| `기관순매수` | INTEGER | institutional net buy | +| `기관누적순매수` | INTEGER | cumulative institutional net buy | + +### 1.2 Sample table + +`A005930` sample starts at `19860415` and has 10,488 rows. It includes OHLCV plus foreign/institutional columns, which makes daily prediction/ranking richer than plain OHLCV. + +### 1.3 Metadata availability + +The daily DB itself has only per-symbol OHLCV tables. Market/name metadata was found in `_database/stock_tick_back.db:stockinfo`: + +| Column | Type | Meaning | +|---|---|---| +| `index` | TEXT | stock/product code without `A`/`Q` prefix | +| `종목명` | TEXT | Korean instrument name | +| `코스닥` | INTEGER | `1` = KOSDAQ, `0` = non-KOSDAQ/KOSPI-side metadata | + +Join result between daily table names and `stockinfo`: + +| Item | Count | +|---|---:| +| daily tables matched to `stockinfo` | 4,229 | +| daily tables not matched | 498 | + +Unmatched examples: `A000075`, `A000885`, `A001140`, `A003410`, `A003560`, `A005390`, `A006390`, `A010420`, `A010600`, `A010620`. + +These unmatched instruments must be quarantined until name/market metadata is resolved. + +### 1.4 Data quality flags + +Observed issues that the DB Analysis tab must expose: + +| Check | Observation | +|---|---| +| Null core OHLCV values | none found in quick aggregate scan | +| zero/non-positive OHLC examples | `A004360`, `A006280` each had 1 flagged row | +| OHLC consistency issues | examples include `A000050`, `A000100`, `A000155`, `A000220`, `A000440` | +| very short histories | examples include `A0017J0` 3 rows, `A0189Z0` 4 rows | + +These are not fatal, but they must be visible and excluded from training windows when invalid. + +## 2. Universe classification requirement + +Goal: train/evaluate only KOSPI/KOSDAQ equity symbols, excluding ETF/ETN and similar listed products. + +### 2.1 Classification sources + +Use layered metadata, not table-name guessing alone: + +1. Daily DB table name: `A######`, `A*`, `Q*`. +2. `stock_tick_back.db:stockinfo` for `종목명` and `코스닥`. +3. Optional later: KRX official listed-product metadata export for exact ETF/ETN/SPAC/REIT/common/preferred type. + +### 2.2 Proposed instrument taxonomy + +| `instrument_type` | Include by default? | Rule | +|---|---:|---| +| `common_equity` | yes | matched metadata, normal numeric code, not product/SPAC/REIT/preferred | +| `preferred_stock` | no by default, optional | names ending with common preferred markers like `우`, `우B`, `우C`, alphanumeric preferred codes | +| `ETF` / `ETN` / `fund_product` | no | `Q*`, KODEX/TIGER/RISE/ACE/SOL/HANARO/ARIRANG/KOSEF/KBSTAR/TIMEFOLIO/TREX/HK/PLUS prefix, ETN/ETF/futures/bond/covered-call names | +| `SPAC` | no | name contains `스팩` / `SPAC` | +| `REIT` | no by default, optional | exact REIT classification; avoid false positives like `메리츠` by using official metadata or strict suffix rules | +| `unknown_unmatched` | no | not found in metadata | + +### 2.3 Market classification + +| `market` | Rule | +|---|---| +| `KOSDAQ` | `stockinfo.코스닥 == 1` | +| `KOSPI` | `stockinfo.코스닥 == 0` and type is allowed equity | +| `UNKNOWN` | no metadata match | + +The first implementation should create a generated universe manifest, not mutate either DB. + +Expected artifact: + +```text +webui/rl_runs/daily_ohlcv_universe/universe_/universe.json +webui/rl_runs/daily_ohlcv_universe/universe_/symbols.csv +webui/rl_runs/daily_ohlcv_universe/universe_/exclusions.csv +``` + +## 3. Development pages / dashboard plan + +### Page D0 — Daily DB Analysis tab + +Purpose: prove the DB is usable before model development. + +| Section | Required fields | +|---|---| +| DB overview | path, table count, total rows, date range, latest coverage | +| Schema card | columns, types, one-schema or multi-schema status | +| Market/product counts | KOSPI/KOSDAQ/common/preferred/ETF/ETN/SPAC/REIT/unknown | +| Data quality | nulls, non-positive OHLC, OHLC consistency, short histories | +| Symbol drilldown | search by code/name, row count, date min/max, sample rows | +| Export buttons | read-only generated artifact links only | + +Backend/API target: + +```text +webui/daily_ohlcv_dashboard.py +/api/daily-ohlcv/db-summary +/api/daily-ohlcv/symbol/ +``` + +Frontend target: + +```text +webui/v2_src/src/tabs/DailyOhlcvDbTab.svelte +``` + +### Page D1 — Daily Universe Management tab + +Purpose: manage which symbols can enter training/evaluation without changing source DB. + +| Feature | Rule | +|---|---| +| Exclusion rules | ETF/ETN/fund/SPAC/REIT/preferred/unknown toggle | +| Market filters | KOSPI, KOSDAQ, both | +| Minimum history | e.g. >= 252 / 756 / 1260 trading days | +| Liquidity filters | volume/amount rolling median thresholds | +| Data-quality filters | invalid OHLC rows, stale symbols, latest-date coverage | +| Manifest generation | writes generated universe artifact under `webui/rl_runs`, not DB | + +Backend/API target: + +```text +stom_rl/daily_ohlcv_universe.py +webui/daily_ohlcv_dashboard.py +/api/daily-ohlcv/universe/preview +/api/daily-ohlcv/universe/manifests +``` + +### Page D2 — Daily Dataset Builder page + +Purpose: turn raw daily tables into model-ready supervised/RL datasets. + +| Dataset | Target | +|---|---| +| Supervised ranker | symbol/date features and `ret_1d`, `ret_3d`, `ret_5d`, `ret_20d` targets | +| Portfolio RL | per-date top-K candidate panel + next-day return path | +| Regime model | index/breadth/volatility daily features | + +Rules: + +- split by date, not random rows; +- no future target in normalization; +- preserve stock codes as strings; +- record all feature/target definitions. + +### Page D3 — Daily Prediction / Top-K page + +Purpose: daily OHLCV deep learning without RL first. + +Recommended first models: + +| Model | Why | +|---|---| +| simple momentum/volatility baselines | hard to beat, easy to audit | +| supervised classifier | next-N-day positive after cost | +| supervised ranker | Top-K swing candidate selection | +| Kronos-style sequence predictor | use OHLCV/time sequence if daily CSV is built | + +Acceptance: must beat no-trade, equal-weight Top-K momentum, and volatility-adjusted baseline after costs and drawdown. + +### Page D4 — Daily Portfolio RL page + +Purpose: only after D3 passes, train constrained daily RL. + +Recommended action contract: + +| Action | Meaning | +|---|---| +| `0` | hold/no rebalance | +| `1..K` | buy/add selected candidate slot | +| `K+1..K+M` | sell/reduce holding slot | + +Reward: + +```text +reward = daily_nav_return + - turnover_cost + - drawdown_penalty + - concentration_penalty + - invalid_action_penalty +``` + +Must compare against: + +- no-trade/cash; +- equal-weight Top-K; +- momentum/volatility rule; +- supervised ranker portfolio; +- market index proxy where available. + +### Page D5 — Daily Walk-forward / Gate page + +Purpose: prevent one lucky split from becoming a false model claim. + +Required gates: + +| Gate | Required | +|---|---| +| date-based walk-forward | yes | +| purging/embargo | yes for overlapping N-day targets | +| cost/slippage | yes | +| turnover | visible | +| max drawdown | visible | +| fold consistency | multiple folds | +| shuffled/control baseline | yes for alpha claims | +| dashboard verdict | `GO`, `WATCH`, `NO-GO` only; no profit claim | + +### Page D6 — Daily Dashboard Progress / Result Visualization layer + +Purpose: make daily-model development observable from raw DB readiness through final gate decisions, not only after models finish. + +| Visualization area | Required display | +|---|---| +| Development progress timeline | D0 DB analysis → D1 universe → D2 dataset → D3 supervised/ranker → D4 RL → D5 walk-forward gate | +| Stage status cards | `NOT_STARTED`, `RUNNING`, `PASS`, `WATCH`, `NO-GO`, `BLOCKED` with evidence links | +| Artifact registry | latest DB summary, universe manifest, dataset build, training run, prediction run, backtest, RL run, gate report | +| Data coverage charts | tables by market/type, rows by year, symbols with latest date, excluded/unmatched counts | +| Quality charts | invalid OHLC count, short-history distribution, missing latest-date count, stale symbols | +| Universe charts | KOSPI/KOSDAQ counts, common/preferred/ETF/ETN/SPAC/REIT/unknown exclusions | +| Prediction result charts | Top-K return by horizon, hit ratio, calibration/reliability, expected-return buckets | +| Portfolio/RL charts | NAV curve, drawdown, turnover, exposure count, concentration, action distribution, invalid actions | +| Walk-forward charts | fold-by-fold return/MDD/turnover, baseline deltas, shuffled-control comparison | +| Decision panel | final `GO/WATCH/NO-GO`, reasons, blocking gates, exact command/artifact evidence | +| Korean summary panel | user-readable interpretation: 단타/스윙/장기/종목선별 가능 범위 and current blocker | + +Recommended backend endpoints: + +```text +/api/daily-ohlcv/progress +/api/daily-ohlcv/artifacts +/api/daily-ohlcv/charts/coverage +/api/daily-ohlcv/charts/universe +/api/daily-ohlcv/charts/prediction +/api/daily-ohlcv/charts/portfolio +/api/daily-ohlcv/gate/latest +``` + +Recommended frontend components: + +```text +webui/v2_src/src/tabs/DailyOhlcvTab.svelte +webui/v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte +webui/v2_src/src/tabs/dailyOhlcv/DailyDbQualityCard.svelte +webui/v2_src/src/tabs/dailyOhlcv/DailyUniverseCard.svelte +webui/v2_src/src/tabs/dailyOhlcv/DailyPredictionResultCard.svelte +webui/v2_src/src/tabs/dailyOhlcv/DailyPortfolioRlCard.svelte +webui/v2_src/src/tabs/dailyOhlcv/DailyWalkForwardGateCard.svelte +``` + +Read-only rule: these pages may display generated artifacts and run status, but must not mutate `_database/*` or place broker/live orders. Any artifact generation action should be explicit CLI/API workflow under `webui/rl_runs/daily_*`, never hidden dashboard side effects. + +## 4. Feasibility by trading style + +| Trading style | Daily OHLCV role | Feasibility | Recommended path | +|---|---|---:|---| +| 당일 단타 | pre-market / previous-close watchlist only | low as standalone | daily selection + 1m/1s execution validation | +| 스윙 | 2-10 day Top-K/risk-managed selection | high | supervised ranker first, then daily portfolio RL | +| 장기 | 20-120 day technical selection | medium | add fundamentals/sector later | +| 종목 선별 | market/liquidity/regime filtered daily ranking | high | D1-D3 first | +| 예측 | probability/ranking, not exact price promise | medium | hit probability + expected return buckets | +| 강화학습 | portfolio rebalance after baselines pass | medium | D4 only after D3 gate | + +## 5. Recommended execution sequence + +| Phase | Deliverable | Status target | +|---|---|---| +| D0 | DB Analysis API/tab | first implementation | +| D1 | Universe Management API/tab and generated manifest | required before modeling | +| D2 | Dataset builder with date splits | required before prediction/RL | +| D3 | Supervised Top-K/ranker baseline | required before RL | +| D4 | Daily Portfolio RL env/train/gate | only after D3 beats baselines | +| D5 | Walk-forward / gate engine | final model decision logic | +| D6 | Dashboard progress/result visualization layer | evidence surface across all stages | + +## 6. Non-goals / guardrails + +- Do not mutate `_database/Stock_Database_ohlcv_1day.db`. +- Do not train on ETF/ETN/SPAC/REIT/unknown symbols by default. +- Do not call daily OHLCV predictions live-trading ready. +- Do not mix daily RL with opening `ts_imb` results as one strategy. +- Do not implement broker/order routing in dashboard. diff --git a/docs/stom_daily_ohlcv_deeprl_plan_2026-06-11.md b/docs/stom_daily_ohlcv_deeprl_plan_2026-06-11.md new file mode 100644 index 000000000..3df639d79 --- /dev/null +++ b/docs/stom_daily_ohlcv_deeprl_plan_2026-06-11.md @@ -0,0 +1,188 @@ +# STOM Daily OHLCV Deep Learning / RL Plan (2026-06-11) + +## Verdict + +Daily OHLCV is worth adding as a separate model track, but it must not be treated as a direct replacement for the current 1s/opening execution research. + +- **Best fit:** swing selection, next-day/next-N-day ranking, portfolio rebalancing, market/regime filters. +- **Weak fit:** same-day intraday scalping. Daily OHLCV can create a watchlist before/after market, but intraday entry/exit still needs 1m/1s execution evidence. +- **Possible but limited:** long-term stock selection from OHLCV alone. For robust long-horizon selection, fundamentals/news/macro/sector data should be added later. + +This plan is research-only. No profit guarantee, no live/broker/order readiness. Existing `ts_imb` opening-gap curve remains a RULE baseline, not RL. Costs and drawdown gates remain mandatory. + +## What daily OHLCV can support + +| Goal | Feasibility | Best model type | Comment | +|---|---:|---|---| +| Same-day scalping / intraday 단타 | Low with daily-only | Not recommended | Daily bars do not contain intraday path, spread, SL/TP order, or liquidity timing. Use daily only as prefilter/watchlist; execute/validate with 1m/1s. | +| Next-day direction prediction | Medium | Supervised DL/classifier/ranker | Predict `ret_1d > cost`, gap risk, downside risk, hit probability. Must beat persistence and simple momentum/mean-reversion baselines. | +| Swing trading 2-10 days | Medium-High | Supervised ranker + rule baseline, then RL portfolio | Daily OHLCV is naturally matched to swing horizons; evaluate turnover, MDD, capacity, and walk-forward stability. | +| Long-term selection 20-120 days | Medium | Ranking model + portfolio optimizer/RL | OHLCV alone may capture trend/volatility but lacks valuation/fundamentals. Treat as technical factor model until additional data exists. | +| Portfolio allocation/rebalancing | Medium | Daily portfolio RL | Existing `PortfolioEnv` can be adapted to daily candidate rows. RL action should be constrained and compared against fixed Top-K/risk-parity/no-trade. | +| Risk regime filter | High | Supervised/regime classifier | Index trend/volatility/breadth from daily data can decide when to disable or reduce strategies. | + +## Required separation from existing opening/1s track + +| Layer | Daily OHLCV role | Existing 1s/opening role | +|---|---|---| +| Universe | select symbols and regimes across days | validate opening execution and intraday fill realism | +| Signal horizon | 1d, 3d, 5d, 20d returns | 09:00-09:30 / seconds-level paths | +| Execution proof | daily close/open assumptions only, low fidelity | marketable fill, SL/TP order, gap-through, VI/halt, capacity | +| RL environment | daily portfolio rebalance | orderbook/opening/sizing experiments | + +Daily results must not be mixed with `ts_imb` as if they were the same strategy family. + +## Development plan to add to the current roadmap + +### D1. Daily OHLCV dataset builder + +Build a true daily dataset with one row per symbol/day. + +Expected file targets: + +```text +stom_rl/daily_ohlcv_dataset.py +tests/test_stom_rl_daily_ohlcv_dataset.py +``` + +Required columns: + +| Column | Meaning | +|---|---| +| `symbol` | string stock code; preserve leading zero | +| `date` | trading date | +| `open`, `high`, `low`, `close` | daily adjusted or raw OHLC, explicitly labeled | +| `volume`, `amount` | daily liquidity | +| `market_cap` optional | later capacity/ranking feature | +| `tradable` | false for halt/suspension/invalid bars | + +Hard rules: + +- No lookahead in adjustment, ranking, or normalization. +- Split by date, not random rows. +- Record market calendar and missing-bar handling. +- If Korean stocks: handle ±30% limit-up/down and suspension flags when available. + +### D2. Daily supervised prediction/ranking baseline + +Before RL, build supervised daily prediction and ranking baselines. + +Expected file targets: + +```text +stom_rl/daily_prediction.py +stom_rl/daily_ranker.py +tests/test_stom_rl_daily_prediction.py +tests/test_stom_rl_daily_ranker.py +``` + +Targets: + +| Target | Use | +|---|---| +| `ret_1d` | next-day watchlist / close-to-close | +| `ret_3d`, `ret_5d` | swing | +| `ret_20d` | medium-term selection | +| `downside_5d` | risk filter | +| `hit_5d_after_cost` | cost-aware classification | + +Baselines to beat: + +- no-trade/cash +- equal-weight Top-K momentum +- volatility-adjusted momentum +- simple mean-reversion +- market/index beta proxy where available + +### D3. Daily portfolio RL environment + +Adapt the existing portfolio action pattern to daily bars. + +Expected file targets: + +```text +stom_rl/daily_portfolio_env.py +stom_rl/daily_rl_train.py +stom_rl/daily_rl_gate.py +tests/test_stom_rl_daily_portfolio_env.py +tests/test_stom_rl_daily_rl_gate.py +``` + +Recommended first action contract: + +| Action | Meaning | +|---|---| +| `0` | hold / no rebalance | +| `1..K` | buy/add selected candidate slot | +| `K+1..K+M` | sell/reduce holding slot | + +Reward: + +```text +reward = daily_nav_return + - turnover_cost + - drawdown_penalty + - concentration_penalty + - invalid_action_penalty +``` + +Do not start with a free-form continuous allocation policy. Use masks and constrained actions first. + +### D4. Walk-forward validation gate + +Expected file targets: + +```text +stom_rl/daily_walk_forward.py +stom_rl/daily_model_gate.py +tests/test_stom_rl_daily_walk_forward.py +``` + +Required gates: + +| Gate | Requirement | +|---|---| +| date split | purged/embargoed or strictly forward-only | +| cost | include commissions/slippage assumption | +| turnover | measured and penalized | +| drawdown | maxDD and worst month/session visible | +| baseline | Top-K/rule/no-trade comparison | +| robustness | multiple folds, not one lucky split | +| selection | no retuning on OOS results | + +### D5. Dashboard integration + +Expected file targets: + +```text +webui/rl_dashboard_daily.py +webui/v2_src/src/tabs/rlTrading/DailyOhlcvModelCard.svelte +``` + +Dashboard should show: + +- horizon: `1d`, `3d`, `5d`, `20d` +- model type: supervised / RL / baseline +- split metadata +- Top-K hit rate and return after cost +- drawdown, turnover, concentration +- `GO/NO-GO`, never profit guarantee + +## What becomes possible after this track exists + +| User goal | Possible implementation | +|---|---| +| 당일 단타 종목 후보 | previous-day daily OHLCV watchlist + intraday 1m/1s execution validation | +| 스윙 종목 선별 | daily Top-K ranker over `ret_3d`/`ret_5d` with risk filter | +| 장기 후보 | `ret_20d`/`ret_60d` ranker, preferably with fundamentals later | +| 예측 | probability/ranking, not exact price promise | +| 강화학습 | daily portfolio rebalance RL after supervised/rule baselines pass | + +## Recommended immediate next work + +1. Build `daily_ohlcv_dataset.py` with small deterministic fixtures and strict date splits. +2. Create a daily supervised Top-K baseline before RL. +3. Add daily model gate and dashboard evidence. +4. Only after the baseline passes, implement daily portfolio RL. + +Daily RL should be treated as a new independent track, not as an unlock shortcut for the current opening/fresh-validation lock. diff --git a/docs/stom_daily_ohlcv_final_usage_handoff_2026-06-14.md b/docs/stom_daily_ohlcv_final_usage_handoff_2026-06-14.md new file mode 100644 index 000000000..33f43cf75 --- /dev/null +++ b/docs/stom_daily_ohlcv_final_usage_handoff_2026-06-14.md @@ -0,0 +1,116 @@ +# Daily OHLCV Final Usage Handoff — 2026-06-14 + +## Verdict + +`PASS` for the final Daily OHLCV research/evidence dashboard handoff. + +The system is still **research-only**. It is not a live-trading program, not broker/order ready, not a profit claim, and not a deployable model claim. Current global locks remain: + +| Gate | Current state | Meaning | +|---|---|---| +| D0 price basis | `unknown` / `UNKNOWN_CONFIRMED` | adjusted/raw/split/dividend basis is not independently verified. | +| D1 universe | `WATCH_HEURISTIC_UNIVERSE` | official/manual KRX common-equity validation is incomplete. | +| D3 baseline | `WATCH` | baselines are evidence, not model-build approval. | +| D4 RL | `RESEARCH_ONLY` | RL environment/telemetry exists, but policy underperforms D3 and remains diagnostic. | +| D5 gate | `NO-GO` | fresh OOS/walk-forward promotion is blocked. | +| D8/D9 registry | `RESEARCH_ONLY_BLOCKED` / `BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER` | registry is audit evidence only; paper-forward/live/order use is blocked. | +| Model build | `model_build_allowed=false` | remains false until D0/D1/D3/D5 gates pass. | +| Trading guardrail | `no live/broker/orders`, `no profit claim`, `23bp` default cost | must stay visible in docs/UI/API. | + +## What users can do now + +| Capability | Available? | How to use it | Boundary | +|---|---:|---|---| +| Inspect daily DB coverage and quality | Yes | Open Daily OHLCV D0 or call `/api/daily-ohlcv/db-summary`. | Price basis remains unknown; do not treat returns as decision-grade. | +| Inspect universe inclusion/exclusion | Yes | Open D1 or call `/api/daily-ohlcv/universe/preview`. | Universe is heuristic WATCH until official/manual KRX evidence is supplied. | +| Build/read dataset preview evidence | Yes | Open D2 or call `/api/daily-ohlcv/dataset/latest`; review split/leakage/invalid-bar status. | Dataset is research preview; upstream D0/D1 blockers propagate. | +| Compare D3 no-trade/shuffle/rule/supervised baselines | Yes | Open D3 or `/api/daily-ohlcv/prediction/latest`; compare net return, MDD, turnover, hit-rate, shuffle/no-trade deltas. | Baselines are not trading signals or profit proof. | +| Inspect D4 RL learning/reward/action/NAV diagnostics | Yes | Open D4 or `/api/daily-ohlcv/portfolio/latest`; use learning curve, reward stack, action distribution, invalid actions, turnover/drawdown, D3 overlay. | RL remains `RESEARCH_ONLY`; current policy underperforms the D3 baseline. | +| Inspect D5 walk-forward/OOS gate | Yes | Open D5 or `/api/daily-ohlcv/walk-forward/latest`; review folds, no-OOS-retuning, shuffle/no-trade controls, cost sensitivity, NO-GO reasons. | D5 is `NO-GO`; no model build or paper-forward promotion. | +| Use D6 decision cockpit and visual lab | Yes | Open D6 charts: decision cockpit, evidence flow, glossary, equity overlay, heatmap, scatter, universe breakdown, symbol preview. | Graphs explain evidence/failure only; they do not prove profitability. | +| Use D7 research lab diagnostics | Yes | Inspect feature/regime/correlation/failure/symbol guidance cards and plan next diagnostic artifacts. | Placeholder/diagnostic cards are not alpha claims. | +| Audit D8 registry hashes/drift/decision log | Yes | Open D8/D9 registry panel or `/api/daily-ohlcv/registry/latest`; compare config/data/code hashes, drift, drawdown, decision log, effective blockers. | Registry remains blocked; unsafe/malformed artifacts fail closed. | +| Paper-forward/live/broker/order use | No | Not supported. | `paper_forward_allowed=false`, `live_broker_order_allowed=false`; `no_live_broker_order_readiness=true` means explicitly not broker/order ready. | + +## Page-by-page usage table + +| Page | Current status | Main table/chart | Use it for | Do not use it for | Next evidence needed | +|---|---|---|---|---|---| +| D0 Daily DB Analysis | `PASS` with `PRICE_BASIS_UNKNOWN` | Table count, row count, date range, quality flags, split-like windows | Confirm DB coverage and why price-basis blocks downstream labels. | Claim adjusted/raw/total-return returns. | Independent adjusted/raw plus split/dividend/corporate-action proof. | +| D1 Universe Management | `WATCH_HEURISTIC_UNIVERSE` | Include/exclude counts, stockinfo matches, quarantine reasons, preview rows | Review candidate universe and quarantine ETF/ETN/fund/SPAC/REIT/preferred/unmatched rows. | Claim official KOSPI/KOSDAQ common-equity universe is final. | Official KRX metadata or manual reviewed CSV with required columns and audit trail. | +| D2 Dataset Builder | `PASS` as research preview | Dataset manifest, split chronology, leakage status, features/targets | Inspect ret_1d/3d/5d/20d labels and date-based split readiness. | Promote model training while D0/D1 remain blocked. | Rerun after D0/D1 verification; keep no-lookahead checks. | +| D3 Prediction / Top-K | `WATCH` | Baseline metrics, prediction rows, shuffle/no-trade/rule/supervised comparison | Compare frozen baselines under 23bp cost and controls. | Call Top-K/ranker output a tradable model. | Verified D0/D1 plus stronger preregistered D3 baseline that clears controls. | +| D4 Daily Portfolio RL | `RESEARCH_ONLY` | Learning curve, reward components, policy NAV, drawdown, action distribution, invalid actions, D3 overlay | Debug environment, reward, action mask, cost/turnover/concentration penalties, and policy failure modes. | Claim RL profitability or deploy the policy. | Reward/action redesign, preregistered hypotheses, D3-beating policy under cost. | +| D5 Walk-forward / Gate | `NO-GO` | Fold metrics, shuffle/no-trade controls, cost sensitivity, D4 state contract | Decide whether evidence is GO/WATCH/NO-GO and why. | Retune after seeing OOS or ignore failed folds. | Fresh OOS run with no retuning, fold consistency, MDD/turnover/cost controls, and D3 baseline outperformance. | +| D6 Dashboard Visualization | `PASS` | Decision cockpit, flow, glossary, overlays, heatmap, scatter | Navigate D0-D9 evidence and interpret graph meanings. | Treat visual curves as proof of returns. | Continue keeping blockers, controls, and provenance visible. | +| D7 Research Lab | `WATCH` | Feature/regime/correlation/failure cards and symbol drilldown | Generate next research hypotheses and failure attribution. | Use feature importance/regime/correlation cards as trading signals. | `feature_importance_by_fold.csv`, `regime_bucket_metrics.csv`, `correlation_cluster_summary.csv`, `failure_reason_attribution.csv`. | +| D8 Registry | `RESEARCH_ONLY_BLOCKED` | Manifest, candidate registry, hashes, drift, drawdown, decision log, effective blockers | Audit candidate status and prove paper-forward remains blocked. | Override D0-D5 gates or hide malformed artifacts. | All D0/D1/D3/D4/D5 blockers cleared and registry re-generated. | +| D9 Paper-forward | `BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER` | Paper-selected row, realized-return/drawdown evidence, no-live/broker/order cards | Confirm no paper/live/broker/order readiness and track future continuation hash drift. | Submit orders, connect brokers, or call paper evidence live readiness. | Only after explicit new plan and strict gates; current contract still blocks. | + +## API and artifact map + +| Surface | Endpoint / artifact | Expected current result | +|---|---|---| +| D0 DB summary | `/api/daily-ohlcv/db-summary` | `price_basis=unknown`, `price_basis_status=UNKNOWN_CONFIRMED`, decision-grade return blocked. | +| D1 universe | `/api/daily-ohlcv/universe/preview` | `WATCH_HEURISTIC_UNIVERSE`, official metadata missing, quarantine evidence visible. | +| D2 dataset | `/api/daily-ohlcv/dataset/latest` | Generated research-preview dataset, D0/D1 blockers propagated. | +| D3 prediction | `/api/daily-ohlcv/prediction/latest` | Frozen baselines, `go_summary_allowed=false`, shuffle/no-trade controls. | +| D4 portfolio RL | `/api/daily-ohlcv/portfolio/latest` | `RESEARCH_ONLY`, learning/reward/action/NAV/drawdown evidence, model/paper/live flags false. | +| D5 walk-forward | `/api/daily-ohlcv/walk-forward/latest` | `NO-GO`, 5 folds, no-OOS-retuning, cost sensitivity, false promotion flags. | +| D6 charts | `/api/daily-ohlcv/charts/decision-cockpit`, `/flow`, `/glossary`, `/equity-overlay`, `/walk-forward-heatmap`, `/run-scatter` | Read-only evidence views with blockers and guardrails. | +| D7 diagnostics | `/api/daily-ohlcv/charts/research-diagnostics`, `/api/daily-ohlcv/charts/symbol` | Diagnostic guidance and symbol preview; no buy/sell recommendation. | +| D8/D9 registry | `/api/daily-ohlcv/registry/latest` | `RESEARCH_ONLY_BLOCKED`, `invariant_errors=[]` for current generated run, D0/D1/D3/D4/D5 blockers, false model/paper/live flags. | +| Final registry run | `webui/rl_runs/daily_ohlcv_registry/registry_2026_06_14_g008_paper_forward/` | Contains manifest, candidate registry, paper-selected, realized returns, drawdown, drift, decision log, browser/API report, screenshot, adversarial report. | + +## How to interpret RL graphs + +| Graph | Correct interpretation | Unsafe interpretation | +|---|---|---| +| Learning curve | Whether the D4 research policy is learning or failing under fixed experiment settings. | Proof that the model will earn money. | +| Reward/return curve | Reward decomposition under the current cost/turnover/drawdown/concentration penalties. | Live return curve or broker-fill evidence. | +| Reward stack | Which reward terms dominate or punish the policy. | Reason to deploy without D5 OOS gate. | +| Action distribution | Whether action constraints/masks are working and invalid actions remain controlled. | Buy/sell instruction. | +| NAV / portfolio trajectory | Research policy trajectory from generated artifacts. | Account equity, live PnL, or paper-trading approval. | +| Drawdown | Failure/risk diagnostic and D5 gate input. | Acceptable live risk limit. | +| D3 overlay | Whether RL beats frozen no-trade/shuffle/rule/supervised baselines. | Standalone alpha proof without cost/OOS/shuffle controls. | +| Heatmap/scatter | Fold/cost/failure pattern visualization. | Cherry-picked GO signal. | + +## Remaining blockers and recommended next work + +| Priority | Work | Exit criterion | +|---:|---|---| +| 1 | Resolve D0 price basis | Independent adjusted/raw/split/dividend policy evidence; D0 blocker removed only with dated audit artifact. | +| 2 | Resolve D1 official/manual universe | Official KRX/manual metadata ingestion with complete coverage; heuristic WATCH removed only with audit artifact. | +| 3 | Re-run D3 after D0/D1 | Frozen baselines re-generated under verified inputs; no-trade/shuffle controls and 23bp cost remain mandatory. | +| 4 | Redesign D4 reward/action/environment | Pre-registered reward/action changes; learning/reward/action/NAV/drawdown visuals retained; RL must beat D3 under cost. | +| 5 | Re-run D5 fresh OOS | No OOS retuning, fold consistency, shuffle/no-trade controls, MDD/turnover/cost sensitivity, explicit GO/WATCH/NO-GO. | +| 6 | Re-generate D8/D9 | Registry hashes/drift/decision log updated only after gates change; paper/live/order still require a separate explicit plan. | + +## Recommended commands + +| Purpose | Command | +|---|---| +| Check durable goal state | `gjc ultragoal status --json` | +| Open local dashboard | `py -3.11 webui/run.py` then visit `/daily-ohlcv` | +| Verify Daily OHLCV backend/API/docs markers | `py -3.11 -m pytest tests/test_stom_rl_daily_registry.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q` | +| Verify frontend source/build | `cd webui/v2_src && npm run check && npm run build` | +| Inspect final registry artifact | `webui/rl_runs/daily_ohlcv_registry/registry_2026_06_14_g008_paper_forward/registry_manifest.json` | +| Continue research after blockers change | Start a new `ralplan`/`ultragoal` run; do not bypass D0/D1/D3/D5 gate evidence. | +## Verification summary + +G001-G008 were checkpointed through Ultragoal with focused tests, frontend build/checks, browser/API evidence, cleanup reviews, architect review, and executor QA. The final G009 documentation pass verifies that the user-facing capability table matches current evidence and preserves all research-only guardrails. + +Key current generated evidence: + +- `docs/stom_daily_ohlcv_d0_price_basis_confirmation_result_2026-06-14.md` +- `docs/stom_daily_ohlcv_d1_universe_official_validation_result_2026-06-14.md` +- `docs/stom_daily_ohlcv_d2_dataset_refresh_result_2026-06-14.md` +- `docs/stom_daily_ohlcv_d3_baseline_hardening_result_2026-06-14.md` +- `docs/stom_daily_ohlcv_d4_rl_visualization_result_2026-06-14.md` +- `docs/stom_daily_ohlcv_d5_fresh_oos_walk_forward_gate_result_2026-06-14.md` +- `docs/stom_daily_ohlcv_d6_d7_dashboard_usage_result_2026-06-14.md` +- `docs/stom_daily_ohlcv_d8d9_registry_paper_forward_result_2026-06-14.md` + +## Final decision + +The Daily OHLCV track now has a complete research/evidence dashboard path from D0 through D9. Users can inspect data quality, universe selection, dataset readiness, baselines, RL diagnostics, OOS gates, visualization guidance, and registry/paper-forward locks. They cannot use the system for live trading, broker/order routing, paper-forward promotion, deployable model claims, or profit claims. diff --git a/docs/stom_daily_ohlcv_hypothesis_rejection_audit_prereg_2026-06-18.md b/docs/stom_daily_ohlcv_hypothesis_rejection_audit_prereg_2026-06-18.md new file mode 100644 index 000000000..e39d4184b --- /dev/null +++ b/docs/stom_daily_ohlcv_hypothesis_rejection_audit_prereg_2026-06-18.md @@ -0,0 +1,230 @@ +# Daily OHLCV Hypothesis Rejection / Early-dropout Audit Preregistration — 2026-06-18 + +Date: 2026-06-18 UTC +Status: `PREREGISTERED_RESEARCH_ONLY` +Scope: Diagnose whether Kronos Daily OHLCV hypotheses are rejected too often or too early before/inside research workflows +Related ADR: `docs/stom_daily_ohlcv_dashboard_first_research_platform_adr_2026-06-18.md` +Guardrails: no live trading, no broker/orders, no paper-forward/model-build unlock, no profit claims. + +## Objective + +Create a falsifiable research audit that measures gate-level hypothesis attrition and distinguishes justified rejection from possible early dropout. The audit is designed to improve research triage quality, not to promote a model. + +The central question is: + +> Are Daily OHLCV research hypotheses being rejected too often or too early before enough decision-time evidence is collected? + +## Non-goals + +- This audit does not reverse any existing `NO-GO` result. +- This audit does not unlock D5, model build, paper-forward, live trading, broker integration, or order submission. +- False-negative candidates are not promotion candidates. +- The audit must not retune thresholds on OOS outcomes. +- The audit must not use `future_return_1d` or any future label as a decision-time feature. + +## Fixed policies + +| Policy | Value | +|---|---| +| Default cost | 23bp round trip | +| Sensitivity grid | 0/23/46bp | +| Baseline controls | no-trade, shuffle, frozen-D3 where applicable | +| Promotion status | `NO-GO_RESEARCH_ONLY` unless a later approved gate changes it | +| Candidate semantics | review-only, requires new preregistration | +| Generated artifact root | `webui/rl_runs/daily_ohlcv_rejection_audit/` | + +## Data sources + +Allowed source evidence: + +- Dated docs under `docs/`. +- Existing run manifests under `webui/rl_runs/`. +- Existing batch manifests under `webui/rl_runs/`. +- Existing scenario plans under `artifacts/`. +- Governance index `docs/stom_daily_ohlcv_research_governance_index_2026-06-18.md`. + +Disallowed source evidence: + +- Unhashed ad-hoc notebook/manual notes. +- Same-run post-hoc threshold changes. +- Future labels used as features. +- Live/broker/order logs. +- Any result lacking manifest/source path provenance. + +## Required artifacts + +The audit run must generate: + +| Artifact | Purpose | +|---|---| +| `gate_funnel_metrics.csv` / `.json` | Count hypotheses entering, passing, watching, rejecting, or dropping out at each gate | +| `rejection_reason_taxonomy.csv` / `.json` | Normalize why hypotheses are blocked/NO-GO/WATCH | +| `calibration_metrics.csv` | Compare prior confidence/score buckets with later gate outcomes | +| `threshold_sensitivity.csv` | Show how frozen thresholds affect pass/watch/reject counts without OOS retune | +| `false_negative_candidates.csv` | Review-only list of hypotheses that may deserve new preregistration | +| `audit_manifest.json` | Source hashes, artifact hashes, policies, row counts, guardrails | + +## Schema contracts + +### `gate_funnel_metrics` + +Required columns: + +- `run_id` +- `workflow_id` +- `hypothesis_id` +- `scenario_id` +- `gate_id` +- `gate_order` +- `denominator_group` +- `denominator_count` +- `entered_count` +- `passed_count` +- `watch_count` +- `rejected_count` +- `early_dropout_count` +- `missing_artifact_count` +- `stale_artifact_count` +- `cost_bp` +- `fold_id` +- `split` +- `decision_time_utc` +- `evidence_manifest_path` +- `evidence_manifest_sha256` +- `decision_rule_id` +- `retuned_on_oos` + +Denominator rule: `denominator_count` is fixed before seeing the gate outcome. It is the number of preregistered hypotheses/scenarios eligible to enter the gate. + +Timing rule: `decision_time_utc` is when the gate decision was made from then-available evidence. Later evidence must not rewrite historical rows. + +### `rejection_reason_taxonomy` + +Required columns: + +- `reason_id` +- `reason_family` +- `severity` +- `gate_id` +- `human_readable_reason_ko` +- `remediable` +- `required_next_evidence` +- `source_artifact_path` +- `source_artifact_sha256` +- `applies_to_workflow_ids` + +Allowed `reason_family` values: + +- `DATA_GOVERNANCE` +- `LEAKAGE_RISK` +- `COST_FAILURE` +- `BASELINE_UNDERPERFORMANCE` +- `FOLD_INCONSISTENCY` +- `ACTION_COLLAPSE` +- `MISSING_ARTIFACT` +- `STALE_ARTIFACT` +- `GOVERNANCE_LOCK` +- `INVALID_CONFIG` + +### `calibration_metrics` + +Required columns: + +- `run_id` +- `hypothesis_id` +- `score_source` +- `score_bucket` +- `confidence_bucket` +- `decision_gate` +- `denominator_count` +- `observed_pass_rate` +- `observed_watch_rate` +- `observed_reject_rate` +- `brier_score_optional` +- `ece_optional` +- `split` +- `fold_id` +- `cost_bp` +- `evidence_timing` +- `future_label_used_as_feature` + +`future_label_used_as_feature` must be `false`. + +### `threshold_sensitivity` + +Required columns: + +- `run_id` +- `hypothesis_id` +- `threshold_id` +- `threshold_value` +- `threshold_freeze_time_utc` +- `cost_bp` +- `fold_id` +- `split` +- `pass_count` +- `watch_count` +- `reject_count` +- `baseline_delta` +- `no_trade_delta` +- `shuffle_delta` +- `d3_delta` +- `retuned_on_oos` + +`retuned_on_oos` must be `false`. + +### `false_negative_candidates` + +Required columns: + +- `candidate_id` +- `original_hypothesis_id` +- `original_rejection_gate` +- `original_rejection_time_utc` +- `original_reason_id` +- `original_evidence_manifest_path` +- `later_independent_evidence_manifest_path` +- `later_independent_evidence_sha256` +- `independence_rule_id` +- `why_candidate_ko` +- `review_status` +- `promotion_allowed` +- `requires_new_preregistration` + +`review_status` must be `REVIEW_ONLY`. +`promotion_allowed` must be `false`. +`requires_new_preregistration` must be `true`. + +## Independent evidence rule + +Later evidence can support a false-negative candidate only if it comes from: + +1. A later timestamped manifest, or +2. A later fold/window not available at the original decision time, or +3. A separately preregistered follow-up diagnostic. + +Same-run cherry-pick, same-fold threshold changes, missing hashes, stale artifacts, or optimistic generated payloads fail closed. + +## Planned acceptance criteria + +The audit passes as a research diagnostic only if: + +1. Every generated artifact has source paths and hashes. +2. Every cost-bearing table has 0/23/46bp rows or a documented not-applicable reason. +3. Every candidate is explicitly review-only. +4. No candidate can set model/paper/live readiness true. +5. The audit manifest records source hashes, artifact hashes, denominator policy, timing policy, independent evidence policy, and guardrails. +6. Focused tests prove missing/stale/optimistic artifacts fail closed. + +## Expected interpretation + +| Outcome | Meaning | +|---|---| +| High justified rejection | Governance is strict but working | +| High early dropout with remediable reasons | Data/process quality work is needed before new model experiments | +| False-negative candidates present | New preregistration may be justified, not promotion | +| No false-negative candidates | Current rejection gates may be conservative but not obviously over-pruning | + +## Final guardrail + +This preregistration is about **research process quality**. It is not a trading strategy, not an RL performance result, and not evidence of live-trading readiness. diff --git a/docs/stom_daily_ohlcv_price_basis_result_2026-06-13.md b/docs/stom_daily_ohlcv_price_basis_result_2026-06-13.md new file mode 100644 index 000000000..849b60a8a --- /dev/null +++ b/docs/stom_daily_ohlcv_price_basis_result_2026-06-13.md @@ -0,0 +1,78 @@ +# STOM Daily OHLCV Price Basis Confirmation Result (2026-06-13) + +## Status + +`UNKNOWN_CONFIRMED` / `WATCH_PRICE_BASIS_UNKNOWN_CONFIRMED` + +This is a research-only D0 evidence update. It does not claim profitability, live/broker/order readiness, or model-build readiness. + +## Source + +- DB: `_database/Stock_Database_ohlcv_1day.db` +- Generated artifact: `webui/rl_runs/daily_ohlcv_db_summary/daily_ohlcv_price_basis_2026_06_13/` +- Audit JSON: `webui/rl_runs/daily_ohlcv_db_summary/daily_ohlcv_price_basis_2026_06_13/price_basis_audit.json` +- Representative windows CSV: `webui/rl_runs/daily_ohlcv_db_summary/daily_ohlcv_price_basis_2026_06_13/price_basis_windows.csv` + +## Finding + +The daily OHLCV DB has broad coverage but still does not declare whether prices are adjusted or raw. + +| Item | Result | +|---|---:| +| Tables scanned | 4,727 | +| Rows | 14,691,020 | +| Date range | 19860415 ~ 20260612 | +| Quality scan scope | all_tables | +| `price_basis` | `unknown` | +| `price_basis_status` | `UNKNOWN_CONFIRMED` | +| Decision-grade return status | `BLOCKED_UNTIL_PRICE_BASIS_VERIFIED` | +| Split-like tables in representative scan | 281 | +| Split-like window samples | 326 | + +Component status: + +| Component | Status | +|---|---| +| adjusted price | `not_declared_in_daily_db_schema` | +| raw price | `not_declared_in_daily_db_schema` | +| split adjustment | `not_declared_no_split_factor_or_corporate_action_table` | +| dividend adjustment | `not_declared_no_dividend_or_total_return_field` | + +Representative split-like evidence includes `A000180` on `20260303`, where open/previous-close ratio was about `4.96`, and `A000670` on `20250113`, where open/previous-close ratio was about `0.10`. These windows confirm adjustment-basis risk but are not themselves corporate-action proof. + +## Decision + +D0 remains usable as a DB analysis surface, but the model/research decision label is: + +```text +D0 = PASS but price_basis unknown / UNKNOWN_CONFIRMED +``` + +Blocking implications: + +1. Decision-grade return labels remain blocked until adjusted/raw basis is independently verified. +2. Split-like discontinuities must be flagged or excluded from model decision windows. +3. Dashboard/API must keep `model_build_allowed=false` until price basis and downstream gates pass. +4. The 23bp default cost assumption remains mandatory for later comparisons unless an artifact explicitly documents another cost basis. + +## Verification performed + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_ohlcv_db.py -q +``` + +Result: `10 passed`. + +```powershell +py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py tests/test_stom_rl_daily_ohlcv_db.py -q +``` + +Result: `21 passed`. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- `ts_imb` remains an opening-gap RULE baseline, not RL. +- Leading-zero stock codes remain strings in API/dashboard evidence. diff --git a/docs/stom_daily_ohlcv_research_governance_index_2026-06-16.md b/docs/stom_daily_ohlcv_research_governance_index_2026-06-16.md new file mode 100644 index 000000000..8f8c35281 --- /dev/null +++ b/docs/stom_daily_ohlcv_research_governance_index_2026-06-16.md @@ -0,0 +1,100 @@ +# Daily OHLCV Research Governance Index — 2026-06-16 + +Date: 2026-06-16 KST +Status: `ACTIVE_RESEARCH_LEDGER` +Scope: Daily OHLCV D0-D9 research, D3 baseline, D4 RL diagnostics, D5 gate, scenario automation + +## Purpose + +This index makes the Daily OHLCV research history findable and auditable. It separates: + +- durable human-readable decisions in `docs/` +- generated/session evidence in `webui/rl_runs/` +- code/tests in `stom_rl/`, `webui/`, and `tests/` + +The system remains research-only: no live/broker/orders, no profit claims, no paper-forward/model-build promotion while D5 is `NO-GO`. + +## Current latest D4 research chain + +| Order | Document | Type | Status | Artifact anchor | +|---:|---|---|---|---| +| 1 | `docs/stom_daily_ohlcv_rl_continuation_prereg_2026-06-14.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | D4 reward/action continuation plan | +| 2 | `docs/stom_daily_ohlcv_d4_rl_visualization_result_2026-06-14.md` | result | `RESEARCH_ONLY` | D4 visual/telemetry evidence | +| 3 | `docs/stom_daily_ohlcv_final_usage_handoff_2026-06-14.md` | handoff | `PASS` handoff with global locks | D0-D9 dashboard usage map | +| 4 | `docs/stom_daily_ohlcv_d4_no_trade_diagnostic_result_2026-06-16.md` | result | `NO-GO_RESEARCH_ONLY` | `scenario_batch_no_trade_diag_001` | +| 5 | `docs/stom_daily_ohlcv_d4_action_induction_v2_prereg_2026-06-16.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | next D4 action-induction v2 plan | + +## Latest generated evidence + +| Evidence | Path | Meaning | +|---|---|---| +| No-trade diagnostic batch | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_no_trade_diag_001/scenario_batch_manifest.json` | Four D4 stress scenarios, all `NO-GO`. | +| No-trade diagnostic summary | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_no_trade_diag_001/no_trade_diagnostic_research_summary.json` | Aggregated missed/avoided no-trade diagnostic. | +| Per-run portfolio artifacts | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_no_trade_diag_001__*/` | D4 RL manifests, rewards, actions, state observations, no-trade diagnostics. | +| Scenario plan reused | `artifacts/scenario_batch_d4_action_reward_001_plan.json` | Action/reward/environment stress matrix plan. | + +## Document naming contract + +| Document type | Required pattern | When to create | +|---|---|---| +| Preregistration | `docs/stom_daily_ohlcv__prereg_YYYY-MM-DD.md` | Before changing hypotheses, reward/state/action contract, gates, or scenario matrix. | +| Result report | `docs/stom_daily_ohlcv__result_YYYY-MM-DD.md` | After a run/test produces evidence. | +| Handoff | `docs/stom_daily_ohlcv__handoff_YYYY-MM-DD.md` | When another agent/session should continue from current evidence. | +| Verdict | `docs/stom_daily_ohlcv__verdict_YYYY-MM-DD.md` | When a GO/WATCH/NO-GO decision must be frozen. | +| Governance index | `docs/stom_daily_ohlcv_research_governance_index_YYYY-MM-DD.md` | When research history or data governance rules materially change. | + +Do not edit old result documents to hide or soften failed experiments. Create a new dated document that references the old evidence. + +## Minimum report fields + +Every new result report must include: + +1. Date and status. +2. Experiment type: `RULE`, `supervised gate`, `RL experiment`, or `baseline`. +3. Guardrails: no live/broker/orders, no profit claim, paper/model flags. +4. Default cost and sensitivity assumptions. +5. Exact command(s) run. +6. Generated artifact paths. +7. Split/OOS/fold details where applicable. +8. Baseline/no-trade/shuffle controls where applicable. +9. Test/verification command and observed output. +10. Verdict: `PASS`, `WATCH`, `NO-GO`, or `RESEARCH_ONLY` with reasons. +11. Next allowed research action. + +## Data governance checklist + +| Check | Required evidence | +|---|---| +| Source provenance | Source hashes in run manifests or a documented commit/session reference. | +| Artifact provenance | Manifest paths and artifact hashes for generated outputs. | +| Cost accounting | 23bp default cost in docs and manifests. | +| Split integrity | Train/val/test or fold metadata; no OOS retuning. | +| Label leakage | State/feature manifest showing future labels are not used in decision-time features. | +| Baseline controls | no-trade, shuffle, and frozen D3 baseline comparison for D4/D5 claims. | +| Status flags | `model_build_allowed=false`, `go_summary_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false` unless a later approved gate changes them. | +| Generated vs durable separation | Generated artifacts under `webui/rl_runs/`; decisions under `docs/`. | +| Leading-zero codes | Codes preserved as strings in examples/artifacts. | +| Failure visibility | `NO-GO`/blocker reasons visible in docs and dashboard; no marketing language. | + +## Current blockers that remain active + +| Blocker | Current meaning | +|---|---| +| D0 price basis | Return labels are not independently verified as adjusted/raw/split/dividend safe. | +| D1 universe | Universe remains heuristic/WATCH without complete official/manual validation. | +| D3 baseline | Frozen D3 remains a comparator, not model-build approval. | +| D4 RL | Current policy collapses to no-trade and remains research-only. | +| D5 gate | `NO-GO`; no model build or paper-forward. | +| D8/D9 registry | Audit evidence only; live/broker/order readiness blocked. | + +## Next research pointer + +The next executable research should follow: + +`docs/stom_daily_ohlcv_d4_action_induction_v2_prereg_2026-06-16.md` + +Expected first result document after implementation: + +`docs/stom_daily_ohlcv_d4_action_induction_v2_result_2026-06-16.md` + +Until that result exists, D4 action-induction v2 is preregistered but not proven. diff --git a/docs/stom_daily_ohlcv_research_governance_index_2026-06-17.md b/docs/stom_daily_ohlcv_research_governance_index_2026-06-17.md new file mode 100644 index 000000000..2bcf98441 --- /dev/null +++ b/docs/stom_daily_ohlcv_research_governance_index_2026-06-17.md @@ -0,0 +1,125 @@ +# Daily OHLCV Research Governance Index — 2026-06-17 + +Date: 2026-06-17 UTC +Status: `ACTIVE_RESEARCH_LEDGER` +Supersedes: `docs/stom_daily_ohlcv_research_governance_index_2026-06-16.md` +Scope: Daily OHLCV D0-D9 research, D3 baseline, D4 RL diagnostics, D5 gate, scenario automation, data governance + +## Purpose + +This index keeps the Daily OHLCV research history findable and auditable. It separates: + +- durable human-readable decisions in `docs/` +- preregistered scenario plans in `artifacts/` +- generated/session evidence in `webui/rl_runs/` +- code/tests in `stom_rl/`, `webui/`, and `tests/` + +The system remains research-only: no live/broker/orders, no profit claims, no paper-forward/model-build promotion while D5 is `NO-GO`. + +## Current latest D4 research chain + +| Order | Document | Type | Status | Artifact anchor | +|---:|---|---|---|---| +| 1 | `docs/stom_daily_ohlcv_rl_continuation_prereg_2026-06-14.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | D4 reward/action continuation plan | +| 2 | `docs/stom_daily_ohlcv_d4_rl_visualization_result_2026-06-14.md` | result | `RESEARCH_ONLY` | D4 visual/telemetry evidence | +| 3 | `docs/stom_daily_ohlcv_final_usage_handoff_2026-06-14.md` | handoff | `PASS` handoff with global locks | D0-D9 dashboard usage map | +| 4 | `docs/stom_daily_ohlcv_d4_no_trade_diagnostic_result_2026-06-16.md` | result | `NO-GO_RESEARCH_ONLY` | `scenario_batch_no_trade_diag_001` | +| 5 | `docs/stom_daily_ohlcv_d4_action_induction_v2_prereg_2026-06-16.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | D4 action-induction v2 frozen plan | +| 6 | `docs/stom_daily_ohlcv_d4_action_induction_v2_result_2026-06-17.md` | result | `NO-GO_RESEARCH_ONLY` | `scenario_batch_d4_action_induction_v2_001` | +| 7 | `docs/stom_daily_ohlcv_d4_trade_quality_filter_prereg_2026-06-17.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | next D4 trade-quality/abstention plan | +| 8 | `docs/stom_daily_ohlcv_d4_trade_quality_filter_result_2026-06-17.md` | result | `NO-GO_RESEARCH_ONLY` | `scenario_batch_d4_trade_quality_filter_001` | + +## Latest generated evidence + +| Evidence | Path | Meaning | +|---|---|---| +| Trade-quality filter batch | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_d4_trade_quality_filter_001/scenario_batch_manifest.json` | Five D4 confidence/margin/joint/risk/control scenarios, all `NO-GO`. | +| Trade-quality filter summary | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_d4_trade_quality_filter_001/trade_quality_filter_research_summary.json` | Aggregated filter returns, abstention counts, action distribution, and baseline deltas. | +| Trade-quality filter plan | `artifacts/scenario_batch_d4_trade_quality_filter_001_plan.json` | Frozen run matrix for the 2026-06-17 trade-quality filter preregistration. | +| Trade-quality source hashes | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__confidence_abstain_v1/source_hashes.json` | Representative source provenance artifact; each trade-quality portfolio run keeps the same `source_hashes.json` contract. | +| Action-induction v2 batch | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_d4_action_induction_v2_001/scenario_batch_manifest.json` | Three D4 v2 state/action-prior scenarios, all `NO-GO`. | +| Action-induction v2 summary | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_d4_action_induction_v2_001/action_induction_v2_research_summary.json` | Aggregated v2 state/action reachability and baseline comparison. | +| Action-induction v2 plan | `artifacts/scenario_batch_d4_action_induction_v2_001_plan.json` | Frozen run matrix for v2 state and action-prior diagnostics. | +| Per-run v2 portfolio artifacts | `webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_action_induction_v2_001__*/` | D4 RL manifests, rewards, actions, state observations, no-trade diagnostics. | +| No-trade diagnostic batch | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_no_trade_diag_001/scenario_batch_manifest.json` | Prior four D4 stress scenarios, all `NO-GO`. | +| No-trade diagnostic summary | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_no_trade_diag_001/no_trade_diagnostic_research_summary.json` | Prior missed/avoided no-trade diagnostic. | + +## Current D4 findings snapshot + +| Finding | Evidence | Status | +|---|---|---| +| v1/no-trade diagnostic | Prior D4 policies collapsed to all-hold/no-trade under multiple capacity/training settings. | `NO-GO_RESEARCH_ONLY` | +| v2 action reachability | v2 state/action-prior scenarios produce buy/add/sell/reduce actions on `val+test`. | Diagnostic progress only | +| v2 trade quality | All v2 scenarios underperform the best D3/no-trade baseline after 23bp cost. | `NO-GO_RESEARCH_ONLY` | +| Trade-quality filters | Confidence/margin/joint filters emit `abstention_reasons.csv` and block weak entries, but all five scenarios remain below `no_trade_cash` / best D3. | `NO-GO_RESEARCH_ONLY` | +| D5 gate | Latest trade-quality batch: `gate_status_counts={"NO-GO": 5}`. | Model-build/paper/live blocked | + +## Document naming contract + +| Document type | Required pattern | When to create | +|---|---|---| +| Preregistration | `docs/stom_daily_ohlcv__prereg_YYYY-MM-DD.md` | Before changing hypotheses, reward/state/action contract, gates, or scenario matrix. | +| Result report | `docs/stom_daily_ohlcv__result_YYYY-MM-DD.md` | After a run/test produces evidence. | +| Handoff | `docs/stom_daily_ohlcv__handoff_YYYY-MM-DD.md` | When another agent/session should continue from current evidence. | +| Verdict | `docs/stom_daily_ohlcv__verdict_YYYY-MM-DD.md` | When a GO/WATCH/NO-GO decision must be frozen. | +| Governance index | `docs/stom_daily_ohlcv_research_governance_index_YYYY-MM-DD.md` | When research history or data governance rules materially change. | + +Do not edit old result documents to hide or soften failed experiments. Create a new dated document that references the old evidence. + +## Minimum report fields + +Every new result report must include: + +1. Date and status. +2. Experiment type: `RULE`, `supervised gate`, `RL experiment`, or `baseline`. +3. Guardrails: no live/broker/orders, no profit claim, paper/model flags. +4. Default cost and sensitivity assumptions. +5. Exact command(s) run. +6. Generated artifact paths. +7. Split/OOS/fold details where applicable. +8. Baseline/no-trade/shuffle controls where applicable. +9. Test/verification command and observed output. +10. Verdict: `PASS`, `WATCH`, `NO-GO`, or `RESEARCH_ONLY` with reasons. +11. Next allowed research action. + +## Data governance checklist + +| Check | Required evidence | +|---|---| +| Source provenance | Source hashes in run manifests or a documented commit/session reference. | +| Artifact provenance | Manifest paths and artifact hashes for generated outputs. | +| Cost accounting | 23bp default cost in docs and manifests; add 46bp sensitivity when promoting a lane. | +| Split integrity | Train/val/test or fold metadata; no OOS retuning. | +| Label leakage | State/feature manifest and tests showing future labels are not used in decision-time features. | +| Baseline controls | no-trade, shuffle, and frozen D3 baseline comparison for D4/D5 claims. | +| Status flags | `model_build_allowed=false`, `go_summary_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false` unless a later approved gate changes them. | +| Generated vs durable separation | Generated artifacts under `webui/rl_runs/`; decisions under `docs/`. | +| Leading-zero codes | Codes preserved as strings in examples/artifacts. | +| Failure visibility | `NO-GO`/blocker reasons visible in docs and dashboard; no marketing language. | + +## Current blockers that remain active + +| Blocker | Current meaning | +|---|---| +| D0 price basis | Return labels are not independently verified as adjusted/raw/split/dividend safe. | +| D1 universe | Universe remains heuristic/WATCH without complete official/manual validation. | +| D3 baseline | Frozen D3 remains a comparator, not model-build approval. | +| D4 RL | v2 made actions reachable and trade-quality filters emit abstention telemetry, but all D4 variants still underperform no-trade/best D3 baseline. | +| D5 gate | `NO-GO`; no model build or paper-forward. | +| D8/D9 registry | Audit evidence only; live/broker/order readiness blocked. | + +## Next research pointer + +The completed D4 trade-quality filter run remains `NO-GO_RESEARCH_ONLY`. The next executable research should not tune thresholds against this result without a new preregistration. Recommended next lane: D3/D4 signal-quality audit and past-only risk proxy improvement. + +Minimum requirements for the next preregistration: + +- verify whether D3 score magnitude and score margin are calibrated enough to support abstention, +- add a better past-only daily OHLCV risk/regime proxy if available from generated artifacts, +- keep no-trade, shuffle, equal-weight top-k, and frozen D3 baselines mandatory, +- keep `abstention_reasons.csv` and future-label no-leakage tests mandatory for any D4 filter, +- keep D5/model-build/paper-forward/live status `NO-GO` until a fresh gate passes. + +Latest completed result document: + +`docs/stom_daily_ohlcv_d4_trade_quality_filter_result_2026-06-17.md` diff --git a/docs/stom_daily_ohlcv_research_governance_index_2026-06-18.md b/docs/stom_daily_ohlcv_research_governance_index_2026-06-18.md new file mode 100644 index 000000000..b9a9da81b --- /dev/null +++ b/docs/stom_daily_ohlcv_research_governance_index_2026-06-18.md @@ -0,0 +1,121 @@ +# Daily OHLCV Research Governance Index — 2026-06-18 + +Date: 2026-06-18 UTC +Status: `ACTIVE_RESEARCH_LEDGER` +Supersedes: `docs/stom_daily_ohlcv_research_governance_index_2026-06-17.md` +Scope: Daily OHLCV D0-D9 research, D3 baseline, D4 RL diagnostics, D5 gate, scenario automation, data governance + +## Purpose + +This index keeps the Daily OHLCV research history findable and auditable. It separates durable decisions in `docs/`, preregistered plans in `artifacts/`, generated/session evidence in `webui/rl_runs/`, and source/tests in `stom_rl/`, `webui/`, and `tests/`. + +The system remains research-only: no live/broker/orders, no profit claims, no paper-forward/model-build promotion while D5 is `NO-GO`. + +## Current latest D4/D3 research chain + +| Order | Document | Type | Status | Artifact anchor | +|---:|---|---|---|---| +| 1 | `docs/stom_daily_ohlcv_rl_continuation_prereg_2026-06-14.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | D4 reward/action continuation plan | +| 2 | `docs/stom_daily_ohlcv_d4_rl_visualization_result_2026-06-14.md` | result | `RESEARCH_ONLY` | D4 visual/telemetry evidence | +| 3 | `docs/stom_daily_ohlcv_d4_no_trade_diagnostic_result_2026-06-16.md` | result | `NO-GO_RESEARCH_ONLY` | `scenario_batch_no_trade_diag_001` | +| 4 | `docs/stom_daily_ohlcv_d4_action_induction_v2_prereg_2026-06-16.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | D4 action-induction v2 frozen plan | +| 5 | `docs/stom_daily_ohlcv_d4_action_induction_v2_result_2026-06-17.md` | result | `NO-GO_RESEARCH_ONLY` | `scenario_batch_d4_action_induction_v2_001` | +| 6 | `docs/stom_daily_ohlcv_d4_trade_quality_filter_prereg_2026-06-17.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | D4 trade-quality/abstention plan | +| 7 | `docs/stom_daily_ohlcv_d4_trade_quality_filter_result_2026-06-17.md` | result | `NO-GO_RESEARCH_ONLY` | `scenario_batch_d4_trade_quality_filter_001` | +| 8 | `docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_prereg_2026-06-18.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | D3/D4 signal-quality diagnostic contract | +| 9 | `docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_result_2026-06-18.md` | result | `WATCH_DIAGNOSTIC_ONLY` / `NO-GO_RESEARCH_ONLY` | `scenario_batch_signal_quality_audit_001` | +| 10 | `docs/stom_daily_ohlcv_dashboard_scenario_generator_result_2026-06-18.md` | dashboard result | `IMPLEMENTED_RESEARCH_ONLY_DASHBOARD` / `NO-GO_RESEARCH_ONLY` | Daily RL Guide priority 1-5 scenario generator/maturity UI | +| 11 | `docs/stom_daily_ohlcv_dashboard_first_research_platform_adr_2026-06-18.md` | ADR | `ACCEPTED_FOR_RESEARCH_PLATFORM_EXECUTION` / `RESEARCH_ONLY` | Dashboard-first, CLI-internal, no-live job-intent contract | +| 12 | `docs/stom_daily_ohlcv_hypothesis_rejection_audit_prereg_2026-06-18.md` | preregistration | `PREREGISTERED_RESEARCH_ONLY` | Gate-funnel / early-dropout / false-negative audit contract | +| 13 | `docs/stom_daily_ohlcv_dashboard_first_research_platform_completion_result_2026-06-18.md` | result | `IMPLEMENTED_RESEARCH_ONLY_DASHBOARD_PLATFORM` | Non-live dashboard/research platform `100%`; live/model/paper readiness `0%` | + +## Latest generated evidence + +| Evidence | Path | Meaning | +|---|---|---| +| Signal-quality audit manifest | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/signal_quality_manifest.json` | Main run metadata, source hashes, cost sensitivity, guardrails, and required artifact map. | +| Signal-quality bucket metrics | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/signal_quality_bucket_metrics.csv` | Score/sign/margin/confidence buckets with source timing, future-label flags, split/fold metadata, and 0/23/46bp rows. | +| Risk proxy metrics | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/risk_proxy_bucket_metrics.csv` | Past/current pre-action risk proxy buckets with proxy status, D3 deltas, turnover proxy, and cost rows. | +| Baseline controls | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/baseline_control_metrics.csv` | Measured no-trade, shuffle, equal-weight top-k, and frozen D3 controls. | +| Leakage audit | `webui/rl_runs/daily_ohlcv_signal_quality/signal_quality_audit_2026_06_18_001/signal_quality_leakage_audit.json` | Feature timing and future-label evaluation-only audit. | +| Signal-quality batch plan | `artifacts/scenario_batch_signal_quality_audit_001_plan.json` | Frozen five-scenario diagnostic matrix. | +| Signal-quality batch manifest | `webui/rl_runs/daily_ohlcv_signal_quality_batches/scenario_batch_signal_quality_audit_001/scenario_batch_manifest.json` | Five preregistered scenarios completed, `WATCH: 5`, no failures, promotion remains `NO-GO_RESEARCH_ONLY`. | +| Trade-quality filter batch | `webui/rl_runs/daily_ohlcv_scenario_batches/scenario_batch_d4_trade_quality_filter_001/scenario_batch_manifest.json` | Prior D4 confidence/margin/joint/risk/control scenarios, all `NO-GO`. | +| Daily RL Guide scenario/maturity result | `docs/stom_daily_ohlcv_dashboard_scenario_generator_result_2026-06-18.md` | Dashboard priority 1-5 completion report: scenario draft generator, signal-quality binding, market-regime readiness, AI queue, scenario comparison, and maturity percentages. | +| Dashboard-first research platform ADR | `docs/stom_daily_ohlcv_dashboard_first_research_platform_adr_2026-06-18.md` | Freezes no-live product scope, CLI-internal UX, approval-gated intent-record-only POST boundary, forbidden command/live fields, and fail-closed job-intent lifecycle. | +| Hypothesis rejection audit preregistration | `docs/stom_daily_ohlcv_hypothesis_rejection_audit_prereg_2026-06-18.md` | Freezes falsifiable gate-funnel, rejection taxonomy, calibration, threshold-sensitivity, false-negative review-only, denominator/timing, and independent-evidence schema. | +| Hypothesis rejection analytics manifest | `webui/rl_runs/daily_ohlcv_rejection_audit/hypothesis_rejection_audit_2026_06_18_001/audit_manifest.json` | Gate-funnel, rejection taxonomy, calibration, threshold sensitivity, false-negative review-only row counts, hashes, and research-only locks. | +| Hypothesis rejection follow-up evidence | `webui/rl_runs/daily_ohlcv_rejection_audit/hypothesis_rejection_audit_2026_06_18_001/follow_up_review_evidence_manifest.json` | Separately hashed independent-evidence manifest for the review-only false-negative candidate; does not reverse NO-GO. | +| Dashboard-first final completion evidence | `docs/stom_daily_ohlcv_dashboard_first_research_platform_completion_result_2026-06-18.md` | Final report: non-live dashboard/research platform completion `100%`, live/model/paper readiness `0%`, and verified UI/API/build evidence. | +| Dashboard-first browser completion screenshot | `artifacts/dashboard_first_g005_completion_report.png` | Browser evidence for the final completion panel and Korean guardrails. | + +## Current findings snapshot + +| Finding | Evidence | Status | +|---|---|---| +| D4 trade-quality filters | Abstention telemetry works, but all five D4 scenarios underperformed no-trade/best D3 after 23bp cost. | `NO-GO_RESEARCH_ONLY` | +| D3/D4 signal quality | New diagnostic artifacts expose score/margin/confidence/risk proxy timing and baseline controls. Fold evidence is mixed: test F05 favorable, test F03 negative, F04 near zero. | `WATCH_DIAGNOSTIC_ONLY` | +| Baseline controls | Signal-quality audit now measures no-trade, shuffle, equal-weight top-k, and frozen D3 controls instead of listing them only in a manifest. | Required comparator visibility restored | +| Label leakage | Bucket/proxy rows include source timing and future-label flags; lagged drawdown-path proxies are t-1 generated-artifact inputs. | Current audit passes no-leakage checks | +| D5 gate | Latest signal-quality run is diagnostic only and does not run or pass a D5 promotion gate. | Model-build/paper/live blocked | +| Dashboard scenario generator | Daily RL Guide now exposes fixed JSON scenario drafts, signal-quality result binding, market-regime readiness, AI improvement queue, scenario comparison, and numeric maturity reporting. | `IMPLEMENTED_RESEARCH_ONLY_DASHBOARD`; live/model/paper readiness still 0% | +| Dashboard-first platform governance | ADR removes live trading from product goals, demotes CLI commands to backend provenance/admin detail, and requires dashboard POST behavior to create immutable research job-intent records only. | `ACCEPTED_FOR_RESEARCH_PLATFORM_EXECUTION`; no execution/live/model unlock | +| Hypothesis over-rejection audit contract | Preregistration defines how to test whether hypotheses are rejected too often or too early without hindsight promotion. False-negative candidates are review-only and require new preregistration. | `PREREGISTERED_RESEARCH_ONLY` | +| Hypothesis over-rejection audit | Generated rejection analytics now expose gate-funnel counts, rejection taxonomy, calibration, threshold sensitivity, and review-only false-negative candidates with independent evidence hashes. | `COMPLETED_RESEARCH_ONLY`; no NO-GO reversal | +| Dashboard-first completion | Workflow center, workflow inspector, safe config preview, approval-gated intent ledger, rejection analytics, and final completion panel are integrated into the user-facing dashboard. | Non-live platform `100%`; live/model/paper readiness `0%` | + +## Document naming contract + +| Document type | Required pattern | When to create | +|---|---|---| +| Preregistration | `docs/stom_daily_ohlcv__prereg_YYYY-MM-DD.md` | Before changing hypotheses, reward/state/action contract, gates, or scenario matrix. | +| Result report | `docs/stom_daily_ohlcv__result_YYYY-MM-DD.md` | After a run/test produces evidence. | +| Handoff | `docs/stom_daily_ohlcv__handoff_YYYY-MM-DD.md` | When another agent/session should continue from current evidence. | +| Verdict | `docs/stom_daily_ohlcv__verdict_YYYY-MM-DD.md` | When a GO/WATCH/NO-GO decision must be frozen. | +| Governance index | `docs/stom_daily_ohlcv_research_governance_index_YYYY-MM-DD.md` | When research history or data governance rules materially change. | + +Do not edit old result documents to hide or soften failed experiments. Create a new dated document that references the old evidence. + +## Minimum report fields + +Every new result report must include: date/status, experiment type, guardrails, default cost and sensitivity, exact commands, generated artifact paths, split/fold details, baseline/no-trade/shuffle controls, verification output, verdict, next allowed action, and data-governance notes. + +## Data governance checklist + +| Check | Required evidence | +|---|---| +| Source provenance | Source hashes in run manifests or a documented commit/session reference. | +| Artifact provenance | Manifest paths and artifact hashes for generated outputs. | +| Cost accounting | 23bp default cost in docs and manifests; 0/23/46bp sensitivity before promotion discussion. | +| Split integrity | Train/val/test or fold metadata; no OOS retuning. | +| Label leakage | State/feature manifest and tests showing future labels are not used in decision-time features. | +| Baseline controls | no-trade, shuffle, equal-weight top-k, and frozen D3 comparison for D4/D5 claims. | +| Status flags | `model_build_allowed=false`, `go_summary_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false` unless a later approved gate changes them. | +| Generated vs durable separation | Generated artifacts under `webui/rl_runs/`; decisions under `docs/`. | +| Leading-zero codes | Codes preserved as strings in examples/artifacts. | +| Failure visibility | `NO-GO`/blocker reasons visible in docs and dashboard; no marketing language. | + +## Current blockers that remain active + +| Blocker | Current meaning | +|---|---| +| D0 price basis | Return labels are not independently verified as adjusted/raw/split/dividend safe. | +| D1 universe | Universe remains heuristic/WATCH without complete official/manual validation. | +| D3 baseline | Frozen D3 remains a comparator, not model-build approval. Signal-quality evidence is mixed and diagnostic only. | +| D4 RL | Action reachability and abstention telemetry exist, but D4 variants still lack cost-aware, fold-consistent superiority. | +| D5 gate | `NO-GO`; no model build, paper-forward, live trading, broker integration, or orders. | +| D8/D9 registry | Audit evidence only; live/broker/order readiness blocked. | + +## Next research pointer + +The dashboard-first research platform is now the latest completed lane. It should be used to inspect evidence, blockers, workflows, generated artifacts, and review-only false-negative candidates, not to unlock live/model/paper behavior. The next useful research remains a **past-only market-regime data quality audit** under a fresh preregistration: + +- validate adjusted/raw/split/dividend basis for daily OHLCV labels, +- verify universe breadth and missing-data behavior, +- build better past-only volatility/drawdown/breadth proxies from validated OHLCV artifacts, +- keep no-trade, shuffle, equal-weight top-k, and frozen D3 controls mandatory, +- keep D5/model-build/paper-forward/live status `NO-GO` until a fresh gate passes. + +Latest completed result document: + +`docs/stom_daily_ohlcv_dashboard_first_research_platform_completion_result_2026-06-18.md` diff --git a/docs/stom_daily_ohlcv_rl_continuation_final_handoff_2026-06-14.md b/docs/stom_daily_ohlcv_rl_continuation_final_handoff_2026-06-14.md new file mode 100644 index 000000000..1084a50b5 --- /dev/null +++ b/docs/stom_daily_ohlcv_rl_continuation_final_handoff_2026-06-14.md @@ -0,0 +1,124 @@ +# STOM Daily OHLCV RL Continuation Final Handoff (2026-06-14) + +## Verdict + +`PASS` for the research/evidence handoff. `NO-GO` for model build, paper-forward, live trading, broker routing, and order submission. + +This document closes the 2026-06-14 Daily OHLCV RL continuation stories G001-G006. The work produced a stricter research loop and clearer dashboard evidence, not a deployable trading model and not a profit claim. + +## Guardrail snapshot + +| Surface | Current state | Meaning | +|---|---|---| +| D0 price basis | `unknown` / `UNKNOWN_CONFIRMED` | Daily return labels are still not decision-grade until adjusted/raw/split/dividend basis is independently verified. | +| D1 universe | `WATCH_HEURISTIC_UNIVERSE` | Official or manually reviewed KRX common-equity universe validation is still required. | +| D3 baseline | `WATCH` / `D3_WATCH_RESEARCH_ONLY` | Frozen D3 remains a comparator and blocker, not model-build approval. | +| D4 RL | `RESEARCH_ONLY` | RL environment/training telemetry exists for diagnostics only. | +| D5 fresh OOS gate | `NO-GO` / `D5_NO_GO_RESEARCH_ONLY_GATE` | The fresh OOS walk-forward gate remains blocked by D0/D1/D3 and RL-underperformance evidence. | +| Model/paper/live flags | `model_build_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false` | No model creation/promotion, paper-forward, live/broker/order readiness is allowed. | +| Cost assumption | 23bp round trip default; 0bp/46bp sensitivity only | Cost sensitivity is diagnostic and must not be cherry-picked. | +| Data safety | No `_database` mutation; leading-zero codes preserved as strings | Generated artifacts are read-only evidence. | + +## What was developed + +| Story | Result | Main artifact/docs | Verification status | +|---|---|---|---| +| G001 preregistration | Froze Option A constrained-action reward/action redesign, controls, thresholds, and failure taxonomy before implementation. | `docs/stom_daily_ohlcv_rl_continuation_prereg_2026-06-14.md`; `webui/rl_runs/daily_ohlcv_rl_prereg/prereg_2026_06_14_g001_rl_continuation/` | Complete; architect/QA approved. | +| G002 D4 environment | Hardened reward/action contract: 23bp turnover cost, drawdown/concentration/churn penalties, invalid-action reasons, no-trade/hold behavior, action masks, leading-zero code preservation. | `stom_rl/daily_portfolio_env.py`; `docs/stom_daily_ohlcv_rl_continuation_g002_env_reward_action_result_2026-06-14.md`; `webui/rl_runs/daily_ohlcv_portfolio_env/env_contract_2026_06_14_g002_reward_action/` | Complete; focused tests passed. | +| G003 D4 telemetry | Added learning/reward/action telemetry, reward/action ablations, invalid-action rows, turnover/drawdown, policy NAV, state observations, source hashes, and D3 overlay. | `stom_rl/daily_rl_train.py`; `docs/stom_daily_ohlcv_rl_continuation_g003_training_telemetry_result_2026-06-14.md`; `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_14_g003_training_telemetry/` | Complete; focused tests passed. | +| G004 D5 gate | Re-ran/hardened fresh OOS walk-forward: 5 folds, 5/5 purge/embargo, no OOS retuning, no-trade/shuffle/D3 controls, 0/23/46bp sensitivity, MDD/turnover checks, fail-closed D4 evidence. | `stom_rl/daily_walk_forward.py`; `docs/stom_daily_ohlcv_rl_continuation_g004_walk_forward_gate_result_2026-06-14.md`; `webui/rl_runs/daily_ohlcv_walk_forward/walk_forward_2026_06_14_g004_fresh_oos_gate/` | Complete; gate remains `NO-GO`. | +| G005 dashboard/API | Updated Daily OHLCV backend and Svelte dashboard surfaces so D4/D5 evidence is read-only, false promotion flags are visible, D0/D1/D3 blockers propagate, and stale/optimistic artifacts fail closed. | `webui/daily_ohlcv_dashboard.py`; `webui/v2_src/src/lib/dailyOhlcvApi.ts`; `webui/v2_src/src/tabs/dailyOhlcv/DailyModelResultsCard.svelte`; `webui/rl_runs/daily_ohlcv_dashboard/dashboard_2026_06_14_g005_evidence_surfaces/` | Complete; API/tab tests, build, route/dist tests, browser audit, architect/QA approved. | +| G006 handoff | Documents what users can do, what remains blocked, graph interpretation, and failure-analysis loop. | This document; `webui/rl_runs/daily_ohlcv_final_handoff/rl_continuation_final_2026_06_14_g006/` | Complete; final aggregate checkpoint recorded in `.gjc/ultragoal/ledger.jsonl`. | + +## Was an RL model created? + +| Question | Answer | +|---|---| +| Was a deployable RL trading model created? | No. `model_build_allowed=false` remains locked. | +| Was an RL research policy/artifact generated? | Yes. D4 research-only portfolio artifacts and telemetry were generated for diagnostics. | +| Is RL research continuing? | Yes, but only inside the read-only research loop with preregistered hypotheses, controls, D3 baseline comparison, 23bp cost, fresh OOS, and explicit failure labels. | +| Can the dashboard be used to trade or submit orders? | No. The dashboard is an evidence viewer. It has no live/broker/order approval. | + +## What users can do now + +| Capability | How to use it | Current boundary | +|---|---|---| +| Inspect daily DB coverage and price-basis blocker | Open `/daily-ohlcv`, D0 section, or call `/api/daily-ohlcv/db-summary`. | Price basis remains `unknown`; do not treat returns as adjusted/decision-grade. | +| Inspect universe/quarantine evidence | Use D1 section or `/api/daily-ohlcv/universe/preview`. | Universe remains `WATCH_HEURISTIC_UNIVERSE`; official/manual KRX validation is still required. | +| Inspect D2 dataset readiness | Use D2 section or `/api/daily-ohlcv/dataset/latest`. | D0/D1 blockers propagate into dataset interpretation. | +| Compare D3 baselines | Use D3 section or `/api/daily-ohlcv/prediction/latest`. | Baselines are controls/comparators only; not trading signals. | +| Inspect D4 RL diagnostics | Use D4 model evidence card or `/api/daily-ohlcv/portfolio/latest`. | D4 is `RESEARCH_ONLY`; learning/reward/NAV/drawdown/action graphs diagnose failure modes. | +| Inspect D5 fresh OOS gate | Use D5 walk-forward card or `/api/daily-ohlcv/walk-forward/latest`. | D5 remains `NO-GO`; no model build or paper-forward unlock. | +| Use dashboard charts | Use `/api/daily-ohlcv/charts/*` through the Daily OHLCV page: decision cockpit, research diagnostics, flow, cost sensitivity, heatmap, equity overlay, run scatter, universe breakdown. | Charts explain evidence and blockers; they do not prove profitability. | +| Audit generated artifacts | Inspect `webui/rl_runs/daily_ohlcv_*` directories and source hashes. | Generated/session artifacts are evidence; do not mutate `_database`. | +| Plan next research iteration | Use the failure taxonomy below and start a new preregistered plan before changing reward/action/environment again. | No OOS retuning or cherry-picking after seeing gate results. | + +## Page/table usage guide + +| Page/table | Current status | What to read first | Use it for | Do not use it for | +|---|---|---|---|---| +| D0 Daily DB Analysis | `PRICE_BASIS_UNKNOWN` | Price-basis status, table/date coverage, quality flags, split-like windows | Confirm why all downstream return evidence is blocked. | Claim adjusted/raw/total-return correctness. | +| D1 Universe Management | `WATCH_HEURISTIC_UNIVERSE` | Include/exclude counts, quarantine reasons, official metadata status, preview rows | Review common-equity candidate universe and exclusion reasons. | Claim official KRX universe finality. | +| D2 Dataset Builder | Research preview | Manifest, split chronology, leakage status, invalid-bar status, target windows | Check dataset construction and no-lookahead evidence. | Promote training while D0/D1 blockers remain. | +| D3 Prediction / Top-K | `WATCH` | Frozen baseline metrics, no-trade/shuffle rows, D3 blockers, 23bp cost | Compare RL against no-trade/shuffle/rule/supervised baselines. | Treat baseline/ranker output as a tradable model. | +| D4 Daily Portfolio RL | `RESEARCH_ONLY` | Learning curve, reward breakdown, reward/action ablations, action distribution, invalid actions, turnover, drawdown, policy NAV, D3 overlay | Diagnose reward/action/environment behavior and failure modes. | Claim RL profitability, paper-forward, live readiness, or order instructions. | +| D5 Walk-forward / Gate | `NO-GO` | Fold table, purge/embargo, no-OOS-retuning flag, D4 state contract, cost sensitivity, failure reasons | Determine why the research policy fails/blocks under fresh OOS controls. | Retune on OOS or ignore no-trade/shuffle/D3 controls. | +| D6 Decision Cockpit / Visual Lab | Evidence viewer | Decision flags, evidence flow, glossary, cost/fold visualizations | Navigate D0-D9 evidence and see blocker propagation. | Treat visual curves as proof of returns. | +| D7 Research Diagnostics | Diagnostic/WATCH | Failure cards, symbol preview, feature/regime guidance | Decide what diagnostic artifact should be produced next. | Use diagnostics as alpha or buy/sell recommendations. | +| D8 Registry | `RESEARCH_ONLY_BLOCKED` | Candidate registry, hashes, drift, blockers, decision log | Audit candidate status and prove promotion is blocked. | Override D0-D5 gates. | +| D9 Paper-forward | `BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER` | Paper-selected row, realized-return/drawdown evidence, no-live/broker/order cards | Confirm paper/live/broker/order use is blocked. | Submit orders, connect brokers, or present paper-forward readiness. | + +## RL graph interpretation + +| Graph/table | Correct interpretation | Failure signal to look for | +|---|---|---| +| Learning curve | Did the research policy optimize its training objective under fixed preregistered settings? | Flat/noisy curve, reward spike without NAV improvement, instability by episode. | +| Reward breakdown | Which reward terms dominate after 23bp cost, drawdown, concentration, churn, and invalid-action penalties. | Reward hacking: higher reward while net NAV, drawdown, or controls worsen. | +| Reward/action ablations | Sensitivity to removing reward penalties or action constraints. | Apparent edge only exists when costs/penalties are removed. | +| Action distribution | Whether actions are diverse, masked correctly, and invalid actions are controlled. | Action collapse into one action, excessive no-trade without explanation, invalid action clusters. | +| Turnover/cost | How much policy behavior is eaten by 23bp round-trip cost. | Cost sensitivity failure at 23bp or 46bp. | +| Policy NAV | Research trajectory of generated artifacts. | NAV underperforms no-trade, shuffle, or frozen D3; do not treat as account equity. | +| Drawdown | Risk/failure diagnostic and D5 gate input. | Drawdown/concentration failure even if average return looks acceptable. | +| D3 overlay | Direct comparison to no-trade/shuffle/rule/supervised baselines. | RL underperforms frozen D3 or wins only on cherry-picked folds/costs. | +| Fold/cost heatmap and scatter | Stability across OOS folds and cost assumptions. | Fold instability, worst-fold dominance, 0bp-only edge, inconsistent signs. | + +## Failure-analysis loop + +| Failure category | Trigger | Required next action | +|---|---|---| +| `reward_hacking` | Reward improves while NAV, drawdown, cost, or controls worsen. | Revise reward terms in a new preregistered plan; do not relabel as success. | +| `action_collapse` | Policy degenerates into one action or ignores no-trade/hold/masks. | Inspect action masks, invalid reasons, no-trade behavior, and reward/action ablations. | +| `drawdown_or_concentration_failure` | Return evidence depends on unacceptable drawdown or concentrated exposure. | Tighten risk penalties/limits and rerun D4/D5 under the same controls. | +| `cost_sensitivity_failure` | Edge disappears under 23bp or 46bp. | Keep `NO-GO`; cost-free curves are diagnostic only. | +| `fold_instability` | Few folds drive the result or worst-fold risk dominates. | Add/refresh OOS folds only under preregistered no-retuning rules. | +| `D0_price_basis_blocker` | `price_basis=unknown` remains. | Resolve adjusted/raw/split/dividend basis before decision-grade claims. | +| `D1_universe_blocker` | Universe remains heuristic/WATCH. | Supply official/manual KRX common-equity validation artifact. | +| `D3_underperformance` | RL policy underperforms frozen D3. | Treat RL as failed diagnostic and revise hypothesis before rerun. | +| `artifact_or_provenance_drift` | Source hashes, run ids, manifests, or registry evidence drift unexpectedly. | Fail closed, regenerate evidence with hashes, and record the drift. | + +## Remaining blockers + +| Priority | Blocker | Exit criterion | +|---:|---|---| +| 1 | D0 price basis unknown | Independent adjusted/raw/split/dividend/corporate-action audit removes `PRICE_BASIS_UNKNOWN`. | +| 2 | D1 universe heuristic/WATCH | Official KRX metadata or manually reviewed common-equity universe artifact removes `WATCH_HEURISTIC_UNIVERSE`. | +| 3 | D3 baseline WATCH | Frozen D3 rerun under verified D0/D1 clears controls and becomes a stable comparator. | +| 4 | D4 RL underperformance | A new preregistered RL hypothesis beats no-trade, shuffle, and frozen D3 after 23bp cost without reward hacking. | +| 5 | D5 NO-GO | Fresh OOS walk-forward passes 5+ folds, purge/embargo, no OOS retuning, D3/no-trade/shuffle controls, MDD/turnover, and 0/23/46bp sensitivity. | +| 6 | D8/D9 blocked registry/paper-forward | Only after D0/D1/D3/D4/D5 clear; paper/live/broker/order still requires a separate explicit plan. | + +## Recommended commands + +| Purpose | Command | +|---|---| +| Check durable execution state | `gjc ultragoal status --json` | +| Open local dashboard | `py -3.11 webui/run.py`, then visit `/daily-ohlcv` | +| Verify RL/env/walk-forward core | `py -3.11 -m pytest tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_walk_forward.py -q` | +| Verify Daily OHLCV dashboard/API | `py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q` | +| Verify dashboard build/dist | `cd webui/v2_src && npm run build`; then `py -3.11 -m pytest tests/test_v2_route.py tests/test_v2_dist_marker.py -q` | +| Inspect G005 dashboard evidence | `webui/rl_runs/daily_ohlcv_dashboard/dashboard_2026_06_14_g005_evidence_surfaces/` | +| Inspect G004 D5 gate evidence | `webui/rl_runs/daily_ohlcv_walk_forward/walk_forward_2026_06_14_g004_fresh_oos_gate/` | + +## Final decision + +Daily OHLCV RL research can continue, but only as research. The current system now has better reward/action/environment telemetry, stricter D5 fail-closed OOS gating, and a dashboard that shows why promotion is blocked. It still has no deployable RL model, no paper-forward approval, no live/broker/order readiness, and no profit claim. diff --git a/docs/stom_daily_ohlcv_rl_continuation_g002_env_reward_action_result_2026-06-14.md b/docs/stom_daily_ohlcv_rl_continuation_g002_env_reward_action_result_2026-06-14.md new file mode 100644 index 000000000..ebc25d277 --- /dev/null +++ b/docs/stom_daily_ohlcv_rl_continuation_g002_env_reward_action_result_2026-06-14.md @@ -0,0 +1,57 @@ +# Daily OHLCV RL Continuation G002: D4 Environment Reward/Action Hardening + +Date: 2026-06-14 KST +Status: `RESEARCH_ONLY` +Source plan: `.gjc/plans/ralplan/2026-06-11-0158-38ea/pending-approval.md` +Preregistration source: `docs/stom_daily_ohlcv_rl_continuation_prereg_2026-06-14.md` +Generated artifact: `webui/rl_runs/daily_ohlcv_portfolio_env/env_contract_2026_06_14_g002_reward_action/` + +## Scope + +G002 implements/hardens only the D4 Daily Portfolio RL environment reward/action contract. It does not claim model readiness, paper-forward approval, live/broker/order readiness, or profit. + +## Guardrails preserved + +| Guardrail | Status | +|---|---| +| No live/broker/orders | Preserved in environment manifest and fill assumption | +| No profit claim | Preserved; artifacts are evidence/diagnostic only | +| Default round-trip cost | 23bp | +| `_database` mutation | Not used by this story | +| Leading-zero codes | Preserved through `str(code).zfill(6)` and positions/state artifacts | +| D4 status | `RESEARCH_ONLY` | +| `model_build_allowed` | `false` | + +## Implementation result + +| Area | Result | +|---|---| +| Net return after cost | `net_return_after_cost = gross_return - turnover_cost` is explicit in reward info, reward components, CSV artifacts, and environment manifest. | +| Drawdown penalty | Reward still subtracts drawdown penalty and exposes `current_drawdown` / `drawdown_penalty`. | +| Concentration penalty | Reward still subtracts concentration penalty and exposes concentration telemetry. | +| Turnover/churn penalty | 23bp turnover cost plus separate churn penalty remain explicit. | +| Invalid actions | Invalid action reason is now explicit (`invalid_action_reason`) for both masked actions and unknown out-of-range actions; action mask reasons are emitted. | +| No-trade/hold | `hold` is always valid; flat hold is explicitly marked `no_trade_action=true`. | +| Action masks | `action_mask_details()` exposes valid/blocked reasons for hold/buy/add/sell/reduce. | +| Generated inspection | `action_masks.csv` includes `mask_reason_*`; `reward_breakdown.csv` includes requested/executed action, invalid reason, no-trade flag, and net-return-after-cost. | + +## Verification + +```powershell +py -3.11 -m py_compile stom_rl/daily_portfolio_env.py tests/test_stom_rl_daily_portfolio_env.py +py -3.11 -m pytest tests/test_stom_rl_daily_portfolio_env.py -q +py -3.11 -m py_compile stom_rl/daily_rl_train.py tests/test_stom_rl_daily_rl_gate.py +py -3.11 -m pytest tests/test_stom_rl_daily_rl_gate.py -q +py -3.11 -c +``` + +Observed result: + +```text +tests/test_stom_rl_daily_portfolio_env.py + tests/test_stom_rl_daily_rl_gate.py: 12 passed +G002 env inspection artifact PASS +``` + +## Remaining state + +This story only hardens the environment contract. D4 remains `RESEARCH_ONLY`, D5 remains `NO-GO`, and `model_build_allowed=false` remains locked until D0/D1/D3/D5 gates pass under later approved evidence. diff --git a/docs/stom_daily_ohlcv_rl_continuation_g003_training_telemetry_result_2026-06-14.md b/docs/stom_daily_ohlcv_rl_continuation_g003_training_telemetry_result_2026-06-14.md new file mode 100644 index 000000000..83561455a --- /dev/null +++ b/docs/stom_daily_ohlcv_rl_continuation_g003_training_telemetry_result_2026-06-14.md @@ -0,0 +1,57 @@ +# STOM Daily OHLCV RL Continuation G003 Training Telemetry Result (2026-06-14) + +## Verdict + +`RESEARCH_ONLY`. G003 hardens Daily OHLCV D4 RL training/evaluation telemetry for failure analysis. It is not a profit result, not a deployable model, and not live/broker/order readiness. + +## Artifact + +- Run: `portfolio_2026_06_14_g003_training_telemetry` +- Directory: `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_14_g003_training_telemetry/` +- Input D3 run: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_14_g004_d3_baseline_hardened/` +- Cost assumption: 23bp round trip +- Status: `RESEARCH_ONLY` +- `model_build_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false` + +## What changed + +| Area | Result | +|---|---| +| Reward breakdown | Adds `requested_action`, `executed_action`, `invalid_action_reason`, `no_trade_action`, `net_return_after_cost`, `no_trade_hold_reward`, and action-mask reason fields. | +| Action telemetry | Action distribution now separates requested vs executed actions, invalid reasons, and no-trade hold rows. | +| Reward/action ablations | Adds `reward_action_ablations.csv` and `reward_action_ablation_summary.json` with recorded reward and counterfactual removals of turnover cost, drawdown, concentration, churn, and invalid-action penalties. | +| Turnover/drawdown telemetry | Carries requested/executed/no-trade context and net-after-cost accounting into turnover/drawdown CSVs. | +| State observations | Adds action-mask bitset and per-action mask reasons while preserving `future_label_exposed=false`. | +| Provenance | Adds source hashes for `stom_rl/daily_rl_train.py`, `stom_rl/daily_portfolio_env.py`, and `stom_rl/daily_prediction.py`; generated `source_hashes.json`. | +| D3 overlay | Keeps frozen D3 baseline overlay in `policy_baseline_comparison.csv` with no-trade/shuffle/rule/supervised baselines at 23bp. | + +## Current run interpretation + +The generated policy remains a constrained D4 diagnostic path. On this run it records all/mostly no-trade hold behavior and does not unlock model building. That is visible evidence for failure analysis, not something to hide or re-label as success. D5 fresh OOS walk-forward, shuffle/no-trade controls, D0 price-basis confirmation, D1 universe review, and D3 baseline status still govern promotion. + +## Verification + +Commands run: + +```powershell +py -3.11 -m py_compile stom_rl/daily_rl_train.py tests/test_stom_rl_daily_rl_gate.py +py -3.11 -m pytest tests/test_stom_rl_daily_rl_gate.py -q +py -3.11 -c "import json; from stom_rl.daily_rl_train import run_and_write_daily_rl; out=run_and_write_daily_rl(run_id='portfolio_2026_06_14_g003_training_telemetry', overwrite=True, prediction_run_dir='webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_14_g004_d3_baseline_hardened', episodes=8, candidate_limit=20, max_positions=5, seed=7); print(json.dumps({'run_id': out['written']['run_id'], 'artifact_dir': out['written']['artifact_dir'], 'status': out['result']['verdict']['status'], 'model_build_allowed': out['result']['manifest']['model_build_allowed'], 'reward_action_ablation_rows': out['result']['manifest']['row_counts']['reward_action_ablation_rows'], 'source_hash_count': len(out['result']['source_hashes'])}, ensure_ascii=False))" +py -3.11 -m py_compile stom_rl/daily_rl_train.py stom_rl/daily_portfolio_env.py tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_portfolio_env.py +py -3.11 -m pytest tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_portfolio_env.py -q +``` + +Observed results: + +- `tests/test_stom_rl_daily_rl_gate.py`: `4 passed`. +- Combined focused RL/env regression: `13 passed`. +- Artifact generation wrote `portfolio_2026_06_14_g003_training_telemetry` with `status=RESEARCH_ONLY`, `model_build_allowed=false`, 28 reward/action ablation rows, and 3 source hashes. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- 23bp round-trip cost remains the default accounting assumption. +- Leading-zero stock codes remain string-preserved in source and artifact paths. +- D4 remains `RESEARCH_ONLY`; D5 remains required before any model-build claim. diff --git a/docs/stom_daily_ohlcv_rl_continuation_g004_walk_forward_gate_result_2026-06-14.md b/docs/stom_daily_ohlcv_rl_continuation_g004_walk_forward_gate_result_2026-06-14.md new file mode 100644 index 000000000..f10b2c441 --- /dev/null +++ b/docs/stom_daily_ohlcv_rl_continuation_g004_walk_forward_gate_result_2026-06-14.md @@ -0,0 +1,61 @@ +# STOM Daily OHLCV RL Continuation G004 Walk-Forward Gate Result (2026-06-14) + +## Verdict + +`NO-GO` / `D5_NO_GO_RESEARCH_ONLY_GATE`. G004 hardens and reruns the Daily OHLCV D5 fresh OOS walk-forward gate. This is research-only validation evidence, not a profit result, not a deployable model, and not live/broker/order readiness. + +## Artifact + +- Run: `walk_forward_2026_06_14_g004_fresh_oos_gate` +- Directory: `webui/rl_runs/daily_ohlcv_walk_forward/walk_forward_2026_06_14_g004_fresh_oos_gate/` +- Input D3 run: `webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_14_g004_d3_baseline_hardened/` +- Input D4 run: `webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_14_g003_training_telemetry/` +- Cost sensitivity: 0bp / 23bp / 46bp +- Purge/embargo: 5 days / 5 days +- Forward folds: 5 +- `model_build_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false` + +## Gate result + +| Check | Result | +|---|---| +| D4 state contract consumed | `PASS`; state observations, reward breakdown, invalid actions, policy NAV, D3 overlay, reward/action ablations, ablation summary, and source hashes were present. | +| Forward folds | 5 folds assigned with purge/embargo windows, minimum purge/embargo validation, and `retuned_on_oos=false`. | +| No-trade/shuffle/D3 controls | No-trade, shuffled-score, and frozen D3 baseline rows remain in gate evidence. | +| Cost sensitivity | 0bp, 23bp, and 46bp rows generated for each fold. | +| MDD/turnover limits | Checked; latest selected-fold worst MDD and mean turnover did not exceed configured research limits. | +| D0/D1 blockers | `PRICE_BASIS_UNKNOWN` and `UNIVERSE_WATCH_HEURISTIC` remain. | +| RL vs D3 | `RL_POLICY_UNDERPERFORMS_D3_BASELINE` remains. | +| D4 lock | `D4_RL_RESEARCH_ONLY_LOCK` remains. | + +## Why the result remains NO-GO + +The gate is intentionally conservative. Even though the forward folds completed and D4 artifacts were consumed, model building remains locked because D0 price basis is still unknown, D1 universe review is still WATCH/heuristic, D4 is research-only, and the RL policy does not beat the frozen D3 baseline evidence under the current gate. + +## Verification + +Commands run: + +```powershell +py -3.11 -m py_compile stom_rl/daily_walk_forward.py tests/test_stom_rl_daily_walk_forward.py +py -3.11 -m pytest tests/test_stom_rl_daily_walk_forward.py -q +py -3.11 -c "import json; from stom_rl.daily_walk_forward import run_and_write_daily_walk_forward; out=run_and_write_daily_walk_forward(run_id='walk_forward_2026_06_14_g004_fresh_oos_gate', overwrite=True, prediction_run_dir='webui/rl_runs/daily_ohlcv_prediction/prediction_2026_06_14_g004_d3_baseline_hardened', portfolio_run_dir='webui/rl_runs/daily_ohlcv_portfolio/portfolio_2026_06_14_g003_training_telemetry', n_folds=5, purge_days=5, embargo_days=5, top_k=20, seed=17); print(json.dumps({'run_id': out['written']['run_id'], 'artifact_dir': out['written']['artifact_dir'], 'status': out['result']['gate_verdict']['status'], 'readiness_status': out['result']['gate_verdict']['readiness_status'], 'model_build_allowed': out['result']['gate_verdict']['model_build_allowed'], 'n_folds': out['result']['gate_verdict']['n_folds'], 'cost_sensitivity_bp': out['result']['gate_verdict']['cost_sensitivity_bp'], 'd4_reward_action_ablation_rows': out['result']['gate_verdict']['d4_reward_action_ablation_rows'], 'd4_source_hash_count': out['result']['gate_verdict']['d4_source_hash_count'], 'reasons': out['result']['gate_verdict']['reasons']}, ensure_ascii=False))" +py -3.11 -m py_compile stom_rl/daily_rl_train.py stom_rl/daily_portfolio_env.py stom_rl/daily_walk_forward.py tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_walk_forward.py +py -3.11 -m pytest tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_walk_forward.py -q +``` + +Observed results: + +- `tests/test_stom_rl_daily_walk_forward.py`: `42 passed`. +- Combined G003/G004 focused regression: `55 passed`. +- Artifact generation wrote `walk_forward_2026_06_14_g004_fresh_oos_gate` with `status=NO-GO`, `readiness_status=D5_NO_GO_RESEARCH_ONLY_GATE`, 5 folds, 5/5 purge/embargo, 0/23/46bp cost sensitivity, 28 consumed D4 ablation rows, 3 D4 source hashes, `d4_artifact_issues=[]`, and false model/paper/live flags. +- Cleaner/review blocker fixes: empty required D4 state observations, reward breakdown, invalid actions, policy baseline comparison, policy NAV, reward/action ablations, reward/action ablation summary, and source hashes now fail closed; missing/zero purge or embargo now fails closed; malformed/unreadable/wrong-schema required D4 JSON/CSV and missing/malformed frozen baseline requirements now fail closed; malformed/unreadable D4 baseline comparison JSON now fails closed instead of defaulting to neutral evidence. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- 23bp round-trip cost remains the default accounting assumption, with 0bp/46bp sensitivity shown only as diagnostics. +- Leading-zero stock codes remain string-preserved in source and upstream artifacts. +- D5 remains `NO-GO`; model building remains disallowed. diff --git a/docs/stom_daily_ohlcv_rl_continuation_prereg_2026-06-14.md b/docs/stom_daily_ohlcv_rl_continuation_prereg_2026-06-14.md new file mode 100644 index 000000000..8dcab0d1a --- /dev/null +++ b/docs/stom_daily_ohlcv_rl_continuation_prereg_2026-06-14.md @@ -0,0 +1,119 @@ +# Daily OHLCV RL Continuation Preregistration + +Date: 2026-06-14 KST +Status: `PREREGISTERED_RESEARCH_ONLY` +Source plan: `.gjc/plans/ralplan/2026-06-11-0158-38ea/pending-approval.md` +Generated artifact: `webui/rl_runs/daily_ohlcv_rl_prereg/prereg_2026_06_14_g001_rl_continuation/preregistration_manifest.json` + +## Guardrail snapshot + +| Surface | Locked state | Consequence | +|---|---|---| +| D0 price basis | `unknown` / `UNKNOWN_CONFIRMED` | Return evidence is not decision-grade until price basis is independently verified. | +| D1 universe | `WATCH_HEURISTIC_UNIVERSE` | Official/manual KRX common-equity validation remains required. | +| D3 baseline | `WATCH` / `D3_WATCH_RESEARCH_ONLY` | Frozen D3 is a comparator, not model-build approval. | +| D4 RL | `RESEARCH_ONLY` | RL graphs are diagnostics only. | +| D5 gate | `NO-GO` | No model-build, paper-forward, or readiness promotion. | +| Global flags | `model_build_allowed=false`, `go_summary_allowed=false`, `paper_forward_allowed=false`, `live_broker_order_allowed=false` | Must remain false until D0/D1/D3/D5 gates pass in a later approved workflow. | + +This preregistration keeps: no live/broker/orders, no profit claims, 23bp round-trip default cost, no `_database` mutation, and leading-zero stock codes as strings. + +## Selected hypothesis + +**Option A — conservative constrained-action reward redesign** is frozen as the next Daily OHLCV RL research path. + +Hypothesis: a constrained daily-portfolio RL policy with explicit 23bp turnover cost, drawdown/concentration/churn penalties, invalid-action handling, and no-trade/hold behavior may reduce prior D4 failure modes. It must still beat no-trade, deterministic shuffle, and frozen D3 baselines on fresh OOS before any promotion discussion. + +This is not a deployable model, paper-forward approval, broker/order readiness, or profit claim. + +## Current reference evidence + +| Reference | Value | +|---|---:| +| Prior D4 policy total net return, val+test | -42.12% | +| Prior D4 policy NAV, val+test | 0.5788 | +| Prior D4 max drawdown, val+test | -81.91% | +| Best frozen D3 baseline | `equal_weight_topk_momentum` | +| Best frozen D3 total net return | +31.37% | +| Prior D4 delta vs best D3 | -73.49pp | +| Prior D5 status | `NO-GO` | +| Prior D5 forward folds | 5 | + +These numbers are research evidence only and remain blocked by D0/D1/D3/D5 locks. + +## Frozen reward/action contract for the next implementation stories + +| Contract | Requirement | +|---|---| +| Net return after cost | Use 23bp round-trip cost by default and preserve 0/46bp as labeled sensitivity. | +| Drawdown penalty | Penalize current/realized drawdown and classify drawdown-only gains as failure. | +| Turnover/churn penalty | Penalize avoidable rebalance churn after costs. | +| Concentration penalty | Penalize overconcentration relative to configured max positions / target diversification. | +| Invalid actions | Count, penalize, and surface invalid or masked actions; do not hide them. | +| No-trade/hold | Keep no-trade/hold as a valid action/control, not an implicit failure. | +| Leading-zero codes | Preserve codes such as `000250` as strings in artifacts and UI samples. | + +## Required controls + +- `no_trade_cash` +- deterministic shuffle with recorded seed/hash +- frozen D3 best baseline, currently `equal_weight_topk_momentum` +- prior D4 policy comparison +- 23bp default cost and 0/46bp sensitivity + +## D4 evidence required before D5 + +The next D4 artifact must remain `RESEARCH_ONLY` and include: + +- `learning_curve.csv` +- `reward_breakdown.csv` +- `reward_component_summary.json` +- `action_distribution.csv` +- `invalid_actions.csv` +- `turnover.csv` +- `drawdown.csv` +- `policy_nav.csv` +- `state_observations.csv` +- `observation_manifest.json` +- `baseline_comparison.json` +- false `model_build_allowed`, `go_summary_allowed`, `paper_forward_allowed`, and `live_broker_order_allowed` +- deltas vs no-trade, deterministic shuffle, and frozen D3 + +## D5 threshold contract + +| Gate item | Frozen threshold | +|---|---| +| Forward folds | at least 5 | +| Purge / embargo | at least 5 days each unless justified in artifact | +| OOS retuning | `retuned_on_oos=false` | +| Cost sensitivity | 0bp / 23bp / 46bp | +| Controls | no-trade, deterministic shuffle, frozen D3 baseline | +| Current blocker behavior | D5 remains `NO-GO` while D0/D1/D3 blockers remain | + +Promotion preconditions remain: D0 price basis verified, D1 official/manual universe review complete, D3 no longer `WATCH`, D4 no longer underperforms D3, and D5 passes fresh OOS fold/cost/drawdown/turnover checks. + +## No-retuning / no-cherry-pick rules + +- Freeze reward terms and action space before running the new OOS workflow. +- Do not choose policies by OOS metrics. +- Do not change thresholds after seeing OOS results. +- Any material reward/action change after inspection requires a new preregistered hypothesis. +- Report failure plainly as `NO-GO` or `RESEARCH_ONLY` rather than hiding it behind a favorable chart. + +## Failure categories to classify + +| Category | Meaning | +|---|---| +| `reward_hacking` | Reward improves while actual NAV/drawdown/control deltas worsen. | +| `action_collapse` | Policy degenerates into one action or ignores no-trade/hold. | +| `drawdown_or_concentration_failure` | Return comes from unacceptable drawdown or concentrated exposure. | +| `cost_sensitivity_failure` | Edge disappears under 23bp or 46bp. | +| `fold_instability` | Few folds drive the result or worst-fold risk dominates. | +| `D0_price_basis_blocker` | Price basis remains unknown. | +| `D1_universe_blocker` | Universe remains heuristic/watch. | +| `D3_underperformance` | RL underperforms frozen D3. | +| `artifact_or_provenance_drift` | Hash/source/data/code provenance changes unexpectedly. | + +## Next executable story + +The next Ultragoal story may implement D4 portfolio environment reward and action changes. This preregistration story intentionally does not implement training or environment changes. diff --git a/docs/stom_daily_ohlcv_rl_master_restart_plan_2026-06-13.md b/docs/stom_daily_ohlcv_rl_master_restart_plan_2026-06-13.md new file mode 100644 index 000000000..66d77ac57 --- /dev/null +++ b/docs/stom_daily_ohlcv_rl_master_restart_plan_2026-06-13.md @@ -0,0 +1,200 @@ +# STOM Daily OHLCV RL Master Restart Plan (2026-06-13) + +## 0. 결론 + +이 문서는 최근 D0/D1/D3 hardening 결과와 기존 일봉 계획 문서를 합쳐, **일봉 OHLCV 기반으로 전략 후보를 찾는 연구용 모델 시스템 전체를 다시 시작하기 위한 마스터 문서**다. + +작성 결론은 명확하다. + +- 마스터 문서를 새로 정리하는 것이 맞다. 기존 문서들은 D0~D6 개별 계획/결과가 분산되어 있어, 지금부터 D4 RL 학습·보상·환경·수익률 그래프까지 확장하려면 하나의 기준 문서가 필요하다. +- 단, 이 문서의 목표는 **실거래 모델 출시**가 아니라 **반복 학습/검증 가능한 research candidate model factory** 구축이다. +- `model_build_allowed=false`는 유지한다. D0 가격 기준, D1 공식 유니버스, D5 fresh OOS gate가 통과되기 전에는 어떤 그래프도 수익 증명이 아니다. + +## 1. 기준 문서와 최신 상태 + +| 문서/근거 | 역할 | 현재 반영 상태 | +|---|---|---| +| `docs/stom_daily_ohlcv_codex_handoff_2026-06-13.md` | 현재 Daily OHLCV dashboard/API/UI handoff | D0~D9 반영 | +| `docs/stom_daily_ohlcv_db_analysis_and_page_plan_2026-06-11.md` | D0~D6 원계획 | 반영 | +| `docs/stom_daily_ohlcv_deeprl_plan_2026-06-11.md` | 일봉 DL/RL 방향 | 반영 | +| `docs/stom_daily_ohlcv_price_basis_result_2026-06-13.md` | D0 가격 기준 확인 결과 | `UNKNOWN_CONFIRMED` 반영 | +| `docs/stom_daily_ohlcv_universe_official_validation_result_2026-06-13.md` | D1 공식 유니버스 검증 경로 | `WATCH_HEURISTIC_UNIVERSE` 반영 | +| `docs/stom_daily_ohlcv_d3_baseline_hardening_result_2026-06-13.md` | D3 baseline hardening 결과 | `WATCH`, shuffle/control/delta 반영 | +| `docs/stom_daily_ohlcv_d8d9_registry_paper_forward_result_2026-06-13.md` | D8/D9 registry/paper-forward 결과 | `RESEARCH_ONLY_BLOCKED`, effective gate 반영 | + +## 2. 전체 목표 정의 + +| 구분 | 목표 | +|---|---| +| 최종 연구 목표 | 일봉 OHLCV 기반으로 스윙/종목선별/리스크 필터/포트폴리오 리밸런싱 후보 전략을 찾는다. | +| 모델 목표 | 정확한 가격 예측이 아니라 확률, 순위, 기대수익 bucket, 리스크 bucket, 포트폴리오 action policy를 만든다. | +| RL 목표 | D3 baseline을 넘는 경우에만 daily portfolio rebalance RL 후보를 연구한다. | +| 대시보드 목표 | DB → Universe → Dataset → Baseline → RL → Walk-forward → Decision까지 실패/성공 근거를 그래프로 확인한다. | +| 사용 가능 모델의 의미 | `train/evaluate/infer`가 재현되고, OOS gate를 통과해 research/paper candidate로 쓸 수 있는 모델. 실거래/브로커 주문 모델이 아니다. | +| 금지 | 수익 보장, 실거래 준비, broker/order routing, 긍정 곡선만 보고 GO, `ts_imb`를 RL이라고 부르기. | + +## 3. 현재 전체 진행률 + +| 단계 | 페이지 | 현재 상태 | 진행률 | 완료된 것 | 남은 병목 | +|---|---|---:|---:|---|---| +| D0 | Daily DB Analysis | `PASS` but `price_basis=unknown` | 80% | table/row/date/quality/split-like evidence, `UNKNOWN_CONFIRMED`, blocking implication | adjusted/raw/split/dividend 외부 근거 필요 | +| D1 | Universe Management | `WATCH_HEURISTIC_UNIVERSE` | 70% | stockinfo + heuristic 분류, official CSV ingestion contract, quarantine evidence | 공식 KRX/manual CSV 실제 투입 및 검토 | +| D2 | Dataset Builder | `PASS` | 80% | feature/label/split, leakage/split chronology evidence | 가격 기준 확정 후 label 신뢰도 재검증 | +| D3 | Prediction / Top-K Baseline | `WATCH` | 85% | no-trade, deterministic shuffle, Top-K, vol-adjusted, supervised ranker/classifier, 23bp, MDD/turnover/hit-rate/delta | D0/D1 blocker 해소 후 fresh baseline freeze | +| D4 | Portfolio RL | `RESEARCH_ONLY` | 55% | constrained env, observation/state manifest, reward/action/NAV/drawdown/turnover/action distribution visualization | D3 baseline 초과 reward/action 재설계 필요 | +| D5 | Walk-forward Gate | `NO-GO` | 55% | state-aware D4 artifact consumption, fold gate, shuffle/no-trade/cost controls, no-OOS-retuning evidence | fresh OOS, fold consistency, MDD/turnover/delta 통과 | +| D6 | Dashboard Visualization | evidence viewer | 85% | Decision cockpit, flow, glossary, overlay, heatmap, scatter, symbol preview, D0~D9 timeline | 계속되는 연구 artifact 추가 시 UI 갱신 | +| D7 | Research Lab / Explainability | diagnostics only | 35% | feature/regime/correlation/failure placeholder/fallback cards | 실제 feature/regime/correlation/failure autopsy artifact 필요 | +| D8 | Model Registry / Promotion | `RESEARCH_ONLY_BLOCKED` | 45% | config/data/code/source hashes, source runs, promotion status, effective gate blockers, unsafe artifact blocking | D0/D1/D3/D4/D5 gate 해소 전 승격 금지 | +| D9 | Paper Forward Ledger | `RESEARCH_ONLY_BLOCKED` | 40% | blocked paper-selected row, realized return/drawdown samples, drift, decision log, no-live/broker/order readiness | 실제 paper-only continuation도 D5 PASS 전 금지 | + +## 4. 전체 페이지 설계 테이블 + +| 페이지 | 이름 | 목적 | 핵심 시각화 | 주요 artifact/API | 통과 조건 | +|---|---|---|---|---|---| +| D0 | Daily DB Analysis | DB가 연구 가능한지 확인 | table/date coverage, quality flag, split-like window, price-basis blocker | `daily_ohlcv_db.py`, `/api/daily-ohlcv/db-summary`, `price_basis_audit.json` | 가격 기준이 `VERIFIED` 또는 unknown blocker가 명확히 표시됨 | +| D1 | Universe Management | 학습/평가 universe 정의 | include/exclude breakdown, official metadata status, quarantine table | `daily_ohlcv_universe.py`, `/api/daily-ohlcv/universe/preview`, `universe.json`, `quarantine.csv` | 공식/manual KRX metadata로 common equity 검증 | +| D2 | Dataset Builder | raw DB를 모델용 feature/label/split으로 변환 | split timeline, leakage report, normalization stats, blocked windows | `daily_ohlcv_dataset.py`, `feature_panel.csv`, `label_panel.csv`, `split_assignments.csv` | date split, no leakage, label/feature definitions reproducible | +| D3 | Prediction / Top-K | RL 전 강한 supervised/rule baseline 확립 | baseline table, calibration, Top-K return, hit-rate, MDD, turnover, delta vs shuffle | `daily_prediction.py`, `daily_ranker.py`, `baseline_metrics.json`, `baseline_delta_summary.json` | no-trade/shuffle/rule/supervised 비교 후 WATCH/PASS 판정 | +| D4-A | RL Environment Inspector | RL 환경이 올바른지 확인 | observation shape, action mask, holdings, invalid action, reward components | `daily_portfolio_env.py`, `env_manifest.json`, `reward_breakdown.csv` | reward/action/accounting이 테스트로 검증됨 | +| D4-B | RL Training Monitor | 실제 학습 과정 관찰 | episode reward, NAV, loss, entropy, invalid action rate, turnover, drawdown | `daily_rl_train.py`, `training_manifest.json`, `episode_metrics.csv`, `learning_curve.csv` | 학습이 재현되고 baseline보다 나쁜 경우 명확히 표시 | +| D4-C | RL Policy Evaluation | 학습 policy를 검증 split에서 평가 | equity curve, drawdown, action distribution, holdings concentration, baseline delta | `policy_metrics.json`, `positions.csv`, `baseline_comparison.json` | 23bp 후 D3 best baseline 초과 또는 `RESEARCH_ONLY` 유지 | +| D5 | Walk-forward / Gate | 과최적화 방지 | fold heatmap, cost sensitivity, shuffled control, fold consistency, gate reasons | `daily_walk_forward.py`, `walk_forward_manifest.json`, `fold_metrics.csv`, `cost_sensitivity.csv` | 사전등록 조건으로 `GO/WATCH/NO-GO` 판정 | +| D6 | Decision Cockpit | 전체 상태를 한 화면에서 판단 | D0~D5 flow, blocker list, model_build_allowed, go_summary_allowed | `/api/daily-ohlcv/charts/decision-cockpit` | 왜 모델 빌드가 잠겼는지 즉시 설명 가능 | +| D7 | Research Lab | 실패 원인/feature/regime 분석 | feature importance, regime heatmap, correlation, failure autopsy, symbol drilldown | planned `daily_research_lab.py`, current diagnostics fallback/API | 개선 가설이 사전등록 가능한 형태로 정리됨 | +| D8 | Model Registry | candidate model version 관리 | run list, config hash, data hash, code/source hash, metric summary, promotion status, effective blockers | `stom_rl/daily_registry.py`, `/api/daily-ohlcv/registry/latest`, `candidate_registry.json` | 재현 가능한 model candidate만 registry 등록; 현재는 blocked | +| D9 | Paper Forward Ledger | fresh forward/paper 검증 | blocked daily selected list, realized return, drift, drawdown, decision log | `paper_selected.csv`, `realized_returns.csv`, `drawdown.csv`, `drift.csv`, `decision_log.jsonl` | paper-only forward evidence 누적, live claim 없음 | + +## 5. D4 RL을 다시 시작하기 전 필요한 잠금 해제 조건 + +| Lock | 현재 상태 | D4 RL 진행 가능 조건 | +|---|---|---| +| Price basis lock | `UNKNOWN_CONFIRMED` | adjusted/raw/split/dividend 기준을 외부 근거로 문서화하거나 split-like windows 제외 정책 확정 | +| Universe lock | `WATCH_HEURISTIC_UNIVERSE` | `_database/krx_listed_products.csv` 또는 manual CSV로 common equity universe 검증 | +| Baseline lock | D3 `WATCH` | D3 baseline run을 freeze하고 no-trade/shuffle/rule/supervised deltas를 기준선으로 고정 | +| Walk-forward lock | D5 `NO-GO` | D4 후보가 D5 fresh OOS gate를 통과해야 model candidate로 승격 | +| Model-build lock | `model_build_allowed=false` | D0/D1/D3/D5 조건 통과 후에만 `WATCH` 또는 `GO` 검토 가능 | + +## 6. D4 RL 환경/보상/학습 그래프 설계 + +### 6.1 환경 계약 + +| 항목 | 설계 | +|---|---| +| 시점 | date-based daily rebalance | +| 후보 | D3 Top-K/ranker candidate panel | +| observation | candidate features + current holdings + cash/exposure/risk state | +| action | constrained hold/buy/add/sell/reduce; free-form continuous allocation 금지 | +| mask | 현금 부족, 보유 없음, concentration 초과, invalid symbol 등 masking | +| fill assumption | daily open/close 등 명시된 basis만 사용; 아직 broker/marketable fill 아님 | +| accounting | NAV, realized/unrealized return, turnover, concentration, MDD | + +### 6.2 Reward 공식 + +기본 reward는 다음을 출발점으로 한다. + +```text +reward = daily_nav_return + - 23bp_turnover_cost + - drawdown_penalty + - concentration_penalty + - invalid_action_penalty + - churn_penalty +``` + +| 보상 구성 | 시각화 | 실패 조건 | +|---|---|---| +| `daily_nav_return` | NAV/equity curve, daily return bar | shuffle/no-trade보다 낮음 | +| `turnover_cost` | turnover line, cumulative cost | 과도한 churn으로 수익 잠식 | +| `drawdown_penalty` | drawdown curve, MDD heatmap | MDD gate 초과 | +| `concentration_penalty` | holdings concentration / top exposure | 일부 종목 과집중 | +| `invalid_action_penalty` | invalid action rate | mask 설계 실패 | +| `churn_penalty` | rebalance count, action distribution | 의미 없는 매매 반복 | + +### 6.3 학습 그래프 + +| 그래프 | 의미 | 파일/API | +|---|---|---| +| Episode reward curve | 학습 reward 안정성 | `learning_curve.csv` / planned `/api/daily-ohlcv/rl/training/latest` | +| NAV curve | policy equity 경로 | `positions.csv`, `policy_metrics.json` | +| Drawdown curve | 리스크 누적 | `drawdown.csv` | +| Turnover/cost curve | 비용 민감도 | `turnover.csv`, `reward_breakdown.csv` | +| Action distribution | hold/buy/sell/reduce 비율 | `action_distribution.csv` | +| Invalid action rate | 환경/action mask 품질 | `invalid_actions.csv` | +| Reward component stack | 어떤 penalty가 reward를 깎는지 | `reward_breakdown.csv` | +| Baseline overlay | RL vs D3 rule/supervised/no-trade/shuffle | `baseline_comparison.json` | +| Fold heatmap | OOS stability | `fold_metrics.csv` | +| Cost sensitivity | 23bp 기본 + stress cost | `cost_sensitivity.csv` | + +## 7. 모델 후보 승격 기준 + +| 단계 | 이름 | 승격 조건 | 실패 시 | +|---|---|---|---| +| M0 | 구현 smoke | env step/reset/accounting test 통과 | env 수정 | +| M1 | train reproducible | seed/config/data hash가 같으면 주요 metrics 유사 | artifact invalid | +| M2 | baseline comparable | no-trade/shuffle/D3 best rule/supervised와 비교 가능 | D4 `RESEARCH_ONLY` 유지 | +| M3 | OOS candidate | val/test에서 D3 best baseline 초과, MDD/turnover 과도하지 않음 | reward/action 재설계 | +| M4 | walk-forward candidate | 5+ folds, fresh OOS, no retuning, cost sensitivity 통과 | D5 `NO-GO` | +| M5 | paper candidate | forward ledger에서 drift/실패 원인 추적 가능 | research-only 유지 | +| M6 | production consideration | 별도 broker/execution/latency/risk/compliance 계획 필요 | 현재 범위 밖 | + +## 8. 다음 실행 순서 + +| 순서 | 작업 | 산출물 | 권장 검증 | +|---:|---|---|---| +| 1 | 공식 가격 기준/보정 정책 확정 | updated D0 doc + `price_basis_audit.json` | `tests/test_stom_rl_daily_ohlcv_db.py` | +| 2 | 공식 KRX/manual universe CSV 투입 | `krx_listed_products.csv`, updated universe manifest | `tests/test_stom_rl_daily_ohlcv_universe.py` | +| 3 | D3 frozen baseline 재생성 | frozen `prediction_manifest.json`, checksum doc | `tests/test_stom_rl_daily_prediction.py` | +| 4 | D4 env inspector 강화 | `env_manifest.json`, `reward_breakdown.csv`, env tests | `tests/test_stom_rl_daily_portfolio_env.py` | +| 5 | D4 training monitor 구축 | `learning_curve.csv`, `episode_metrics.csv`, UI graph | 신규 training monitor tests | +| 6 | D4 policy evaluation | `policy_metrics.json`, `positions.csv`, `baseline_comparison.json` | RL gate tests | +| 7 | D5 fresh walk-forward | `fold_metrics.csv`, `cost_sensitivity.csv`, gate verdict | `tests/test_stom_rl_daily_walk_forward.py` | +| 8 | D6/D7 visualization 확장 | reward/action/env/failure-analysis cards | dashboard API/tab tests + browser check | +| 9 | D8 registry/promotion hardening | model registry row + promotion/effective gate reason + source hashes | `tests/test_stom_rl_daily_registry.py` | +| 10 | D9 paper-forward ledger hardening | blocked paper candidate ledger + drift/drawdown/decision log | registry tests + dashboard API/tab tests | + +## 9. 권장 GJC 시작 명령 + +작업을 바로 실행하기보다, 이 마스터 문서를 기준으로 새 실행 계획을 끊어 진행하는 것이 안전하다. + +```powershell +gjc ultragoal status --json +``` + +새 실행을 시작할 때 권장 brief: + +```powershell +gjc ultragoal create-goals --brief "D4 Daily Portfolio RL environment/training visualization restart based on docs/stom_daily_ohlcv_rl_master_restart_plan_2026-06-13.md. Preserve guardrails: no live/broker/orders, no profit claims, 23bp default cost, no _database mutation, model_build_allowed=false until D0/D1/D5 gates pass. Start with D4-A environment inspector, D4-B learning/reward graph artifacts, and D4-C policy evaluation against frozen D3 baselines." +``` + +개별 검증 명령: + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_rl_gate.py tests/test_stom_rl_daily_walk_forward.py -q +``` + +```powershell +py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py tests/test_stom_rl_daily_prediction.py -q +``` + +```powershell +cd webui/v2_src +npm run check +npm run build +``` + +## 10. 지금부터의 개발 원칙 + +| 원칙 | 내용 | +|---|---| +| Baseline-first | RL은 D3 baseline보다 강해야 의미가 있다. | +| Evidence-first | 그래프는 설명 자료일 뿐, 수익 증명이 아니다. | +| Gate-first | D5 fresh OOS 통과 전 모델 빌드 금지. | +| Cost-first | 모든 return/delta는 23bp 비용 후 기준. | +| Read-only dashboard | dashboard/API에서 DB, broker, order side effect 금지. | +| Reproducibility | 모든 run은 config/data/code hash와 artifact 경로를 남긴다. | +| Failure visibility | NO-GO, WATCH, failed fold, drawdown, turnover를 숨기지 않는다. | + +## 11. 한 줄 마스터 방향 + +**일봉 RL은 지금 바로 “수익 모델”을 만드는 작업이 아니라, D0/D1/D3 기준선을 고정한 뒤 D4 환경·보상·학습 그래프·정책 평가를 투명하게 만들고, D5 fresh OOS gate를 통과한 후보만 research/paper candidate로 승격하는 작업이다.** diff --git a/docs/stom_daily_ohlcv_universe_official_validation_result_2026-06-13.md b/docs/stom_daily_ohlcv_universe_official_validation_result_2026-06-13.md new file mode 100644 index 000000000..425fee639 --- /dev/null +++ b/docs/stom_daily_ohlcv_universe_official_validation_result_2026-06-13.md @@ -0,0 +1,85 @@ +# STOM Daily OHLCV Universe Official Validation Result (2026-06-13) + +## Status + +`WATCH_HEURISTIC_UNIVERSE` with official metadata ingestion path added. + +This is research-only D1 evidence. It does not claim profitability, live/broker/order readiness, or model-build readiness. + +## Source and artifacts + +- Daily DB: `_database/Stock_Database_ohlcv_1day.db` +- Local stockinfo metadata: `_database/stock_tick_back.db:stockinfo` +- Expected official/manual metadata CSV path: `_database/krx_listed_products.csv` +- Generated universe artifact: `webui/rl_runs/daily_ohlcv_universe/universe_official_watch_2026_06_13/` +- Manifest: `webui/rl_runs/daily_ohlcv_universe/universe_official_watch_2026_06_13/universe.json` +- Include/exclude CSVs: `symbols.csv`, `exclusions.csv` +- Quarantine evidence: `quarantine.csv` +- Official metadata audit: `official_metadata_audit.json` + +## Finding + +No official KRX/manual listed-product CSV was present at `_database/krx_listed_products.csv` during this run, so the current universe remains WATCH. The code now has an explicit ingestion contract for a future official/manual CSV: + +```text +code,name,market,instrument_type[,source] +``` + +Codes are handled as six-character strings to preserve leading zeros. + +| Item | Result | +|---|---:| +| Verdict | `WATCH_HEURISTIC_UNIVERSE` | +| Official metadata status | `MISSING` | +| Tables | 4,727 | +| Included symbols | 2,599 | +| Excluded symbols | 2,128 | +| stockinfo unmatched tables | 498 | +| official metadata unmatched tables | 4,727 | +| quarantine.csv rows | 575 | +| quarantine reasons | `METADATA_UNMATCHED` 232 + `ALPHANUMERIC_CODE_UNREVIEWED` 343 | + +## Implemented validation path + +The official/manual CSV path supports: + +- `common_equity` / common / stock / ordinary share inclusion for KOSPI/KOSDAQ. +- Official ETF/ETN/fund exclusion. +- Official SPAC exclusion. +- Official preferred-share exclusion. +- Official REIT exclusion. +- Unknown/missing official metadata remains quarantined or WATCH. + +Unit tests cover common-equity inclusion, ETF exclusion, SPAC exclusion, preferred-share exclusion, missing official metadata, leading-zero preservation, unsafe artifact path rejection, and quarantine artifact writing. + +## Decision + +D1 remains: + +```text +D1 = WATCH_HEURISTIC_UNIVERSE +``` + +The current `stockinfo` + name/prefix rules are still a useful preview, but not an official universe certification. D3/D4/D5 model claims must not treat this as a fully verified KRX common-stock universe until the official/manual CSV is supplied and reviewed. + +## Verification performed + +```powershell +py -3.11 -m pytest tests/test_stom_rl_daily_ohlcv_universe.py -q +``` + +Result: `15 passed`. + +```powershell +py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py tests/test_stom_rl_daily_ohlcv_universe.py -q +``` + +Result: `27 passed`. + +## Guardrails + +- No `_database/*` mutation was performed. +- No live trading, broker, or order-routing readiness is implied. +- No profit claim is made. +- `ts_imb` remains an opening-gap RULE baseline, not RL. +- Leading-zero stock codes remain strings in universe, API, and dashboard evidence. diff --git a/docs/stom_daily_ohlcv_visual_improvement_plan_2026-06-13.md b/docs/stom_daily_ohlcv_visual_improvement_plan_2026-06-13.md new file mode 100644 index 000000000..b235ad3fa --- /dev/null +++ b/docs/stom_daily_ohlcv_visual_improvement_plan_2026-06-13.md @@ -0,0 +1,241 @@ +# 일봉 OHLCV 대시보드 시각화 개선 연구·적용 계획 + +**작성일:** 2026-06-13 KST 관측 기준 +**대상:** Kronos `Daily OHLCV` 대시보드 (`http://127.0.0.1:58185/daily-ohlcv`) +**참고:** STOM 개발 대시보드 (`http://127.0.0.1:8770/ui/`, `C:/System_Trading/STOM/STOM_V.wt-dev`) +**상태:** 적용 계획 보고서. 아직 수익모델 GO가 아니며, 실거래·브로커·주문 준비 상태가 아니다. + +## 1. 결론 요약 + +현재 Kronos 일봉 대시보드는 D0~D6 증거를 정직하게 노출하지만, 시각화 밀도가 낮다. 표와 KPI 중심이라서 사용자가 다음 질문에 빠르게 답하기 어렵다. + +- 어떤 단계가 병목인지? +- 어떤 전략/모델이 왜 탈락했는지? +- RL이 베이스라인보다 얼마나, 어느 구간에서 밀렸는지? +- 워크포워드 fold별 일관성이 있는지? +- 유니버스/가격 보정/누수 위험이 어디에 몰렸는지? +- 다음 실험 우선순위가 무엇인지? + +STOM 대시보드는 이 문제를 해결하기 위한 좋은 레퍼런스를 이미 갖고 있다. 특히 **진행도 스트립, 단계 플로우, 적합도/수익/품질 추이, 전 전략 누적곡선, 백테 상세, 세대 이력, Run Compare, Edge/Feature/Correlation 연구랩, Research Wiki, AI Context Pack, 부검/피드백 패널**이 강점이다. Kronos에는 이를 그대로 복사하지 말고, 일봉 연구의 정직성 규칙에 맞게 **검증·실패·잠금 중심 시각화**로 변환해 반영하는 것이 맞다. + +최우선 개선 방향은 다음 4개다. + +1. **Decision cockpit**: `NO-GO` 이유를 한 화면에서 원인별로 분해한다. +2. **Equity/Drawdown/Benchmark overlay**: D3 baseline, D4 RL, D5 selected strategy를 같은 축에서 비교한다. +3. **Walk-forward matrix**: fold × metric heatmap으로 일관성·불안정 구간을 보여준다. +4. **Research lab tabs**: edge, feature importance, correlation, validation, failed candidates를 분리해 탐색 가능하게 만든다. + +## 2. 확인한 현재 상태 + +### 2.1 Kronos Daily OHLCV 대시보드 + +관측 URL: `http://127.0.0.1:58185/daily-ohlcv` + +현재 화면 섹션: + +| 영역 | 현재 제공 | 한계 | +|---|---|---| +| D0-D6 진행 상태 | PASS/WATCH/RESEARCH_ONLY/NO-GO 카드 | 단계별 원인·영향 관계가 약함 | +| DB 분석 | 테이블 수, 행 수, 가격 기준 unknown, 품질 플래그 | 히트맵/분포/기간 커버리지 시각화 부족 | +| 유니버스 | 포함/제외 수, 제외 사유, 미리보기 | market/type/exclusion 관계가 시각적으로 약함 | +| 데이터셋 | split bar, leakage/normalization 상세 | feature/label 품질, split leakage 리스크가 표 중심 | +| D3-D6 모델 증거 | baseline list, RL 비교, gate reasons, fold table | 수익곡선·드로우다운·fold heatmap·원인 분해 부족 | +| 종목 상세 | 선택 시 OHLCV 샘플 | 아직 캔들/수익률/결측/급등락 시각화 없음 | +| artifact registry | 생성 파일 표 | run 비교/계보/검증 패키지화 부족 | + +현재 판정은 유지해야 한다. + +| 항목 | 현재 판정 | +|---|---| +| overall | `D0_D6_EVIDENCE_VISIBLE_MODEL_BUILD_NO_GO` | +| D3 | `WATCH` | +| D4 | `RESEARCH_ONLY` | +| D5 | `NO-GO` | +| D6 | `PASS` | +| model_build_allowed | `false` | +| go_summary_allowed | `false` | + +### 2.2 STOM 대시보드에서 관측한 강점 + +관측 URL: `http://127.0.0.1:8770/ui/` +스크린샷: `.omx/artifacts/stom_ui_review_viewport_2026_06_13.png` + +STOM 화면/소스에서 확인한 주요 구성: + +| STOM 요소 | 관측 내용 | Kronos 적용 방향 | +|---|---|---| +| 상단 상태/진행도 스트립 | run_id, provider, timeframe, progress, start/stop | Daily 연구 run selector + latest artifact selector + progress summary | +| Research Criteria | OOS disabled 등 연구 모드 명시 | `model_build_allowed=false` 사유와 guardrail banner 강화 | +| Metric Glossary | OOS, overfit, MDD, payoff, edge ratio 설명 | D3/D4/D5 지표 glossary를 Daily 탭에 추가 | +| Active Strategy | 현재 전략 source, code/diff 상태 | D3 baseline/D4 policy/D5 selected policy lineage 카드 | +| Process Flow | Generate → Backtest → Score → Autopsy → Iterate | D0 DB → D1 Universe → D2 Dataset → D3 Baseline → D4 RL → D5 Gate → D6 Dashboard flow | +| Fitness Trajectory | score, best-so-far, gate-passed 라인 | baseline score / RL score / gate status trajectory | +| Profit Trajectory | 수익률·수익금 듀얼축 | D3/D4/D5 net return overlay, 단 profit proof 아님 표기 | +| Equity Overlay | 전체 전략 누적곡선 + winner 강조 | D3 baseline curves, D4 RL curve, D5 selected strategy curve 비교 | +| Backtest Detail | 일별손익, 동시보유, 누적수익 | Daily portfolio exposure, turnover, drawdown 상세 | +| Quality Metrics | Calmar, R², MDD, trades, payoff 추이 | drawdown, turnover, hit-rate, fold consistency, calibration 추이 | +| Hall of Fame | 인간/시드/AI 비교 | Daily baseline leaderboard, 단 수익 보장 금지 | +| Generations Table | 세대별 점수, MDD, trades, reasons | experiment/run ledger table with PASS/WATCH/NO-GO reasons | +| Run Compare Console | runs=193, selected=6, normalize compare | Daily artifact compare console | +| Generation Analytics | multi metric, scatter, top table | D3/D4/D5 run scatter: return vs MDD, turnover vs return | +| Edge Ratio 분석 | time×cap heatmap, edge histogram | daily feature segment heatmap: market/cap/volatility/regime | +| Research Wiki | 연구 문서, 실패 후보, 다음 실험 | docs 결과/사전등록/NO-GO 문서 연결 | +| AI State Context | copyable context pack | next experiment context pack, reproduction command pack | +| Autopsy/Feedback | 실패 이유와 다음 세대 전달 | NO-GO root-cause autopsy + next validation checklist | + +## 3. 적용 가능한 개선 기능 목록 + +### 3.1 즉시 반영 가치가 큰 시각화 + +| 우선순위 | 기능 | 설명 | 필요한 데이터 | 산출 UI | +|---|---|---|---|---| +| P0 | Decision Cockpit | model_build_allowed=false 원인을 가격/유니버스/RL/워크포워드/비용/누수로 분해 | `gate_verdict.json`, D2 manifest, D3/D4/D5 verdict | 원인 카드 + severity 색상 + 해결 조건 | +| P0 | D0-D6 Flow Map | STOM Process Flow처럼 단계 인과를 화살표로 표시 | `/api/daily-ohlcv/progress` | DB→Universe→Dataset→Baseline→RL→Gate→Dashboard | +| P0 | Metric Glossary | NO-GO, WATCH, RESEARCH_ONLY, MDD, turnover, hit-rate, calibration 설명 | 정적 텍스트 + API labels | 접이식 glossary | +| P1 | Baseline/RL Equity Overlay | D3 baseline, D4 RL, D5 selected 전략 누적곡선 비교 | D3 positions/predictions, D4 episode/positions, D5 fold metrics | 누적수익곡선, DD overlay | +| P1 | Walk-forward Heatmap | fold × metric 색상표 | `fold_metrics.csv`, `cost_sensitivity.csv`, `shuffle_control.csv` | fold consistency heatmap | +| P1 | Return/MDD Scatter | 전략/run별 return vs MDD 산점도 | D3 baseline metrics, D4 policy metrics, D5 fold aggregate | 우상향 착시 방지 산점도 | +| P1 | Cost Sensitivity Fan | 0/23/46bp 결과 비교 | D5 cost sensitivity | cost stress line/bar | +| P2 | Universe Treemap/Bar | 포함/제외/미매칭/ETF·ETN 제외 사유 시각화 | universe manifest | stacked bar / treemap | +| P2 | DB Coverage Calendar | 날짜별 테이블 수/결측/급등락 창 | db summary, blocked_windows | calendar heatmap | +| P2 | Feature/Label Quality Lab | feature distribution, forbidden feature, label horizon 분포 | D2 panels/stats | mini histogram/bar | +| P3 | Research Wiki Bridge | 관련 docs 결과/사전등록/NO-GO 문서 노출 | docs index | 문서 카드/검색 | +| P3 | Experiment Context Pack | 다음 실험 복사용 context | artifact manifests + verdicts | copy button | +| P4 | Symbol Drilldown Chart | 선택 종목 OHLCV 캔들, return, volume, anomaly flags | symbol API | lightweight/SVG chart | + +### 3.2 STOM 아이디어를 Kronos에 맞게 변환할 때의 원칙 + +| STOM 원형 | Kronos 변환 원칙 | +|---|---| +| 수익 추이 | “수익 가능성”이 아니라 “검증 곡선/실패 곡선”으로 표시 | +| Winner / Hall of Fame | “우승” 대신 “현재 best baseline / blocked candidate”로 표시 | +| Start/Stop 버튼 | Daily 대시보드는 read-only 유지. 실행 버튼 금지 | +| Active Strategy | 정책 선택이 아니라 artifact lineage와 사전등록 여부 표시 | +| Run Compare | cherry-pick 방지를 위해 비용·fold·shuffle control 동시 표시 | +| AI State Context | 다음 실험 재현용 command/context pack만 제공, 매수 추천 문구 금지 | + +## 4. 적용 설계안 + +### 4.1 Backend/API 확장 + +현재 `webui/daily_ohlcv_dashboard.py`는 chart payload를 제공하지만 아직 차트에 충분한 시계열을 주지 않는다. 아래 API를 추가하거나 기존 chart endpoint를 확장한다. + +| API | 목적 | 응답 핵심 필드 | +|---|---|---| +| `/api/daily-ohlcv/charts/decision-cockpit` | NO-GO 원인 분해 | blockers, severity, required_fix, evidence_ref | +| `/api/daily-ohlcv/charts/flow` | D0-D6 인과 플로우 | nodes, edges, status, evidence | +| `/api/daily-ohlcv/charts/equity-overlay` | D3/D4/D5 누적곡선 비교 | curves[{name, kind, points}], guardrail | +| `/api/daily-ohlcv/charts/walk-forward-heatmap` | fold × metric matrix | rows, cols, cells, legend | +| `/api/daily-ohlcv/charts/cost-sensitivity` | 0/23/46bp 민감도 | series by strategy/fold | +| `/api/daily-ohlcv/charts/run-scatter` | return/MDD/turnover 산점도 | points[{x_mdd,y_return,size_trades,color_status}] | +| `/api/daily-ohlcv/charts/universe-breakdown` | 유니버스 분해 | include/exclude by reason/market/type | +| `/api/daily-ohlcv/charts/symbol/` | 종목 상세 차트 | ohlcv, returns, volume, flags | + +모든 API는 다음을 지켜야 한다. + +- GET-only/read-only. +- run id fixed-root 검증 유지. +- sample/point limit bounded. +- `_database` 원본 DB mutation 금지. +- 응답에 `guardrail` 포함. +- 가격 기준 unknown이면 chart title/legend에 `PRICE_BASIS_UNKNOWN` 표시. + +### 4.2 Frontend 컴포넌트 확장 + +현재 `webui/v2_src/src/tabs/dailyOhlcv/` 아래 컴포넌트를 늘리는 방식이 가장 안전하다. + +| 신규 컴포넌트 | 역할 | STOM 참고 | +|---|---|---| +| `DailyDecisionCockpitCard.svelte` | NO-GO root cause 및 해결 조건 | Research Criteria, Winner/Best | +| `DailyFlowMap.svelte` | D0-D6 phase graph | ProcessFlowPanel | +| `DailyMetricGlossary.svelte` | 지표/상태 해설 | ResearchGlossaryPanel | +| `DailyEquityOverlayChart.svelte` | D3/D4/D5 곡선 비교 | EquityOverlayChart, ProfitChart | +| `DailyWalkForwardHeatmap.svelte` | fold matrix | EdgeRatio heatmap, BtHeatmap | +| `DailyRunScatter.svelte` | return vs MDD scatter | EaScatterChart | +| `DailyUniverseBreakdownChart.svelte` | universe include/exclude 시각화 | Run Compare/summary bars | +| `DailyResearchWikiCard.svelte` | 관련 docs/결과 연결 | ResearchWikiPanel | +| `DailyContextPackCard.svelte` | 다음 실험 context 복사 | AIContextPanel | + +UI 배치는 다음이 좋다. + +1. Hero + guardrail banner. +2. Decision Cockpit. +3. D0-D6 Flow Map. +4. Evidence Overview: DB/Universe/Dataset 요약. +5. Model Evidence Lab: Equity overlay, Return/MDD scatter, Walk-forward heatmap. +6. Cost/Shuffle Controls. +7. Universe/Data Quality Lab. +8. Research Wiki + Context Pack. +9. Artifact Registry. + +## 5. 적용 단계별 계획 + +| 단계 | 목표 | 파일/영역 | 완료 조건 | +|---|---|---|---| +| V1 | Decision/Flow/Glossary 추가 | `daily_ohlcv_dashboard.py`, `DailyOhlcvTab.svelte`, 신규 Svelte 3개 | NO-GO 원인, D0-D6 flow, 지표 설명이 한 화면에 보임 | +| V2 | Equity overlay + scatter | 신규 chart API, `DailyEquityOverlayChart.svelte`, `DailyRunScatter.svelte` | D3 baseline/D4 RL/D5 selected 비교가 시각화됨 | +| V3 | Walk-forward heatmap + cost sensitivity | D5 chart endpoint 확장, `DailyWalkForwardHeatmap.svelte` | fold별 약점과 23bp 비용 압력이 한눈에 보임 | +| V4 | Universe/DB quality visual lab | universe/db chart endpoint 확장 | ETF/ETN 제외, 미매칭, 가격 unknown 리스크가 그래프로 보임 | +| V5 | Research Wiki + Context Pack | docs index API 또는 정적 mapping, context card | 다음 실험 계획을 복사 가능한 형태로 제공 | +| V6 | Symbol drilldown candle/return chart | symbol chart API + chart component | 000250 등 개별 종목 OHLCV/volume/anomaly 시각화 | + +## 6. 우선 개발 권장안 + +가장 먼저 V1~V3를 개발하는 것이 맞다. 이유는 현재 사용자가 가장 크게 느끼는 문제인 “시각화 요소가 너무 없음”을 직접 해결하면서도, 수익 과장 위험을 낮추기 때문이다. + +### 권장 1차 개발 묶음 + +| 개발 묶음 | 포함 기능 | 이유 | +|---|---|---| +| V1 Decision Pack | Decision Cockpit, Flow Map, Glossary | 지금 NO-GO 이유를 가장 빠르게 이해 가능 | +| V2 Comparison Pack | Equity Overlay, Return/MDD Scatter | RL이 baseline보다 왜 약한지 시각적으로 확인 | +| V3 Gate Pack | Walk-forward Heatmap, Cost Sensitivity | 실제 모델 빌드 잠금 해제 전 필요한 증거 확인 | + +### 1차 개발 완료 후 기대 화면 + +- 상단에서 `NO-GO` 원인 5개가 severity와 해결조건으로 보인다. +- D0→D6 pipeline이 STOM처럼 흐름도로 표시된다. +- baseline/RL/gate 결과가 한 곡선·산점도·히트맵에서 비교된다. +- “우상향처럼 보이는지”가 아니라 “비용/드로우다운/fold에서 살아남는지”를 보게 된다. +- dashboard는 여전히 read-only이며 실행/주문 버튼은 없다. + +## 7. 검증 계획 + +| 검증 | 명령/방법 | +|---|---| +| API 단위 | `py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py -q` | +| 탭/마커 | `py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_tab.py tests/test_v2_route.py -q` | +| 전체 daily 회귀 | 기존 D0-D6 daily test bundle | +| frontend check | `cd webui/v2_src && npm run check` | +| frontend build | `cd webui/v2_src && npm run build` | +| 브라우저 QA | `/daily-ohlcv`에서 Decision/Flow/Equity/Heatmap marker와 NO-GO guardrail 확인 | + +브라우저 QA에서 반드시 확인할 문구: + +- `model_build_allowed=false` +- `go_summary_allowed=false` +- `RESEARCH_ONLY` +- `NO-GO` +- `PRICE_BASIS_UNKNOWN` +- `UNIVERSE_WATCH_HEURISTIC` +- `no live/broker/orders` + +## 8. 하지 말아야 할 것 + +- STOM의 “Winner/Hall of Fame” 표현을 Kronos에 그대로 가져와 수익모델처럼 보이게 만들면 안 된다. +- Daily OHLCV 화면에서 학습 실행, 주문, 브로커 연결 버튼을 만들면 안 된다. +- D4 RL 결과를 baseline보다 좋게 포장하면 안 된다. 현재 D4는 `RESEARCH_ONLY`이고 D5는 `NO-GO`다. +- price_basis unknown과 universe WATCH를 작은 글씨로 숨기면 안 된다. +- dashboard visual을 수익성 증거로 쓰면 안 된다. + +## 9. 적용 후 전체 방향 + +이번 개선은 “수익 모델 생성”이 아니라 “수익 모델 후보를 만들기 전에 실패 원인과 검증 조건을 빠르게 보는 시각화 플랫폼”을 강화하는 일이다. 성공 조건은 화려한 그래프가 아니라 다음이다. + +1. 실패가 더 빨리 보인다. +2. 원인과 다음 실험이 더 명확하다. +3. baseline 대비 RL의 약점이 숨겨지지 않는다. +4. fresh OOS/forward 검증 전에는 절대 GO로 보이지 않는다. +5. 사용자가 다음 개발 우선순위를 대시보드만 보고 결정할 수 있다. + +이 기준으로 보면 STOM의 시각화 철학은 Kronos에 충분히 반영할 가치가 있다. 단, Kronos에서는 “성과 전시”가 아니라 “검증·잠금·부검” 중심으로 바꿔 적용해야 한다. diff --git a/docs/stom_dashboard_kst_time_update.md b/docs/stom_dashboard_kst_time_update.md new file mode 100644 index 000000000..0033931aa --- /dev/null +++ b/docs/stom_dashboard_kst_time_update.md @@ -0,0 +1,72 @@ +# STOM/Kronos 웹 대시보드 KST 시간 표준화 작업 기록 + +작성일: 2026-05-12 (KST) + +## 목표 + +사용자가 `http://127.0.0.1:5070/training`에서 보는 학습 진행 정보와 다른 웹 대시보드의 학습 요약 정보를 모두 한국 시간 기준으로 이해할 수 있게 만드는 것이 목표다. + +핵심 요구사항은 다음과 같다. + +1. `/training` 학습 모니터의 갱신 시간, 최신 progress 시간, ETA 기반 완료 예상 시각을 KST로 표시한다. +2. `/stom` 성과/예측 대시보드 상단 학습 상태 스트립에도 KST 갱신 시간과 완료 예상 시각을 표시한다. +3. `/` 기본 Kronos 예측 UI 상단 학습 요약 카드에도 KST 갱신 시간과 완료 예상 시각을 표시한다. +4. 기존 학습 프로세스, DB, CUDA/PyTorch 환경, 학습 산출물은 절대 변경하지 않는다. + +## 변경 범위 + +수정한 파일은 웹 표시 계층과 테스트에 한정했다. + +- `webui/templates/training_dashboard.html` + - `Asia/Seoul` 고정 KST 포맷 헬퍼 추가 + - ETA 초 단위를 `완료 예상 시각(KST)`로 변환 + - 마지막/다음 새로고침 시각을 KST로 표시 + - 전체 진행, 단계별 진행, artifact 갱신, GPU 생성 시각, history 갱신 시각을 KST로 표시 +- `webui/templates/stom_dashboard.html` + - 상단 학습 스트립에 `Finish(KST)` 셀 추가 + - KST 시간대 게이트(`stomKstGate`) 추가 + - API의 UTC/ISO 시간과 ETA를 KST 표시로 변환 +- `webui/templates/index.html` + - 기본 예측 UI 상단 학습 요약 카드에 `Finish(KST)` 셀 추가 + - 학습 갱신 시간, 완료 예상 시각, 비교 테이블/슬라이더 날짜 표시를 KST 기준으로 변환 +- `tests/test_training_monitor.py` + - 라우트 HTML에 KST 헬퍼/필드가 포함되는지 확인하는 회귀 테스트 추가 + +## 현재 live 검증 결과 + +검증 시각 기준 live API 상태는 다음과 같았다. + +- URL: `http://127.0.0.1:5070/api/training/status` +- 상태: `running` +- 단계: `tokenizer` +- step: `1,611,000 / 4,701,721` +- 전체 진행률: `17.132%` +- ETA: `192,354.29초` +- 계산된 완료 예상: `2026-05-14 23:01:04 KST` + +HTTP marker 검증: + +- `/training`: `Asia/Seoul / KST`, `formatKstDateTime`, `formatKstEtaTarget`, `Finish time(KST)` 확인 +- `/stom`: `stomTrainingFinish`, `stomKstGate`, `formatKstDateTime`, `Asia/Seoul / KST` 확인 +- `/`: `trainingInlineFinish`, `formatKstDateTime`, `Finish(KST)` 확인 + +## 테스트/검증 명령 + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py -q +node --check .omx\tmp\js-check\training_dashboard.js +node --check .omx\tmp\js-check\stom_dashboard.js +node --check .omx\tmp\js-check\index.js +``` + +결과: + +- `10 passed in 2.35s` +- 3개 템플릿에서 추출한 JavaScript 모두 `node --check` 통과 +- live HTTP 3개 페이지 모두 KST marker 확인 + +## 주의사항 + +- 이번 변경은 표시 기준을 바꾼 것이며 학습 데이터, 학습 루프, checkpoint, predictor, CUDA 설정에는 영향을 주지 않는다. +- ETA는 API가 제공하는 `eta_seconds`를 현재 브라우저/서버 요청 시각 기준으로 더해 계산하므로, 학습 속도 변화에 따라 매 새로고침마다 변할 수 있다. +- 브라우저 화면에서 보이는 `KST`는 `Asia/Seoul` timezone을 명시적으로 지정해 생성한다. 로컬 PC의 Windows 시간대 설정과 무관하게 KST로 표시되도록 했다. diff --git a/docs/stom_dashboard_safe_parallel_final_code_review.md b/docs/stom_dashboard_safe_parallel_final_code_review.md new file mode 100644 index 000000000..779f99d17 --- /dev/null +++ b/docs/stom_dashboard_safe_parallel_final_code_review.md @@ -0,0 +1,226 @@ +# STOM ?? ???? read-only ?? ?? code-review + +???: 2026-05-12 KST +?? ??: `aed6967` ?? STOM full training ???? ?? ?? 1~5?? +?? ??: ?? ?? ?? STOM full training? ???? ??, `/training`, `/`, `/stom`?? ?? ??/?? ??/artifact/GPU/ETA ??? ?? ?? ???? ?? ? + +## 1. ?? ?? + +```text +?? ??: APPROVE +Architectural Status: CLEAR +CRITICAL: 0 +HIGH: 0 +MEDIUM: 0 +LOW: 3? ??? ?? ?? +``` + +1~5?? ??? ?? ??? ?????. + +- ?? ?? ??? ???? ?????. +- DB, finetune ???, CUDA/PyTorch ??? ???? ?????. +- ????/API? progress JSON, stdout log, artifact directory, nvidia-smi ??? ?? ????? ?????. +- predictor ?? ??? ???/???/??? ?? ?? ?? ??? ???? ??? readiness gate? ??????. +- `/training`? ?? ???, `/stom`? ?? ?? ?? ?? gate, `/`? ?? inline ???? ?? ??? ?????. + +## 2. ??? ?? commit + +```text +bf48ebe ?? ?? ?? ??? ???? ???? +464e622 ?? ??? ?? ??? ?? ???? ???? +cab7798 ?? ?? ????? ?? ???? ???? +3b64a7f ?? ??? GPU ??? ??? ???? ?? +cb6f59d ?? ???? predictor ???? ???? ?? ?? +``` + +?? ?? ??: + +```text +13 files changed, 1908 insertions(+), 31 deletions(-) +``` + +?? ??: + +- `webui/app.py` +- `webui/training_monitor.py` +- `webui/templates/training_dashboard.html` +- `webui/templates/stom_dashboard.html` +- `webui/templates/index.html` +- `tests/test_training_monitor.py` +- `docs/stom_dashboard_safe_parallel_improvement_step1.md` ~ `step5.md` + +## 3. ????? ?? + +### 3.1 ?? ?? ?? + +??: PASS + +- ??? Flask route/API/template/test/doc ?? ?????. +- ?? ?? ?? ?? ????, DB, finetune output ??? ???? ?????. +- `training_monitor.py`? `finetune/outputs` ?? ??? ??, ?? ?? ? tail/scan? ?????. + +### 3.2 predictor ?? ? ?? ?? ?? + +??: PASS + +?? ??: + +- `webui/app.py`? `build_training_readiness()`? tokenizer/predictor/completed ??? ?? ???? ?????. +- `/api/training/status`? readiness? ?????. +- `/training`?? `trainingReadinessCard`, `trainingArtifactCard`? ????. +- `/stom`?? `stomPerformanceGate`, `stomPredictorGate`, `stomCheckpointGate`, `stomRuntimeGate`? ????. +- ?? live ????? `performance_ready: false`, `predictor_started: false`, `predictor_complete: false`???. + +### 3.3 artifact readiness + +??: PASS + +- `/api/training/artifacts`? checkpoint/model weight count? tokenizer/predictor? readiness? ?????. +- ?? live ????? checkpoint/model weight? 0???? `checkpoint ??`? ?????. +- predictor ??? ??? `/training`, `/stom` ???? ??/locked? ?????. + +### 3.4 progress history + +??: PASS + +- `/api/training/history`? stdout log?? ?? train step? ?????. +- ????? `stage=predictor`? ????? predictor stdout? ??? tokenizer log? fallback?? ????. +- ?? ???? ???? ????. + +### 3.5 GPU/ETA/?? ?? + +??: PASS + +- `/training`? runtime summary? GPU summary? ???????. +- power draw? `Not Supported`?? ???? ??? ?? `?? ??`? ?????. +- power limit, VRAM, utilization? read-only `nvidia-smi` ??? ?????. + +## 4. ??/?? ?? + +### 4.1 ?? ??? + +??: PASS + +- `resolve_run_dir()`? `_safe_run_name()`? `_is_relative_to()`? run path traversal? ?????. +- `tail_training_log()`, `load_training_history()`, artifact scan? run directory ???? ?????. +- ?? ???? ?????. + +### 4.2 XSS/HTML injection + +??: PASS for new training widgets / WATCH for existing `/stom` artifact rendering + +- ?? ??? `/training` ???? `escapeHtml()` ?? `textContent`? ?????. +- ?? ??? `/stom` gate? `textContent`? ?????. +- ?, `/stom` ?? ????/?? artifact ????? ??? API payload? innerHTML? ?? ?? ??? ????. ?? ???? ?? ?? ??? ????, ??/?? artifact? ??? ???? ??? ?? sanitize pass? ?????. + +## 5. ?? ?? + +```powershell +C:\Python4\Python3119\python.exe -m pytest tests est_training_monitor.py tests est_training_progress.py -q +# 13 passed in 2.22s + +C:\Python4\Python3119\python.exe -m compileall webui +# ?? + +git diff --check aed6967..HEAD +# ?? +``` + +Live HTTP ??: + +```text +/training?refresh_interval=10 +- trainingReadinessCard: true +- trainingArtifactCard: true +- runtimeSummaryCard: true +- gpuSummaryMetrics: true +- historyRows: true + +/stom?refresh_interval=10 +- stomPerformanceGate: true +- stomPredictorGate: true +- stomCheckpointGate: true +- stomRuntimeGate: true +- stomTrainingArtifacts: true + +/?refresh_interval=10 +- trainingInlinePanel: true +- trainingInlineReadiness: true +- trainingInlineAutoStatus: true +``` + +Live API ???: + +```text +status: running +stage: tokenizer +step: 1,389,000 / 4,701,721 +tokenizer ???: 29.5424% +?? both-stage ???: 14.7712% +samples/sec: ? 65.00 +ETA: ? 203,848? +readiness.performance_ready: false +readiness.predictor_started: false +readiness.predictor_complete: false +checkpoint_file_count: 0 +model_weight_file_count: 0 +GPU: RTX 4080 SUPER, util ? 34%, VRAM ? 20.22%, power draw ?? ??, power limit 320W +``` + +## 6. ?? ?? + +### CRITICAL (0) + +??. + +### HIGH (0) + +??. + +### MEDIUM (0) + +??. + +### LOW / ?? ?? (3) + +1. `/stom` ?? artifact table innerHTML sanitize pass + - ??: ?? gate ??? ????? ?? artifact ???? payload ???? ??? HTML? ?? ??? ?? ????. + - ??: ?? cleanup ???? `/stom` artifact ??? helper? escape ???? ?????. + +2. ?? ???? JS ?? ?? ?? + - ??: ?? ??? HTTP marker/API/test ?????. + - ??: ?? ? Browser Use ?? Playwright? `/training`, `/stom`, `/`? JS fetch ? DOM ???? ?????. + +3. ??? ?? ? polling ?? ?? + - ??: `/api/training/gpu`? `nvidia-smi`? ????? ?? ?? refresh interval??? ??? ? ? ????. + - ??: ?? clamp? 2???, ??? ???? ???? GPU status cache TTL? ?????. + +## 7. ???? ?? + +Architectural Status: CLEAR + +??: + +- ?? readiness? `app.py`? ?? ???? ?????. +- ??/??/artifact ??? `training_monitor.py`? ???? ????. +- `/training`? ?? ???, `/stom`? ?? ?? gate, `/`? ?? ?? ??? ??? ?????. +- ?? ??? ???? ?? ? ?? ?? ??/?? ??? ????. + +## 8. ?? ??? + +```text +1?? ?? readiness UI/API [??????????] 100% +2?? read-only artifacts API [??????????] 100% +3?? ??? ???? ?? [??????????] 100% +4?? GPU/ETA/?? ?? ?? [??????????] 100% +5?? /stom ?? ?? ?? ?? [??????????] 100% +6?? ?? code-review ?? [??????????] 100% +????? ??? ?? [??????????] 100% +?? ??? ?? ??? [??????????] 14.77% +``` + +## 9. ?? ?? OMX ?? + +```text +$ralph ?? ?? ?? STOM full training? ???? ??, ????? /api/training/status, /api/training/history, /api/training/artifacts? ??? tokenizer ??? predictor ?? ??? ???????. predictor? ???? /training ? /stom gate? training ??? ????? live HTTP? ????, predictor checkpoint? ??? ??? ??? vs ??? ?? ???? ?? ??? ?????. +``` diff --git a/docs/stom_dashboard_safe_parallel_improvement_plan.md b/docs/stom_dashboard_safe_parallel_improvement_plan.md new file mode 100644 index 000000000..198b85bc6 --- /dev/null +++ b/docs/stom_dashboard_safe_parallel_improvement_plan.md @@ -0,0 +1,284 @@ +# STOM 학습 유지 상태의 웹 대시보드 병렬 개선 계획 + +작성일: 2026-05-12 KST +실행 모드: `$ralplan` 안전 계획 단계 +대상 repo: `D:\Chanil_Park\Project\Programming\Kronos` + +## 1. 목표 + +현재 실행 중인 STOM 2025 pred60 Kronos-small 전체 학습을 중단하지 않고 유지하면서, 사용자가 웹에서 학습 상태와 향후 예측 성과를 더 쉽게 확인할 수 있도록 대시보드/프론트엔드 통합을 단계적으로 개선한다. + +핵심 목표: + +1. 학습 프로세스는 계속 `running` 상태로 유지한다. +2. `/training`, `/`, `/stom`의 학습 상태 표시를 공통 UI/공통 기준으로 정리한다. +3. checkpoint/predictor 전환 전에는 성과 지표를 표시하지 않고 “아직 학습 중”임을 명확히 보여준다. +4. 진행률 히스토리, ETA, GPU, checkpoint, predictor 상태를 웹에서 읽기 쉽게 표시한다. +5. 모든 변경은 테스트 후 Lore commit으로 남긴다. + +## 2. 현재 live 학습 기준선 + +계획 작성 시점의 live API 관측: + +| 항목 | 값 | +|---|---:| +| status | running | +| stage | tokenizer | +| step | 1,168,000 / 4,701,721 | +| tokenizer 진행률 | 24.8420% | +| 전체 both-stage 진행률 | 12.4210% | +| checkpoint/predictor | 아직 전 | + +```text +학습 산출물 기준 전체 진행률 [██░░░░░░░░░░░░░░░░░░] 12.42% +tokenizer 진행률 [█████░░░░░░░░░░░░░░░] 24.84% +predictor 진행률 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 3. RALPLAN-DR 요약 + +### 원칙 + +1. **학습 불간섭**: running 학습 프로세스, CUDA/PyTorch 환경, `finetune/outputs` 현재 run은 수정하지 않는다. +2. **읽기 전용 관측 우선**: API/대시보드는 progress/log/artifact를 읽기만 한다. +3. **완료 전 성과 오해 방지**: checkpoint/predictor 전에는 예측 정확도·수익률을 표시하지 않는다. +4. **작은 commit 단위**: 공통 UI, API, 차트, 검증을 분리해 되돌리기 쉽게 만든다. +5. **테스트 우선 보호**: route/template/API 테스트를 갱신하고 live HTTP 확인을 병행한다. + +### 결정 동인 + +1. 사용자가 CMD가 아닌 웹에서 학습 진행률/시간/장비 상태를 보고 싶어 한다. +2. 학습이 장시간 진행 중이므로 환경 변경이나 무거운 병렬 작업은 위험하다. +3. 향후 predictor 완료 후 실제값 vs 예측값 비교 대시보드로 자연스럽게 연결되어야 한다. + +### 선택지 비교 + +| 선택지 | 장점 | 단점 | 판단 | +|---|---|---|---| +| A. 모니터링만 계속 | 안전함 | UI 성숙도 개선이 느림 | 단기 유지에는 가능하지만 사용자 목표에 부족 | +| B. 읽기 전용 웹 개선 병행 | 학습 방해 없이 UX 개선 가능 | 작업 범위 관리 필요 | 채택 | +| C. 학습 코드/환경까지 동시 개선 | 근본 성능 개선 가능성 | running 학습 중단/재현성 손상 위험 | 현재 단계에서는 기각 | + +### ADR + +- Decision: **B. 읽기 전용 웹 개선 병행**을 채택한다. +- Drivers: 학습 유지, 웹 가시성 향상, 향후 성과 대시보드 연결. +- Alternatives rejected: 학습 코드/환경 동시 변경은 running 학습 안정성을 해칠 수 있어 기각. +- Consequences: 우선 웹/API/문서 중심으로 개선하며, 학습 완료 후 predictor 성과 검증 기능을 확장한다. +- Follow-ups: checkpoint 생성 후 예측 CSV/실제값 비교 화면을 활성화한다. + +## 4. 단계별 실행 계획 + +| 단계 | 목적 | 주요 파일 | 검증 | commit 기준 | +|---|---|---|---|---| +| 0. live 학습 보호 | 현재 학습 상태 기준선 고정 | docs | live API, git clean | 계획 commit | +| 1. 공통 학습 상태 표시 정리 | `/`, `/stom`, `/training` 중복 표시 기준 정리 | `webui/app.py`, templates | pytest route/API | 공통 표시 commit | +| 2. read-only artifacts API | checkpoint/predictor 준비 여부를 별도 API로 노출 | `webui/app.py`, tests | artifact count test | API commit | +| 3. 진행률 히스토리 표시 | progress JSON/log 기반 간단 히스토리 카드/차트 | `training_dashboard.html` | template + live HTTP | UI commit | +| 4. GPU/ETA/속도 카드 개선 | 장비/속도/예상시간을 더 명확히 표시 | training page + common widget | live HTTP + escaping 확인 | UI commit | +| 5. `/stom` 성과 준비 상태 연결 | predictor 전에는 “성과 대기” 명확화 | `stom_dashboard.html` | route test | 통합 commit | +| 6. 최종 code-review 문서 | 변경 범위/위험/검증 기록 | docs | pytest + git diff check | review commit | + +## 5. 명시적 금지 사항 + +다음은 현재 학습이 완료되기 전까지 하지 않는다. + +```text +CUDA/PyTorch 재설치 +requirements 대규모 변경 +현재 run 폴더 삭제/정리/이동 +finetune/train_tokenizer.py 수정 +finetune/run_stom_1s_finetune.py 수정 +STOM DB/sample data 수정 +python 프로세스 일괄 종료 +무거운 전체 백테스트 병렬 실행 +``` + +## 6. 테스트 계획 + +필수 테스트: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py tests\test_training_progress.py -q +``` + +필수 live 확인: + +```text +http://127.0.0.1:5070/training?refresh_interval=10 +http://127.0.0.1:5070/?refresh_interval=10 +http://127.0.0.1:5070/stom?refresh_interval=10 +``` + +각 단계 공통 검증: + +```powershell +git diff --check +git status --short --branch +``` + +## 7. 사용 가능한 실행 역할/OMX 경로 + +- `$ralplan`: 계획/범위/위험 정리. 지금 단계. +- `$ralph`: 단일 소유자가 공통 UI/API를 순차 구현하고 검증. +- `$code-review`: 구현 후 변경사항 검토. +- `$ultraqa`: route/API/live dashboard 반복 검증이 필요할 때. + +권장 실행은 `$ralph`다. 이유는 현재 변경이 학습 프로세스와 분리된 웹/UI/API 개선이며, 파일 충돌을 줄이는 것이 안전하기 때문이다. + +## 8. 현재/남은 단계 progress + +```text +안전 계획 수립 [████████████████████] 100% +학습 모니터링 체계 [████████████████████] 100% +웹 기본 통합 [███████████████████░] 95% +프론트엔드 고도화 [████████░░░░░░░░░░░░] 40% +read-only artifacts API [░░░░░░░░░░░░░░░░░░░░] 0% +진행률 히스토리 시각화 [░░░░░░░░░░░░░░░░░░░░] 0% +학습 산출물 전체 진행률 [██░░░░░░░░░░░░░░░░░░] 12.42% +``` + +## 9. 다음 권장 OMX 명령 + +```text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획에 따라 1단계만 구현하세요. 목표는 /training, /, /stom의 학습 상태 UI를 공통 기준으로 정리하고 checkpoint/predictor 전환 전 성과 오해를 막는 표시를 추가하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고, live HTTP 확인 후 Lore commit으로 남기세요. +``` + +--- + +## 10. 2026-05-12 실행 갱신: 3단계 완료 + +3단계 `진행률 히스토리 표시`를 완료했습니다. + +- `/api/training/history`가 최근 stdout step을 읽기 전용으로 파싱합니다. +- `/training` 화면에 최근 step, stage %, overall %, LR, loss 테이블을 표시합니다. +- 아직 시작되지 않은 predictor 단계를 명시 선택하면 tokenizer 로그를 잘못 보여주지 않고 `no stdout log found for this run` 상태를 반환합니다. +- 현재 실행 중인 학습은 중단하지 않았습니다. + +검증: + +```text +pytest tests\test_training_monitor.py tests\test_training_progress.py -q: 12 passed +compileall webui: 통과 +live /api/training/history: point_count 5, latest_point.step 1,320,000 +live /training?refresh_interval=10: historyRows/historySummary 확인 +``` + +다음 단계는 4단계 `GPU/ETA/속도 카드 개선`입니다. + +--- + +## 11. 2026-05-12 실행 갱신: 4단계 완료 + +4단계 `GPU/ETA/속도 카드 개선`을 완료했습니다. + +- `/training`에 `학습 속도 / ETA` 카드가 추가되었습니다. +- `samples/sec`, 경과 시간, 남은 시간, 예상 완료 시각, 최근 loss를 한눈에 볼 수 있습니다. +- GPU summary가 평균 Util, 총 VRAM 사용률, 총 전력 제한, power draw 실측 가능 여부를 반환합니다. +- 현재 워크스테이션처럼 power draw가 실측되지 않는 경우 `실측 불가`로 표시해 전력 수치를 과장하지 않습니다. +- 현재 실행 중인 학습은 중단하지 않았습니다. + +검증: + +```text +pytest tests\test_training_monitor.py tests\test_training_progress.py -q: 13 passed +compileall webui: 통과 +live /api/training/gpu: available true, avg util 39.0%, VRAM 19.61%, power limit 320W +live /training?refresh_interval=10: runtimeSummaryCard/gpuSummaryMetrics 확인 +``` + +다음 단계는 5단계 `/stom 성과 준비 상태 연결`입니다. + + +--- + +## 12. 2026-05-12 ?? ??: 5?? ?? + +5?? `/stom ?? ?? ?? ??`? ??????. + +- `/stom` ??? `?? ?? Gate`, `Predictor`, `Checkpoint`, `?? / ETA` ??? ??????. +- predictor/checkpoint ?? ??? `LOCKED: predictor/checkpoint ?? ? ?? ?? ??`? ??? ?????. +- `/api/training/status` readiness? `/api/training/artifacts` checkpoint ??? `/stom` ?? ??? ??????. +- ?? ?? ?? ??? ???? ?????. + +??: + +```text +pytest tests\test_training_monitor.py tests\test_training_progress.py -q: 13 passed +compileall webui: ?? +live /stom?refresh_interval=10: stomPerformanceGate/stomPredictorGate/stomCheckpointGate/stomRuntimeGate ?? +live /api/training/status: tokenizer running, overall 14.7074% +live /api/training/artifacts: checkpoint 0?, predictor_started false +``` + +?? ??? 6?? `?? code-review ??`???. + + +--- + +## 13. 2026-05-12 ?? ??: 6?? ?? code-review ?? + +6?? `?? code-review ??`? ??????. + +?? ??: + +```text +Recommendation: APPROVE +Architectural Status: CLEAR +CRITICAL/HIGH/MEDIUM: 0 +LOW follow-up: 3 +``` + +?? ??: + +- 1?? ?? readiness UI/API +- 2?? read-only artifacts API +- 3?? ??? ???? +- 4?? GPU/ETA/?? ?? +- 5?? /stom ?? ?? ?? gate + +??: + +```text +pytest tests\test_training_monitor.py tests\test_training_progress.py -q: 13 passed +compileall webui: ?? +git diff --check aed6967..HEAD: ?? +live /training, /stom, / marker ?? +live /api/training/status/history/artifacts/gpu ?? +``` + +?? ?? ??: + +1. `/stom` ?? artifact table innerHTML sanitize pass +2. ?? ???? JS ?? ?? ?? +3. ?? ?? refresh interval ?? ? GPU status cache TTL ?? + +?? ???? ??? 1~6??? ?????. + + +--- + +## 14. 2026-05-12 ?? ??: ?? ?? ???? ????? + +?? code-review ?? ?? ??? ?? STOM full training ??? read-only? ???????. + +??: + +```text +?? ??: tokenizer running +step: 1,442,000 / 4,701,721 +tokenizer ???: 30.6696% +?? both-stage ???: 15.3348% +predictor_started: false +checkpoint/model weight: 0? +?? ?? ??: ?? ?? +``` + +??? ??? ?? ?? ??? ??? **???? ?? ??**???. + +?? ?? ??: + +1. predictor stage ?? ?? +2. predictor checkpoint/model weight ?? ?? +3. /training ? /stom gate? predictor training/ready ??? ????? ?? +4. ? ?? ??? vs ??? ?? ???? ?? ?? diff --git a/docs/stom_dashboard_safe_parallel_improvement_step1.md b/docs/stom_dashboard_safe_parallel_improvement_step1.md new file mode 100644 index 000000000..80404032d --- /dev/null +++ b/docs/stom_dashboard_safe_parallel_improvement_step1.md @@ -0,0 +1,120 @@ +# STOM 학습 유지 웹 대시보드 개선 1단계 구현 기록 + +작성일: 2026-05-12 KST +실행 모드: `$ralph` +기준 계획: `docs/stom_dashboard_safe_parallel_improvement_plan.md` + +## 1. 이번 단계의 목표 + +현재 실행 중인 STOM full training을 중단하지 않고, `/training`, `/`, `/stom`이 같은 기준으로 학습 상태와 예측 성과 준비 상태를 표시하도록 개선했다. + +핵심 목표: + +1. checkpoint/predictor 전환 전에는 예측 성과를 표시하지 않는다. +2. API가 공통 readiness 정책을 내려주고, 세 화면은 같은 의미의 상태를 표시한다. +3. 웹 변경은 학습 process, DB, current output을 수정하지 않는다. + +## 2. 구현 내용 + +### 공통 readiness 정책 + +`webui/app.py`에 `build_training_readiness()`를 추가했다. + +판단 기준: + +- tokenizer 단계 또는 running 상태: `성과 대기: tokenizer 학습 중` +- predictor 단계 진행 중: `predictor 학습 중` +- predictor 완료: `예측 성과 확인 가능` + +`/api/training/status` 응답에 아래 필드를 추가했다. + +```json +{ + "readiness": { + "level": "waiting", + "label": "성과 대기: tokenizer 학습 중", + "message": "현재 tokenizer 단계입니다. checkpoint와 predictor가 아직 준비되지 않아 예측 정확도/수익률을 판단하지 않습니다.", + "performance_ready": false, + "predictor_started": false, + "predictor_complete": false + } +} +``` + +### `/training` + +- `예측 성과 준비 상태` 카드를 추가했다. +- readiness label/message/predictor 시작/완료/성과 가능 여부를 표시한다. +- API/log 값은 기존처럼 escape 처리된 경로만 사용한다. + +### `/` + +- 상단 `실시간 학습 상태` 카드에 `성과 상태` 필드를 추가했다. +- readiness message를 별도 안내 박스로 표시한다. + +### `/stom` + +- 상단 학습 strip에 `성과 상태` 필드를 추가했다. +- predictor 전환 전에는 실제값/예측값 성과를 확정하지 않는다는 안내를 표시한다. + +## 3. 현재 live 학습 상태 + +구현/검증 중 live API 기준: + +```text +status: running +stage: tokenizer +step: 1,177,000 / 4,701,721 +tokenizer 진행률: 25.0334% +전체 both-stage 진행률: 12.5167% +readiness: 성과 대기: tokenizer 학습 중 +``` + +## 4. 검증 + +테스트: + +```text +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py tests\test_training_progress.py -q +9 passed in 1.70s +``` + +컴파일: + +```text +C:\Python\64\Python3119\python.exe -m compileall webui +통과 +``` + +Live HTTP: + +```text +/training?refresh_interval=10: trainingReadinessCard, readiness-card 확인 +/?refresh_interval=10: trainingInlineReadiness, training-readiness-note 확인 +/stom?refresh_interval=10: stomTrainingReadiness, training-readiness-note 확인 +``` + +Git: + +```text +git diff --check +통과 +``` + +## 5. 현재/남은 단계 + +```text +1단계 공통 readiness UI/API [████████████████████] 100% +학습 모니터링 체계 [████████████████████] 100% +웹 기본 통합 [████████████████████] 100% +프론트엔드 고도화 [██████████░░░░░░░░░░] 50% +read-only artifacts API [░░░░░░░░░░░░░░░░░░░░] 0% +진행률 히스토리 시각화 [░░░░░░░░░░░░░░░░░░░░] 0% +학습 산출물 전체 진행률 [███░░░░░░░░░░░░░░░░░] 12.52% +``` + +## 6. 다음 권장 OMX 명령 + +```text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획의 2단계를 구현하세요. 목표는 checkpoint/predictor/weight artifact 상태를 읽기 전용 API로 노출하고, /training과 /stom에서 checkpoint 생성 여부를 명확히 표시하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고 live HTTP 확인 후 Lore commit으로 남기세요. +``` diff --git a/docs/stom_dashboard_safe_parallel_improvement_step2.md b/docs/stom_dashboard_safe_parallel_improvement_step2.md new file mode 100644 index 000000000..7162dbd6a --- /dev/null +++ b/docs/stom_dashboard_safe_parallel_improvement_step2.md @@ -0,0 +1,137 @@ +# STOM 학습 유지 웹 대시보드 개선 2단계 구현 기록 + +작성일: 2026-05-12 KST +실행 모드: `$ralph` +기준 계획: `docs/stom_dashboard_safe_parallel_improvement_plan.md` + +## 1. 이번 단계의 목표 + +현재 실행 중인 STOM full training을 중단하지 않고, checkpoint/predictor/model weight artifact 상태를 읽기 전용으로 확인하는 API와 웹 표시를 추가했다. + +핵심 목표: + +1. current run output을 수정하지 않고 artifact 상태만 읽는다. +2. tokenizer/predictor checkpoint 생성 여부를 `/api/training/artifacts`로 확인한다. +3. `/training`과 `/stom`에서 checkpoint가 아직 없음을 명확히 표시한다. +4. predictor checkpoint 전에는 예측 성과를 확정하지 않는다. + +## 2. 구현 내용 + +### read-only artifact API + +`webui/training_monitor.py`에 `inspect_training_artifacts()`를 추가했다. + +확인 항목: + +- `finetune_tokenizer/checkpoints` +- `finetune_predictor/checkpoints` +- `.pt`, `.pth`, `.safetensors`, `.ckpt`, `.bin` weight 파일 +- tokenizer/predictor progress/log/manifest 존재 여부 + +`webui/app.py`에 아래 endpoint를 추가했다. + +```text +GET /api/training/artifacts +GET /api/training/artifacts?run= +``` + +현재 live 응답 요약: + +```text +level: waiting +label: checkpoint 대기 +model_weight_file_count: 0 +checkpoint_file_count: 0 +tokenizer_checkpoint_ready: false +predictor_checkpoint_ready: false +``` + +### `/training` + +- `Checkpoint / Predictor Artifact` 카드를 추가했다. +- model weight 파일 수, checkpoint 후보 파일 수, tokenizer/predictor checkpoint 상태, predictor 시작 여부를 표시한다. + +### `/stom` + +- 상단 학습 strip 아래에 artifact 상태 문구를 추가했다. +- 현재 checkpoint/model artifact가 없으면 `checkpoint 대기` 상태로 표시된다. + +## 3. 현재 live 학습 상태 + +구현/검증 중 live API 기준: + +```text +status: running +stage: tokenizer +step: 1,198,000 / 4,701,721 +tokenizer 진행률: 25.4800% +전체 both-stage 진행률: 12.7400% +artifact: checkpoint 대기, model weight 0개, checkpoint 0개 +``` + +## 4. 검증 + +테스트: + +```text +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py tests\test_training_progress.py -q +10 passed in 2.17s +``` + +컴파일: + +```text +C:\Python\64\Python3119\python.exe -m compileall webui +통과 +``` + +Live HTTP: + +```text +/api/training/status: readiness 확인 +/api/training/artifacts: checkpoint 대기, weight 0개 확인 +/training?refresh_interval=10: trainingArtifactCard 확인 +/stom?refresh_interval=10: stomTrainingArtifacts 확인 +``` + +Git: + +```text +git diff --check +통과 +``` + +## 5. Ralph deslop 점검 + +범위: + +- `webui/training_monitor.py` +- `webui/app.py` +- `webui/templates/training_dashboard.html` +- `webui/templates/stom_dashboard.html` +- `tests/test_training_monitor.py` + +점검 결과: + +- 새 API는 읽기 전용 helper에만 추가되어 학습 프로세스와 분리됨 +- path traversal은 기존 `resolve_run_dir()`/`_is_relative_to()` 경계를 재사용함 +- UI는 existing escape 경로와 textContent 중심으로 표시함 +- 2단계 범위를 넘어서는 히스토리 차트/추가 프론트 통합은 다음 단계로 보류함 + +## 6. 현재/남은 단계 + +```text +1단계 공통 readiness UI/API [████████████████████] 100% +2단계 read-only artifacts API [████████████████████] 100% +학습 모니터링 체계 [████████████████████] 100% +웹 기본 통합 [████████████████████] 100% +프론트엔드 고도화 [████████████░░░░░░░░] 60% +진행률 히스토리 시각화 [░░░░░░░░░░░░░░░░░░░░] 0% +학습 산출물 전체 진행률 [███░░░░░░░░░░░░░░░░░] 12.74% +``` + +## 7. 다음 권장 OMX 명령 + +```text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획의 3단계를 구현하세요. 목표는 tokenizer progress JSON/log를 읽기 전용으로 사용해 /training에 간단한 진행률 히스토리 카드 또는 표를 추가하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고 live HTTP 확인 후 Lore commit으로 남기세요. +``` diff --git a/docs/stom_dashboard_safe_parallel_improvement_step3.md b/docs/stom_dashboard_safe_parallel_improvement_step3.md new file mode 100644 index 000000000..bce11ba14 --- /dev/null +++ b/docs/stom_dashboard_safe_parallel_improvement_step3.md @@ -0,0 +1,118 @@ +# STOM 학습 대시보드 병렬 개선 3단계: 진행률 히스토리 + +작성일: 2026-05-12 KST +작업 범위: 현재 실행 중인 STOM full training을 중단하지 않고, 웹 대시보드가 stdout/progress JSON을 읽기 전용으로 분석해 최근 step 흐름을 보여주도록 개선 + +## 1. 이번 단계 목표 + +사용자는 CMD 로그가 아니라 웹 화면에서 학습 진행 상태를 빠르게 보고 싶어 했습니다. 3단계의 목표는 `/training` 화면에 다음 정보를 추가하는 것입니다. + +- 최근 학습 step 히스토리 +- 단계 진행률(stage %)과 전체 진행률(overall %) +- learning rate와 loss 변화 +- predictor 단계가 아직 시작되지 않았을 때 tokenizer 로그를 잘못 보여주지 않는 안전장치 + +## 2. 구현 내용 + +### API + +- `webui/training_monitor.py` + - Kronos 학습 stdout 라인 형식 `[Rank 0, Epoch 1/1, Step ...] LR ..., Loss: ...`를 파싱하는 정규식 추가 + - `load_training_history()` 추가 + - 최근 stdout tail에서 학습 step만 추출하여 `points`, `latest_point`, `latest_progress` 반환 + - 명시적으로 선택한 단계에 stdout 로그가 없으면 최신 다른 단계 로그로 fallback하지 않도록 보정 + +- `webui/app.py` + - `/api/training/history` 추가 + - `run`, `stage`, `limit` 쿼리 지원 + +### UI + +- `webui/templates/training_dashboard.html` + - `/training`에 `진행률 히스토리` 카드 추가 + - 최근 step, stage %, overall %, LR, loss 테이블 표시 + - 자동 새로고침 흐름에 history API 호출 포함 + - 단계 클릭 시 로그뿐 아니라 history도 즉시 갱신 + +### 테스트 + +- `tests/test_training_monitor.py` + - stdout 로그에서 최근 step 히스토리를 파싱하는 단위 테스트 추가 + - predictor처럼 아직 stdout 로그가 없는 명시 단계는 tokenizer 로그로 fallback하지 않는 회귀 테스트 추가 + - `/api/training/history` route 등록 테스트 추가 + +## 3. live 학습 상태 스냅샷 + +검증 시점 기준 live API 관측값입니다. + +```text +status: running +run: stom_1s_grid_pred60_2025_full_small +stage: tokenizer +step: 1,320,000 / 4,701,721 +tokenizer stage 진행률: 28.0748% +전체 both-stage 진행률: 14.0374% +최근 loss: -0.0301 +속도: 약 65.44 samples/sec +ETA: 약 206,714초 +readiness: 성과 대기(tokenizer 학습 중, predictor 미시작) +``` + +## 4. 검증 증거 + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py tests\test_training_progress.py -q +# 12 passed in 2.07s + +C:\Python\64\Python3119\python.exe -m compileall webui +# 통과 +``` + +Live HTTP 확인: + +```text +/api/training/history?limit=5 +- point_count: 5 +- latest_point.step: 1,320,000 +- latest_point.stage_percent: 28.0748 +- latest_point.overall_percent: 14.0374 + +/api/training/history?stage=predictor&limit=5 +- stage: predictor +- point_count: 0 +- error: no stdout log found for this run + +/training?refresh_interval=10 +- HTTP 200 +- historyRows 마커 확인 +- historySummary 마커 확인 +- refreshIntervalSeconds 마커 확인 +``` + +## 5. AI slop cleaner 점검 + +범위는 이번 Ralph 세션 변경 파일로 제한했습니다. + +- 중복/불필요한 추상화: 별도 제거 필요 없음 +- 잘못된 fallback 위험: 발견되어 보정 완료 +- 테스트 보강: predictor 미시작 단계 fallback 금지 회귀 테스트 추가 +- 신규 의존성: 없음 + +## 6. 진행률 + +```text +1단계 공통 readiness UI/API [██████████] 100% +2단계 read-only artifacts API [██████████] 100% +3단계 진행률 히스토리 표시 [██████████] 100% +4단계 GPU/ETA/속도 카드 개선 [░░░░░░░░░░] 0% +5단계 /stom 성과 준비 상태 연결 [░░░░░░░░░░] 0% +6단계 최종 code-review 문서 [░░░░░░░░░░] 0% +프론트엔드 고도화 전체 [██████░░░░] 60% +학습 산출물 전체 진행률 [█░░░░░░░░░] 14.04% +``` + +## 7. 다음 권장 OMX 명령 + +```text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획의 4단계를 구현하세요. 목표는 /training의 GPU/ETA/속도 카드를 개선해 samples/sec, ETA, GPU util/VRAM/온도/전력 상태를 더 명확히 표시하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고 live HTTP 확인 후 Lore commit으로 남기세요. +``` diff --git a/docs/stom_dashboard_safe_parallel_improvement_step4.md b/docs/stom_dashboard_safe_parallel_improvement_step4.md new file mode 100644 index 000000000..3fbfa3e34 --- /dev/null +++ b/docs/stom_dashboard_safe_parallel_improvement_step4.md @@ -0,0 +1,152 @@ +# STOM 학습 대시보드 병렬 개선 4단계: GPU/ETA/속도 카드 + +작성일: 2026-05-12 KST +작업 범위: 현재 실행 중인 STOM full training을 중단하지 않고, `/training` 웹 대시보드의 GPU/ETA/속도 표시를 read-only로 고도화 + +## 1. 이번 단계 목표 + +사용자는 긴 학습을 CMD가 아니라 웹 화면에서 확인하고 싶어 했습니다. 4단계는 `학습이 실제로 계속 진행 중인지`, `속도는 어느 정도인지`, `남은 시간은 얼마인지`, `GPU가 어느 정도 사용되는지`를 한눈에 볼 수 있게 만드는 단계입니다. + +중요 제한: + +- 학습 프로세스 중단/재시작 금지 +- CUDA/PyTorch/학습 코드/DB/finetune 출력물 수정 금지 +- `progress.json`, stdout log, `nvidia-smi` 조회 결과만 읽기 +- predictor 완료 전 예측 성과/정확도 판단 금지 + +## 2. 구현 내용 + +### 학습 속도 / ETA 카드 + +`webui/templates/training_dashboard.html`에 `runtimeSummaryCard`를 추가했습니다. + +표시 항목: + +- 현재 단계 +- 단계 상태 +- samples/sec +- 경과 시간 +- 남은 시간 +- 예상 완료 시각 +- 최근 loss +- 최근 갱신 시각 + +### GPU / 전력 카드 개선 + +`webui/training_monitor.py`의 `query_gpu_status()`가 기존 개별 GPU 값 외에 summary 값을 함께 반환하도록 확장했습니다. + +추가된 값: + +- `average_utilization_gpu_percent` +- `total_memory_used_mib` +- `total_memory_total_mib` +- `total_memory_used_percent` +- `total_power_limit_watts` +- `power_draw_available` +- GPU별 `power_draw_available` + +`nvidia-smi`가 현재 워크스테이션에서 power draw를 실측하지 못하는 경우에도, 전력 제한값은 보여주고 실측 전력은 `실측 불가`로 명확하게 표시합니다. + +### UI 개선 + +`/training` GPU 테이블을 다음 항목으로 확장했습니다. + +- GPU 이름 +- Util % +- VRAM % +- VRAM MiB +- Power +- Limit +- Temp + +## 3. live 학습 상태 스냅샷 + +검증 시점 기준 live API 관측값입니다. + +```text +status: running +run: stom_1s_grid_pred60_2025_full_small +stage: tokenizer +step: 1,329,000 / 4,701,721 +tokenizer stage 진행률: 28.2662% +전체 both-stage 진행률: 14.1331% +최근 loss: -0.0319 +속도: 약 65.37 samples/sec +ETA: 약 206,362초 +``` + +GPU live API 관측값: + +```text +GPU: NVIDIA GeForce RTX 4080 SUPER +average utilization: 39.0% +VRAM: 3,212 / 16,376 MiB, 19.61% +power draw: 실측 불가 +power limit: 320.0 W +temperature: 44 C +``` + +## 4. 검증 증거 + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py tests\test_training_progress.py -q +# 13 passed in 1.94s + +C:\Python\64\Python3119\python.exe -m compileall webui +# 통과 + +git diff --check +# 통과 +``` + +Live HTTP 확인: + +```text +/api/training/gpu +- available: true +- average_utilization_gpu_percent: 39.0 +- total_memory_used_percent: 19.61 +- power_draw_available: false +- total_power_limit_watts: 320.0 + +/api/training/status +- status: running +- latest_stage.train_stage: tokenizer +- samples_per_second: 약 65.37 +- eta_seconds: 약 206,362 + +/training?refresh_interval=10 +- runtimeSummaryCard 확인 +- runtimeMetrics 확인 +- gpuSummaryMetrics 확인 +- Power/Limit 컬럼 확인 +``` + +## 5. AI slop cleaner 점검 + +범위는 이번 Ralph 세션 변경 파일로 제한했습니다. + +- `webui/training_monitor.py`: GPU summary 계산은 기존 `query_gpu_status()` 안에서 유지해 새 의존성/새 계층을 만들지 않았습니다. +- `webui/templates/training_dashboard.html`: 숫자/시간/전력 format helper를 추가해 반복 포맷을 줄였습니다. +- `tests/test_training_monitor.py`: power draw 미지원 환경 회귀 테스트를 추가했습니다. +- 신규 의존성: 없음 +- 학습 산출물/DB 변경: 없음 + +## 6. 진행률 + +```text +1단계 공통 readiness UI/API [██████████] 100% +2단계 read-only artifacts API [██████████] 100% +3단계 진행률 히스토리 표시 [██████████] 100% +4단계 GPU/ETA/속도 카드 개선 [██████████] 100% +5단계 /stom 성과 준비 상태 연결 [░░░░░░░░░░] 0% +6단계 최종 code-review 문서 [░░░░░░░░░░] 0% +프론트엔드 고도화 전체 [████████░░] 80% +학습 산출물 전체 진행률 [█░░░░░░░░░] 14.13% +``` + +## 7. 다음 권장 OMX 명령 + +```text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획의 5단계를 구현하세요. 목표는 /stom 성과 대시보드 상단에도 predictor 미시작/성과 대기/학습 진행 상태와 artifact readiness를 더 명확히 연결해, predictor 완료 전에는 예측 성과를 오해하지 않도록 하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고 live HTTP 확인 후 Lore commit으로 남기세요. +``` diff --git a/docs/stom_dashboard_safe_parallel_improvement_step5.md b/docs/stom_dashboard_safe_parallel_improvement_step5.md new file mode 100644 index 000000000..2a0e62cda --- /dev/null +++ b/docs/stom_dashboard_safe_parallel_improvement_step5.md @@ -0,0 +1,136 @@ +# STOM ?? ???? ?? ?? 5??: /stom ?? ?? ?? ?? + +???: 2026-05-12 KST +?? ??: ?? ?? ?? STOM full training? ???? ??, `/stom` ?? ???? ???? predictor ???/?? ??/artifact readiness? ???? ?? + +## 1. ?? ?? ?? + +`/stom` ??? ???/???/Top-K/???? ??? ?? ?? ?? ?????. ??? predictor? ?? ???? ???? checkpoint? ?? ???? ???? ?? artifact ?? baseline ??? ?? ??? Kronos predictor ??? ???? ??? ??? ???. + +?? ??? ??? ?? 4?? gate? `/stom` ??? ??? ???? ????. + +- ?? ?? Gate: predictor/checkpoint ?? ? ?? ?? ?? +- Predictor ??: ???/?? ?/?? +- Checkpoint ??: tokenizer/predictor checkpoint ?? ?? +- ?? / ETA: ?? ?? ??? ?? ?? + +## 2. ?? ?? + +### `/stom` ?? training strip ?? + +`webui/templates/stom_dashboard.html`? `training-gate` ??? ??????. + +??? DOM marker: + +- `stomPerformanceGate` +- `stomPredictorGate` +- `stomCheckpointGate` +- `stomRuntimeGate` + +### readiness ?? + +`/api/training/status`? ?? readiness ??? `/stom`??? ????? ?????. + +- `performance_ready = false`?? `LOCKED: predictor/checkpoint ?? ? ?? ?? ??` ?? +- tokenizer ????? predictor ??? `???`?? ?? +- predictor ?? ??? `?? ?`, ?? ??? `??`? ?? + +### artifact readiness ?? + +`/api/training/artifacts`? ?? tokenizer/predictor checkpoint ?? predictor started ??? `/stom` ??? ?????. + +- tokenizer checkpoint count +- predictor checkpoint count +- predictor started ?? +- predictor checkpoint? ???? ??? checkpoint gate? locked ??? ?? + +## 3. live ?? ?? ??? + +?? ?? ?? live API ??????. + +```text +status: running +run: stom_1s_grid_pred60_2025_full_small +stage: tokenizer +step: 1,383,000 / 4,701,721 +tokenizer stage ???: 29.4148% +?? both-stage ???: 14.7074% +?? loss: -0.0316 +??: ? 65.05 samples/sec +ETA: ? 204,086? +readiness.performance_ready: false +readiness.predictor_started: false +readiness.predictor_complete: false +``` + +Artifact live API ???: + +```text +model_weight_file_count: 0 +checkpoint_file_count: 0 +tokenizer_checkpoint_ready: false +predictor_checkpoint_ready: false +predictor_started: false +``` + +## 4. ?? ?? + +```powershell +C:\Python4\Python3119\python.exe -m pytest tests est_training_monitor.py tests est_training_progress.py -q +# 13 passed in 1.99s + +C:\Python4\Python3119\python.exe -m compileall webui +# ?? + +git diff --check +# ?? +``` + +Live HTTP ??: + +```text +/stom?refresh_interval=10 +- HTTP 200 +- stomPerformanceGate ?? +- stomPredictorGate ?? +- stomCheckpointGate ?? +- stomRuntimeGate ?? +- predictor started ?? ?? + +/api/training/status +- status: running +- latest_stage.train_stage: tokenizer +- readiness.performance_ready: false + +/api/training/artifacts +- predictor_started: false +- checkpoint_file_count: 0 +``` + +## 5. AI slop cleaner ?? + +??? ?? Ralph ?? ?? ??? ??????. + +- `webui/templates/stom_dashboard.html`: ? API? dependency ?? ?? `/api/training/status`, `/api/training/artifacts`? ???????. +- `tests/test_training_monitor.py`: `/stom` marker ?? ???? ??????. +- ???? ??? ? ?? ?? ?? ??? ?? ??? ?????. +- predictor ?? ? ??? `LOCKED`? ??? ??????. + +## 6. ??? + +```text +1?? ?? readiness UI/API [??????????] 100% +2?? read-only artifacts API [??????????] 100% +3?? ??? ???? ?? [??????????] 100% +4?? GPU/ETA/?? ?? ?? [??????????] 100% +5?? /stom ?? ?? ?? ?? [??????????] 100% +6?? ?? code-review ?? [??????????] 0% +????? ??? ?? [??????????] 90% +?? ??? ?? ??? [??????????] 14.71% +``` + +## 7. ?? ?? OMX ?? + +```text +$code-review ???? STOM full training? ???? ?? ??? ???? read-only ?? ??? ?????. ??? docs/stom_dashboard_safe_parallel_improvement_plan.md? 1~5??? ?? commit???. ??? predictor ?? ? ?? ?? ??, /training ? /stom readiness/artifact/ETA/GPU ?? ???, ??? ???, ?? ??? ???? ?? review ??? Lore commit? ??? ????. +``` diff --git a/docs/stom_data_layer_assessment_2026-05-30.md b/docs/stom_data_layer_assessment_2026-05-30.md new file mode 100644 index 000000000..f8d7ec231 --- /dev/null +++ b/docs/stom_data_layer_assessment_2026-05-30.md @@ -0,0 +1,73 @@ +# 데이터 레이어 정밀 평가 — "1일봉/1분봉/1초봉, 1초봉은 진짜 못 쓰나?" + +- 작성일: **2026-05-30 KST** / 브랜치: `feature/stom-rl-lab` +- 방법: 10-에이전트 워크플로우(내부 소스 감사 + 외부 문헌 + PRO/SKEPTIC 적대 패널 + 종합) +- 대상: 시초 갭상승 `ts_imb` — **RULE strategy, NOT reinforcement learning.** 모든 양수치는 in-sample / triggered-subset / 라이브 포워드 없음 / L2 큐 없음 → **기대 실거래 수익 아님, 수익 보장 아님.** + +--- + +## 0. 한 줄 결론 + +**1초봉에 "돈 버는 방향성 신호(알파)"는 없다 — 이건 미탐색이 아니라 *적대적 검정으로 닫힌 문*이다. 그러나 1초봉은 버릴 데이터가 아니라, 백테스트를 정직하게 만들고 룰을 보호하는 *비용·리스크·체결 진실 레이어*로서 대체 불가능하게 이미 쓰이고 있다.** 살아남는 알파-인접 실험은 단 하나(skip-gate, 사전확률 ~20–30%)뿐이다. + +살아남은 **+0.884%/trade는 고정 09:00 진입 룰의 무조건적 모멘텀 드리프트**다. 1초봉은 그 숫자를 *만들지* 않고, 그 숫자가 현실적인지 *증명*한다. + +--- + +## 1. 데이터 레이어 역할 분담 (권장) + +| 레이어 | 올바른 역할 | 쓰면 안 되는 것 | +|---|---|---| +| **1일봉(1d)** | 레짐·유니버스·교차일 컨텍스트: 전일종가(갭 계산 기준), 가격제한폭(±30%), 다일 거래정지 플래그, 후보 세션 선별 | 장중 엣지·체결에는 부적합 | +| **1분봉(1m)** | 신호/피처 scaffolding + 09:00 진입 결정의 자연스러운 보고 grain. 방향성 엣지 추정엔 충분(de-ideal로 중심값 +0.9%→+0.884%, 미미) | **체결 현실성·gap-through·intrabar SL/TP 순서·초당 유동성에는 원천적으로 부족.** ★KRX 주의: 09:00봉은 단일가 동시호가 print(O=H=L=C, 거래량 ~30배=30분 누적; VI 시 실제 첫 체결이 09:02+로 밀림) → 연속거래봉으로 취급 금지, auction 봉으로 태깅 | +| **1초봉(1s)** | **체결/비용/리스크 진실 레이어 (고유·대체불가).** ↓ §2 | 방향성 알파(검정으로 닫힘) | + +문헌(Hasbrouck; Almgren-Chriss; Tóth/Bouchaud 제곱근법칙; Frazzini-Israel-Moskowitz; López de Prado AFML Ch.19)은 틱/1초 데이터를 *정확히 이 비용·리스크 레지스터*에 만장일치로 추천하고, 비-HFT/리테일 지연에서의 순진한 알파 사용을 전형적 과적합으로 경고한다. + +--- + +## 2. 1초봉만 할 수 있는 것 (이미 사용 중·증명됨) + +| 용도 | 유형 | 상태 | 가치 | +|---|---|---|---| +| 진짜 intrabar SL/TP 경로 + 정확한 교차 초 + gap-through 탐지 | execution | **사용중·필수** (`gap_up_backtest.py`). 봉 단위론 불가 — Claeys 2026: 1분봉 18.47%가 SL/TP 순서 모호 | tight-stop 전략의 신뢰불가 봉-P&L을 방어가능하게 전환 | +| 마켓에이블 de-idealized 체결(buy@ask/sell@bid, 스프레드 2회) | cost | **사용중·증명** (`marketable_fill.py`). 룰 net +0.884% (full N=5,173) | 가장 흔한 백테스트 과대(공짜 스프레드) 제거 | +| 초당 참여율/제곱근-impact 슬리피지·용량 한도 | cost | **사용중이나 낙관-편향·미해결** (`liquidity_model.py` 자기문서화: 첫 봉이 09:00 누적일 수 있어 분모 과대→ feasibility는 "낙관적 상한") | 대형 계좌서 +0.884%가 살아남는지 결정하는 용량 한도 | +| VI/거래정지 제외(as-of 조인 前) | risk | **사용중·필수** (`panel_join.py`). VI의 53%가 개장 10분 내, 81%가 30분 내 | look-ahead·허위가격 오염 차단 | +| 장중 누적손실 halt + 연속손실 서킷브레이커(순서 의존) | risk | **사용중·검증**(복구성 단위테스트). replay-only, 라이브 아님 | 봉 단위론 무의미한 꼬리리스크 통제(holdout maxDD −0.7~−0.9%) | +| 보유시간 분포(hold_seconds) | execution | 사용중·기술통계, 1초봉 고유 | 용량/회전 분석 | + +> 핵심 지문: 위 전부가 추정치를 *더 보수적으로* 만들거나 *상한을 정한다* — 이게 알파 소스가 아니라 비용/리스크 레이어라는 정의적 증거다. + +--- + +## 3. 알파 용도 = 적대적 검정으로 닫힘 (★ 정정 포함) + +| 용도 | 상태 | +|---|---| +| 교차종목 **선택** 알파(체결강도/호가 imbalance 랭킹) | **검정·기각.** ★이번 세션 소스 확인: shuffle 하니스가 *존재*하고 단위테스트됨(`portfolio_walk_forward.py shuffle_candidate_signal`, real-vs-shuffle 아티팩트 디스크에 존재). permutation null **실패**: 1초 real 3/5→shuffle 1–2/5(3/5는 no_trade에 지는 tie), 1분 real ≤ shuffle("노이즈가 모델보다 잘 고름"), 세션-proxy real 1/5 ≪ shuffle 4/5. **감사가 "shuffle 미검정"이라 한 건 사실오류 — 정정하면 no-alpha 결론이 *더* 강해짐.** IC≈0.10 랭킹신호는 통계적으론 실재(symbol-disjoint robust)하나 증분 알파로는 수익화 불가 | +| 진입 **타이밍**(최적 진입 초) | **검정·기각** (P1b). baseline-relative de-ideal 증분 음수: ridge −0.456%, gbm −0.384%, 전 5경계 음수, DSR=0, N=5,173. 모멘텀 종목서 "예측 높은 초"는 고점 이후 → granularity가 가치를 *파괴* | +| 상태-**무관** 청산 최적화(넓은 SL, 트레일링) | **검정·기각** (`exit_baselines.py`, 9개 인과 후보). IS-best trail_1 OOS +0.373% vs 고정룰 +1.086%, 개선 −0.713%, DSR 0.931<0.95 — 두 게이트 다 실패. oracle 천장(capture 17.8%)은 비인과 hindsight로 증명됨 | + +--- + +## 4. 스키퍼가 못 죽인 실험 (살아남은 것 — 정직한 사전확률) + +> 권장 순서: **②측정 → ③게이트 → (통과 시에만) ① → (③ 통과 시에만) ④.** 전부 사전등록(pre-register) 필수 — P1식 artifact 방지. + +| # | 실험 | 왜 안 죽었나 | 사전확률 | 노력 | +|---|---|---|---|---| +| ① | **baseline-relative SKIP-GATE**(진입/스킵 *이진*, 타이밍 아님) | 유일한 진짜 미검정 레버. 두 패널 모두 #1 갭으로 지목. 소스 확인: `timing_gate.py`는 t=0서 무조건 진입만 호출, 스킵 메커니즘 부재. 요구가 타이밍보다 훨씬 약함 — 하위 IC 슬라이스가 "최고 초 예측"이 아니라 *23bp 후 순-음수*이기만 하면 됨 | **LOW ~20–30%** (PRO 25–30%, SKEPTIC NO). 함정=P1을 태운 드리프트 트랩: forward-60s 드리프트가 대체로 양수라 하위 슬라이스가 "덜 양수"일 뿐이면 스킵이 실제 돈을 포기 | LOW(며칠) | +| ② | **초당 거래대금 정합 재구성**(용량, 알파 아님) | `liquidity_model.py`가 자기문서화한 미해결 낙관편향(첫 봉 누적 의심)을 제거. PASS중인 게이트의 측정 결함 수정 | 용량 수치를 유의미하게 바꿀 확률 ~60–70%, 할 가치 ~90%. "실패" 불가 — 정직성↑, 결과는 보통 용량↓ | LOW–MOD | +| ③ | **첫 N초 경로형태 = SL여부 리스크 기술자**(저렴한 선행 게이트) | 첫 30–60s 경로(신고가 타이밍, 첫 되돌림까지 시간, 1s 분산)를 조건변수로 쓴 적 없음(체결판정에만 사용). arXiv 2509.16137: intrabar 타이밍이 통계적 ML 가치. **측정-우선 디리스커**: SL예측이 chance면 ①④를 짓기 *전에* 싸게 죽임 | MOD ~40–50% | LOW(먼저 실행) | +| ④ | **상태-조건부 청산**(첫 N초 경로+진입 microstructure, 잔혹하게 deflate) | 기존 grid가 *구성상* 상태-무관(코드 확인). oracle gap 82.2%가 SL 청산에 집중(regret 55%, 평균 +4.678%) | **LOW ~20%.** 강한 반대 구조사실: 갭상승은 장중 평균회귀 → SL종목 더 들면 −1%가 −3% 될 위험. ③ 통과 시에만 | MOD(최고 과적합 위험, 후보 ≤5) | + +--- + +## 5. 정직한 바텀라인 + +1초봉에 **숨은 방향성 엣지는 없다**, 그리고 그 근거는 *부재*가 아니라 *적대적 검정*이다(shuffle null 실패 + baseline-relative de-ideal 음수 + DSR<0.95). 살아남은 +0.884%/trade는 고정 09:00 룰의 무조건 드리프트이고, 1초봉은 그 숫자를 *증명*(진짜 intrabar 체결·gap-through·마켓에이블 스프레드·VI/halt 제외·용량 한도)하는 데만 필요하다 — 1분봉으로 내리면 *엣지가 아니라 정직성과 꼬리리스크 현실성*을 잃는다. + +→ **1초봉을 유지하라. 문헌이 만장일치로 추천하는 용도(비용 측정·체결 현실성·리스크 통제)로 쓰고, 알파로 쓰지 마라.** 저렴하게·사전등록으로: ②초당흐름 재구성(용량 정직화, 높은 사전확률, 거짓희망 못 만듦) → ③SL예측 선행 분류기(싼 게이트) → 통과 시에만 ①skip-gate(유일한 진짜 미검정 알파-인접, ~20–30%, P1의 GO를 P1b NO-GO로 뒤집은 드리프트 트랩이 위협). 어떤 단일 통과도 전체 실험서 시도한 컷 수에 비례해 의심하라. + +**RULE not RL. 모든 양수치는 in-sample / triggered-subset(STOM 기록 세션만, 전체 시장 갭상승 아님) / 라이브 포워드 없음 / L2 큐 없음 — 기대 실거래 수익 아님.** diff --git a/docs/stom_development_direction_review_2026-06-03.md b/docs/stom_development_direction_review_2026-06-03.md new file mode 100644 index 000000000..01315f31e --- /dev/null +++ b/docs/stom_development_direction_review_2026-06-03.md @@ -0,0 +1,106 @@ +# STOM/Kronos 개발 방향성 재검토 — 2026-06-03 + +## 결론 + +현재 저장소의 개발 방향은 두 축으로 분리해야 한다. + +1. **메인라인:** `ts_imb` 시초 갭상승 RULE 전략의 검증, 사이징, 리스크 관리, read-only forward/paper 준비. +2. **실험라인:** orderbook/RL 모델은 대시보드와 함께 “모델을 검증하고 탈락시키는 실험 장치”로 유지. + +즉, 지금까지 만든 RL 인프라와 대시보드는 버릴 필요가 없지만, 수익성 주장을 맡길 축은 아니다. RL은 아직 `NO-GO`이며, 좋은 차트를 만들기보다 baseline 대비 실패/개선 여부를 명확히 보여주는 방향이 맞다. + +## 근거 요약 + +| 영역 | 현재 증거 | 판정 | +|---|---|---| +| `ts_imb` gap-up RULE | 과거 문서에서 비용/체결 stress 후에도 가장 강한 baseline으로 기록 | 계속 진행 | +| PPO/opening candidate | 500 episode OOS에서 cost gate 실패, `NO-GO_USABLE_MODEL` | 사용 모델 아님 | +| skip-gate | full-universe `NO-GO`, negative control hard blocker 포함 | 중단/보류 | +| state-exit gate | full-universe `NO-GO`, primary 자체가 GO 조건 실패 | 중단/보류 | +| free-action DQN/orderbook RL | 과매매/invalid-action/비용 문제로 baseline 열세 | 중단 | +| constrained skip/enter/exit RL | free-action 대비 개선됐지만 아직 baseline 미달 | 연구용 유지 | +| dashboard | API/시각화는 상당히 구현됨. 단, split/seed/cost/baseline 비교가 더 강해야 함 | 계속 개선 | + +## 디렉터리별 스코어 + +| 디렉터리 | 관찰 | AGENTS 필요성 | 방향성 리스크 | +|---|---:|---:|---| +| `stom_rl/` | 약 45개 코드 파일, 약 19.7k LOC, 전략 핵심 | 매우 높음 | RL/RULE 용어 혼동, NO-GO 재튜닝 | +| `webui/` | 약 42개 코드 파일, 약 13.0k LOC, 대시보드/API 핵심 | 높음 | 차트가 성과를 과장할 위험 | +| `webui/v2_src/` | Svelte/Vite 별도 프런트엔드 | 높음 | dist/source 불일치, 거대 탭 유지보수 | +| `finetune/` | STOM/Qlib/훈련 파이프라인 | 중간~높음 | 산출물과 소스 혼재, 환경 민감성 | +| `tests/` | 약 63개 테스트 파일, 약 12.4k LOC | 높음 | 전체 테스트만으로 원인 분리 어려움 | +| `model/` | 작지만 핵심 모델 API | 낮음~중간 | 현 작업 축에서는 상대적으로 안정 | + +## 유지할 것 + +- `ts_imb` RULE baseline을 기준점으로 삼는 평가 체계. +- 23bp 비용과 marketable-fill 계정. +- negative/shuffle control, OOS split, drawdown/cost gate. +- RL 대시보드의 regular route `/rl`. +- orderbook readiness와 action 로그/episode 로그. +- `NO-GO`를 숨기지 않는 문서화 방식. + +## 줄이거나 멈출 것 + +- 자유 action DQN/PPO를 계속 돌려 “좋은 곡선”을 기대하는 방향. +- forced-entry exit-only RL을 주력으로 삼는 방향. +- skip-gate/state-exit gate를 결과를 본 뒤 재튜닝하는 방향. +- dashboard에서 누적 수익 곡선만 강조하고 baseline/비용/실패 사유를 약하게 보이는 방향. +- 실거래 준비 완료, 수익 보장, 라이브 주문 가능 표현. + +## 다음 개발 우선순위 + +| 우선순위 | 작업 | 목적 | 완료 기준 | +|---:|---|---|---| +| 1 | 메인라인/실험라인 라벨 정리 | RULE 전략과 RL 실험 혼동 제거 | docs/dashboard/run metadata에 `rule`/`rl_experiment` 구분 | +| 2 | 대시보드 비교 강화 | 모델 성과를 정직하게 판정 | model vs `ts_imb` baseline vs no-trade, split/seed/cost 배지 | +| 3 | `ts_imb` RULE 사이징/리스크 설계 | 실제 운영 전 필요한 통제 설계 | 일손실한도, 동시보유, 유동성 상한, 중단조건 문서/테스트 | +| 4 | RL은 constrained skip/enter/exit + baseline-relative reward만 유지 | invalid-action/과매매를 줄인 공정 비교 | rolling OOS에서 baseline delta와 NO-GO 사유 산출 | +| 5 | 산출물/소스 정리 | 재현성/리뷰 가능성 개선 | generated artifact와 source 파일 명확히 분리 | + +## RL을 계속한다면 조건 + +RL은 가능하지만, 다음 조건 없이는 “쓸 수 있는 모델”로 부르면 안 된다. + +- action space는 자유 매수/매도 반복이 아니라 제한형이어야 한다. +- reward는 절대 수익보다 baseline-relative 형태가 우선이다. +- `ts_imb`, no-trade, random/negative control과 같은 비교군이 필요하다. +- OOS rolling split과 비용/드로우다운 gate를 통과해야 한다. +- invalid action rate, action remap rate, turnover/trade count를 같이 공개해야 한다. + +## 대시보드 다음 개선안 + +- run header에 `artifact_type`, `evaluation_mode`, `split`, `seed`, `cost_bps`, `horizon`, `baseline` 표시. +- KPI에 `baseline_delta`, `max_drawdown`, `trade_count`, `turnover`, `cost_gate`를 고정. +- smoke/full/walk-forward를 색상/배지로 구분. +- 실패 사유 패널 추가: cost gate fail, baseline underperform, invalid action, overtrade, low sample. +- 누적 수익 차트에는 model line, baseline line, no-trade line, episode boundary를 같이 표시. + +## 추천 OMX 명령 + +```text +$ralph ts_imb RULE 전략의 사이징/리스크 설계를 구현하고 read-only forward 준비 상태를 대시보드에 표시해줘 +``` + +또는 RL 실험을 계속할 경우: + +```text +$ralph RL은 실험 분기로 격리하고 skip/enter/exit 제한형 모델에 baseline-relative reward, rolling OOS, model-vs-baseline dashboard comparison을 추가해줘 +``` + +## 검증 메모 + +- 병렬 탐색 에이전트로 구조, RL 방향, dashboard, 테스트/빌드, 문서/핸드오프, anti-pattern을 점검했다. +- `git status` 기준 현재 브랜치는 `feature/stom-rl-lab`, 관찰 commit은 `943222b`. +- CodeGraph MCP 호출은 transport closed로 실패해 shell/agent/doc/artifact 기반으로 검토했다. +- 이 문서는 수익성 보증이 아니라 현재 코드/문서/실험 결과를 기준으로 한 개발 방향 판단이다. + +## 2차 init-deep 재검토 메모 + +- 기존 AGENTS 계층은 유지한다: 루트, `stom_rl/`, `webui/`, `webui/v2_src/`, `finetune/`, `tests/`. +- `docs/`는 의사결정 장부 역할이 커서 별도 `docs/AGENTS.md`를 추가했다. +- AGENTS 파일은 도구/콘솔 인코딩 문제를 줄이기 위해 ASCII 중심 문구로 정리했다. +- 프런트엔드 지침에 Node 20/22, npm 9+ 조건을 명시했다. +- 현재 방향성 결론은 변하지 않았다: 수익 축은 `ts_imb` RULE, RL은 검증/반증용 실험 레이어다. +- 추가 위험: `RLTradingTab.svelte`의 `RL TRADING`, `DQN/PPO RL 모델` 같은 문구는 사용자가 “수익성 있는 RL 모델”로 오해할 수 있으므로 다음 UI 개선 때 실험/검증 라벨을 더 강하게 붙여야 한다. diff --git a/docs/stom_development_progress_and_rl_feasibility_2026-06-05.md b/docs/stom_development_progress_and_rl_feasibility_2026-06-05.md new file mode 100644 index 000000000..cc4ad5235 --- /dev/null +++ b/docs/stom_development_progress_and_rl_feasibility_2026-06-05.md @@ -0,0 +1,88 @@ +# STOM/Kronos development progress and RL feasibility — 2026-06-05 + +## Purpose + +This document consolidates the STOM opening-research and dashboard direction as of 2026-06-05. It is an evidence ledger, not a marketing or live-trading readiness document. + +The current mainline remains the `ts_imb` opening gap-up **RULE** baseline. PPO/DQN/orderbook RL work remains an isolated research and falsification lane. The official dashboard should make evidence, costs, splits, baselines, controls, and failure reasons visible. + +## Current verdict summary + +| Area | Status | Reason | +|---|---|---| +| `ts_imb RULE baseline` | Main research baseline | Strongest current opening reference; must not be called RL. | +| Skip-gate | `NO-GO` | Full-universe checks did not justify promotion. | +| State-conditioned early-exit gate | `NO-GO` | Primary model failed GO criteria; negative controls behaved as expected. | +| Opening PPO/DQN candidate | `NO-GO_BASELINE` / research-only | Candidate failed no-trade or buy-and-hold baseline gates under the 23bp assumption. | +| Realdata RL smoke | `INCONCLUSIVE` | OOS split, controls, ablations, and baseline superiority were not sufficient for promotion. | +| Rule/meta-label filter | `NO-GO_CONTROL` | Rule-filter evidence did not pass the preregistered control/ablation/baseline gate. | +| Live/broker readiness | `NO-GO` | There is no live-ready model, no broker integration, and no profitability claim. | + +## Evidence trail + +| Date | Artifact | Notes | +|---|---|---| +| 2026-06-01 | `docs/stom_rl_resume_handoff_2026-06-01.md` | Resume context for RL/RULE split and `ts_imb` 23bp guardrails. | +| 2026-06-01 | `docs/stom_rl_opening_ppo_candidate_2026-06-01.md` | Historical PPO opening candidate record; `NO-GO_USABLE_MODEL`. | +| 2026-06-01 | `docs/stom_skip_gate_prereg_2026-06-01.md` | Skip-gate preregistration. | +| 2026-06-01 | `docs/stom_skip_gate_result_2026-06-01.md` | Full-universe `NO-GO`, negative-control context, 23bp/marketable-fill accounting. | +| 2026-06-01 | `docs/stom_state_exit_prereg_2026-06-01.md` | State-conditioned early-exit preregistration. | +| 2026-06-02 | `docs/stom_state_exit_result_2026-06-02.md` | State-exit `NO-GO`; primary model did not satisfy GO criteria. | +| 2026-06-03 | `docs/stom_development_direction_review_2026-06-03.md` | Mainline is `ts_imb` RULE; RL is evidence/falsification lane. | +| 2026-06-03 | `docs/stom_opening_30m_rl_workflow_2026-06-03.md` | Opening-30m workflow with OOS, controls, ablations, and 23bp constraints. | +| 2026-06-04 | `docs/stom_opening_30m_rl_realdata_validation_2026-06-04.md` | Realdata RL smoke remains `INCONCLUSIVE`. | +| 2026-06-04 | `docs/stom_opening_30m_rl_oos_candidate_validation_2026-06-04.md` | DQN/PPO OOS candidate rejected by baseline gate. | +| 2026-06-04 | `docs/stom_opening_30m_rl_feature_revalidation_2026-06-04.md` | Feature/ablation review; final verdict remains `NO-GO_BASELINE`. | +| 2026-06-04 | `docs/stom_opening_30m_rule_filter_prereg_2026-06-04.md` | Rule/meta-label filter preregistration. | +| 2026-06-04 | `docs/stom_opening_30m_rule_filter_result_2026-06-04.md` | Rule-filter smoke `NO-GO_CONTROL`, split hash `225356e7f771784c`. | +| 2026-06-05 | `.omo/plans/rule-filter-realdata-oos-expansion.md` | Bounded realdata OOS expansion plan for rule/RL separation and control evidence. | + +## Completion and feasibility status + +| Surface | Role | Completion estimate | Remaining gap | +|---|---|---:|---| +| `/` dashboard shell | Official dashboard entry | 70% | Keep route labels product-facing, not version/lab-facing. | +| `/rl` RL evidence route | RULE/RL evidence viewer | 80% | Keep rule/RL labels, failure reasons, and costs visible. | +| RL Trading / Evidence tab | Candidate lifecycle, split, controls, ablations, equity evidence | 75% | Strengthen OOS/control/failure panels and avoid success-looking charts without baseline context. | +| Forecast Workbench | Kronos prediction inspection | 60% | Keep separate from STOM/RL trading evidence. | +| History / Runs | Run discovery and evidence navigation | 70% | Keep rule-filter realdata runs discoverable. | +| Artifacts / Models | Read-only artifact inspection | 60% | Label RULE artifacts and RL artifacts separately. | +| STOM Diagnostics | Prediction/backtest diagnostics | 65% | Improve OOS diagnostics and data-quality context. | +| System Health | Runtime/data health | 55% | Distinguish infrastructure readiness from model usability. | +| Docs Page | Decision/evidence ledger surface | 65% | Link durable result docs and preserve verdict labels. | +| RL evidence tables | Controls, ablations, failure reasons, time buckets | 80% | Keep controls and baseline gates visible. | +| Participant Proxy / Orderbook cards | Participant proxy, imbalance, persistence, overheat/upper-wick context | 55-65% | Treat features as research evidence until ablations and controls pass. | +| Cumulative equity curve | Evidence visualization | 60% | Always show baseline/no-trade/cost context; do not imply profitability. | + +## Research feasibility + +| Track | Feasibility | Evidence boundary | +|---|---|---| +| `ts_imb` RULE baseline | Mainline research candidate | Still requires OOS discipline, sizing/risk constraints, and drawdown/cost gates. | +| PPO/DQN RL candidates | Research-only | Current evidence is `NO-GO_BASELINE`, `NO-GO_USABLE_MODEL`, or `INCONCLUSIVE`. | +| Rule/meta-label filter | Research-only | Latest rule-filter family verdict is `NO-GO_CONTROL`. | +| Orderbook/imbalance features | Research feature lane | Requires same-split controls, feature ablation, OOS evidence, and marketable-fill accounting. | +| Participant proxy | Research feature lane | Proxy quality and ablation evidence are required before any promotion claim. | +| Live/broker path | Not feasible from current evidence | No live forward evidence, no broker integration, no live-ready model. | + +## Required guardrails for the next loop + +1. Keep `ts_imb` labeled as a RULE baseline. +2. Preserve 23bp round-trip cost unless a preregistered test explicitly states otherwise. +3. Require OOS split metadata, negative/shuffle controls, feature ablations, drawdown, and cost gates for any alpha claim. +4. Show `NO-GO`, `NO-GO_BASELINE`, `NO-GO_CONTROL`, and `INCONCLUSIVE` plainly in docs and UI. +5. Keep the dashboard read-only; do not add broker/live-order side effects. +6. Do not call dashboard curves proof of profitability. + +## Recommended next work + +1. Commit hygiene and generated-artifact separation before more experiments. +2. Dashboard rule/RL label hardening, including evidence panels for cost, split, baseline delta, controls, ablations, and failure reasons. +3. Bounded realdata OOS rule-filter validation only under preregistered controls. +4. JSON/evidence validators for missing split/control/ablation/gate fields. +5. Feature availability audit for orderbook imbalance, persistence, OFI, participant proxy, and overheat/upper-wick features. +6. Only after those pass, compare constrained RL candidates against no-trade, buy-and-hold, and `ts_imb` RULE baseline under 23bp costs. + +## Bottom line + +The repository is useful as a research and operations evidence platform. It is not live-trading ready. The safest current direction is to keep the `ts_imb` RULE baseline as the mainline evidence anchor and use RL/orderbook experiments to falsify hypotheses under strict OOS, cost, control, and ablation gates. diff --git a/docs/stom_full_training_dashboard_plan.md b/docs/stom_full_training_dashboard_plan.md new file mode 100644 index 000000000..1926d9d46 --- /dev/null +++ b/docs/stom_full_training_dashboard_plan.md @@ -0,0 +1,203 @@ +# STOM OHLCV Kronos 전체 구축 계획과 실행 가이드 + +이 문서는 STOM tick DB를 Kronos 기본 OHLCV로 학습하고, 학습 후 실제값/예측값을 웹 대시보드에서 검증하기 위한 전체 1~5 단계 계획이다. + +## 전체 목표 + +```text +1. 환경 점검 +2. STOM DB → Kronos OHLCV 학습 데이터 준비 +3. Kronos 파일럿 파인튜닝 +4. 예측 검증 결과 생성 +5. 웹 대시보드에서 실제값/예측값 시각화 +``` + +전체 DB 기준 규모: + +- 전체 테이블: `2,427` +- 주식 OHLCV 테이블: `2,425` +- 제외: `moneytop`, `stockinfo` +- `lookback=300`, `predict=60` 기준 학습 가능 그룹: `73,650` +- 예상 학습 window: `95,946,764` + +## Page 1. 환경 점검 + +목표는 “학습을 시작해도 되는 상태인지”를 먼저 확인하는 것이다. + +명령: + +```powershell +python finetune_csv/stom_ohlcv_pipeline.py env-check +``` + +확인 항목: + +- Python 실행 경로 +- torch 설치 여부 +- CUDA 사용 가능 여부 +- Flask/Plotly dashboard dependency +- STOM DB 존재 여부 +- `config_stom_1tick.yaml` 설정 요약 + +CUDA가 false이면 전체 학습을 바로 강행하지 않는다. 먼저 CUDA PyTorch를 설치하거나 CPU pilot만 수행한다. + +## Page 2. DB 분석/학습 데이터 준비 + +전체 검사: + +```powershell +python finetune_csv/prepare_stom_1tick.py inspect ` + --db _database/stock_tick_back.db ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 0 ` + --price-mode close_only +``` + +파일럿 export: + +```powershell +python finetune_csv/prepare_stom_1tick.py export ` + --db _database/stock_tick_back.db ` + --output finetune_csv/data/stom_1tick_kline_pilot.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 100 ` + --price-mode close_only +``` + +`close_only`는 `현재가`를 OHLC 모두에 사용하는 안전 모드다. STOM `시가/고가/저가`가 실제 1초봉 OHLC가 아니라 일중 누적 시고저일 수 있기 때문에 기본값으로 권장한다. + +## Page 3. 파일럿 학습 + +처음부터 전체 `95,946,764` windows를 사용하지 않는다. + +권장 파일럿 설정: + +```yaml +data: + max_samples: 200000 + sample_stride: 5 + +training: + basemodel_epochs: 1 + batch_size: 16 +``` + +학습 명령: + +```powershell +python finetune_csv/train_sequential.py --config finetune_csv/configs/config_stom_1tick.yaml +``` + +목표: + +- DataLoader 정상 생성 +- GPU/CPU memory 확인 +- train/validation loss 계산 +- best model 저장 + +## Page 4. 예측 검증 결과 생성 + +대시보드가 실제값/예측값을 표시하려면 prediction CSV가 필요하다. + +모델이 아직 없을 때 baseline smoke: + +```powershell +python finetune_csv/stom_prediction_eval.py ` + --data finetune_csv/data/stom_1tick_kline_pilot.csv ` + --output webui/stom_predictions/pilot_predictions.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 20 ` + --mode baseline +``` + +학습 모델이 준비된 뒤 real Kronos 검증: + +```powershell +python finetune_csv/stom_prediction_eval.py ` + --data finetune_csv/data/stom_1tick_kline_pilot.csv ` + --output webui/stom_predictions/kronos_predictions.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 20 ` + --mode kronos ` + --model-path finetune_csv/finetuned/stom_1tick_lookback300_pred60/basemodel/best_model ` + --tokenizer-path NeoQuasar/Kronos-Tokenizer-base ` + --device cuda:0 +``` + +저장되는 주요 컬럼: + +- `symbol` +- `session` +- `asof_timestamp` +- `target_timestamp` +- `pred_close` +- `actual_close` +- `error` +- `abs_error` +- `pred_return_window` +- `actual_return_window` +- `direction_hit_window` + +## Page 5. 웹 대시보드 + +실행: + +```powershell +python webui/run.py +``` + +접속: + +```text +http://localhost:7070/stom +``` + +표시 내용: + +- 전체 DB 학습 가능 요약 +- 진행 단계별 명령 +- prediction CSV 선택 +- 실제 close vs 예측 close 차트 +- MAE/RMSE/MAPE/방향정확도 +- 예상등락률 Top-K 검증 표 + +## Commit 운영 원칙 + +커밋에 포함: + +- 코드 +- 테스트 +- 문서 +- 설정 템플릿 + +커밋 제외: + +- `_database/` +- 대용량 CSV +- 모델 checkpoint +- 일회성 prediction 결과 대용량 파일 + +## 현재 구현 상태 + +- 환경 점검 CLI: `finetune_csv/stom_ohlcv_pipeline.py` +- 예측 검증 CLI: `finetune_csv/stom_prediction_eval.py` +- 웹 대시보드: `webui/templates/stom_dashboard.html`, `/stom` +- 대시보드 API: + - `/api/stom/summary` + - `/api/stom/prediction-files` + - `/api/stom/prediction` + +## 목표 달성 기준 + +다음이 모두 통과하면 구축 목표를 달성한 것으로 본다. + +```powershell +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py -q +python -m compileall -q finetune_csv webui tests docs +python finetune_csv/stom_ohlcv_pipeline.py env-check +python finetune_csv/stom_prediction_eval.py --help +``` diff --git a/docs/stom_gpu_rental_kronos_training_plan.md b/docs/stom_gpu_rental_kronos_training_plan.md new file mode 100644 index 000000000..4338a70d9 --- /dev/null +++ b/docs/stom_gpu_rental_kronos_training_plan.md @@ -0,0 +1,317 @@ +# STOM tick Kronos 학습용 GPU 대여/상위 모델 검토 보고서 + +작성일: 2026-05-10 +대상 저장소: `D:\Chanil_Park\Project\Programming\Kronos` + +## 1. 결론 요약 + +핵심 목적은 **STOM tick 전체/연도별 데이터를 더 많이 학습하여 Kronos 예측 가능성이 실제로 상승하는지 검증**하는 것이다. 현재 로컬 4080 Super에서 완료한 공식 200k 학습은 전체 pred60 train 가능량 대비 약 0.27% 수준이므로, 연구 관점에서는 더 큰 학습 실험을 해볼 가치가 있다. 다만 **더 큰 GPU/더 큰 모델이 예측률 상승을 보장하지는 않는다.** 반드시 동일 holdout, random/persistence 비교, 비용 차감 Top-K backtest, rolling validation으로 검증해야 한다. + +권장 순서: + +1. **클라우드 RTX 5090 1대**에서 2025년 Kronos-small 전체 학습을 먼저 실행한다. + - 예상 시간: 약 3.7일 + - 예상 비용: 약 $60~$87, provider/가격에 따라 변동 +2. 2025년 holdout/test 및 2026년 데이터로 성과를 비교한다. +3. 성과가 개선되면 **Kronos-base + RTX 5090/H100/H200** 비교 실험으로 넘어간다. +4. base에서도 개선이 확인될 때만 전체 2022~2026 학습 또는 B200/H200 장기 학습을 검토한다. + +가장 비용 대비 좋은 1차 선택지는 **RTX 5090 cloud**다. H100/H200/B200은 더 빠르지만, 현재 코드가 FP8/AMP/DDP를 충분히 활용하지 못하므로 가격 대비 효율이 항상 좋지는 않다. + +--- + +## 2. 현재 로컬 실측 기준 + +현재 완료된 공식 학습: + +- 모델: `NeoQuasar/Kronos-small` +- tokenizer: `NeoQuasar/Kronos-Tokenizer-base`에서 시작해 STOM용 200k tokenizer fine-tune +- predictor: fine-tuned tokenizer를 사용해 `NeoQuasar/Kronos-small` predictor 200k fine-tune +- GPU: RTX 4080 Super 16GB +- batch size: 4 +- num_workers: 0 +- sample mode: `full_sequential` +- lookback: 300초 +- pred: 60초 + +실측 시간: + +| 단계 | samples | 시간 | +|---|---:|---:| +| tokenizer | train 200k + val 40k | 3,211초, 약 53.5분 | +| predictor | train 200k + val 40k | 4,129초, 약 68.8분 | +| 합계 | 240k | 7,340초, 약 2.04시간 | + +이 보고서의 시간 계산은 위 실측치를 기준으로 선형 환산했다. 실제 클라우드에서는 CPU, 디스크, 데이터로더, PyTorch/CUDA 버전, batch size 조정에 따라 달라진다. + +--- + +## 3. STOM tick DB 직접 분석 결과 + +분석 기준: + +- DB: `_database/stock_tick_back.db` +- read-only 스캔 +- 1초 정규화 +- 09:00:00~09:30:00 +- lookback 300초 +- pred60 +- `price_mode=close_only` + +연도별 직접 계산 결과: + +| 연도 | 거래일/session | 전체 pred60 sample | 70/15/15 기준 train+val sample | +|---|---:|---:|---:| +| 2022 | 193 | 21,959,234 | 18,637,997 | +| 2023 | 244 | 28,176,371 | 24,002,988 | +| 2024 | 240 | 24,712,681 | 21,208,505 | +| 2025 | 240 | 26,569,619 | 22,694,289 | +| 2026 | 34 | 3,320,745 | 2,735,733 | +| 전체 reference | - | 104,738,650 | - | +| 전체 official manifest train+val | - | - | 89,656,982 | + +세부 산출물: + +- `.omx/analysis/stom_yearly_db_sample_report.json` +- `.omx/analysis/stom_yearly_db_split_sample_report.json` + +--- + +## 4. Kronos 모델별 의미 + +로컬 README 기준 모델 구성: + +| 모델 | 파라미터 | context | 상태 | 목적 | +|---|---:|---:|---|---| +| Kronos-mini | 4.1M | 2048 | 사용 가능 | 빠른 실험, 긴 context, 낮은 비용 | +| Kronos-small | 24.7M | 512 | 사용 가능 | 현재 기준 모델, 비용/속도/성능 균형 | +| Kronos-base | 102.3M | 512 | 사용 가능 | 더 높은 표현력, 충분한 데이터에서 성능 개선 검증 | +| Kronos-large | 499.2M | 512 | README상 공개 모델 없음 | 현재 즉시 실행 대상 아님 | + +해석: + +- **small**: 지금의 기준선이다. 1M, 5M, 2025년 학습까지는 small로 먼저 확인하는 것이 합리적이다. +- **base**: 2025년 small에서 개선 신호가 나오면 시도할 가치가 있다. 모델이 약 4.1배 크므로 학습 시간도 대략 3.5~4.5배 증가할 수 있다. +- **mini**: 빠른 사전 실험에는 좋지만 최종 예측 성능 기대치는 낮다. +- **large**: 공개/로컬 사용 가능성이 불명확하고, 현재 목표에서는 우선순위가 낮다. + +--- + +## 5. GPU 대여 서비스 선택지 + +가격은 2026-05-10 웹 확인 기준이며 수시로 변동된다. + +| 선택지 | 장점 | 단점 | 보고서 계산용 가격 | +|---|---|---|---:| +| RTX 5090 marketplace/cloud | 가장 비용 대비 좋음, 32GB VRAM, 2025년 small/base 실험 가능 | host 품질/중단/지역 차이, provider별 환경 편차 | $0.55~$0.99/hr | +| RunPod RTX 5090 | 접근성 좋고 템플릿/스토리지 편함 | 실제 pod 가격/가용성 변동 | 약 $0.69~$0.99/hr | +| Vast.ai RTX 5090 | 최저가 가능 | 보안/안정성/호스트 편차 큼 | 약 $0.15~$0.80/hr 범위 | +| Lambda H100/B200 | 관리형, 안정성, 빠른 datacenter GPU | 비용 높음 | H100 $3.29~$4.29/hr, B200 $6.99/hr | +| H200 | 141GB VRAM, 큰 모델/큰 batch 유리 | Kronos-small에서는 VRAM 이점이 제한적일 수 있음 | $2.25~$4.31/hr | +| B200 | 최고급, 180GB VRAM | 현재 코드가 FP8/멀티GPU 최적화를 못 쓰면 과투자 가능 | $3.40~$6.99/hr | + +주의: 현재 `train_tokenizer.py`, `train_predictor.py`에는 AMP/autocast 사용 흔적이 없고, 최근 실행은 single GPU + DDP disabled였다. 따라서 H100/H200/B200의 이론 성능을 그대로 쓰지 못한다. 클라우드 고급 GPU를 제대로 쓰려면 다음 최적화가 필요하다. + +- `torch.autocast`/AMP 또는 BF16 안정화 +- batch size 증가 +- `num_workers`, `pin_memory`, prefetch 조정 +- 데이터셋 pickle/CSV를 로컬 NVMe에 배치 +- 장기 학습 checkpoint/resume +- 멀티GPU를 쓸 경우 DDP 재검증 + +--- + +## 6. Kronos-small 기준 GPU별 시간/비용 계산 + +계산 가정: + +- 로컬 4080 Super 실측을 1.0배로 둔다. +- RTX 5090은 현실값 2.2배로 추정한다. +- A100은 1.8배, H100은 3.2배, H200은 3.5배, B200은 4.5배로 보수 추정한다. +- 가격은 대표 on-demand/marketplace 값을 사용한다. + +### 6.1 연도별 train+val 학습 시간/비용, Kronos-small + +| 범위 | sample | 4080S 로컬 | RTX 5090 $0.69/hr | RTX 5090 $0.99/hr | H100 $3.29/hr | H200 $4.31/hr | B200 $6.99/hr | +|---|---:|---:|---:|---:|---:|---:|---:| +| 2022 | 18,637,997 | 6.6일 | 3.0일 / $50 | 3.0일 / $71 | 2.1일 / $163 | 1.9일 / $195 | 1.5일 / $246 | +| 2023 | 24,002,988 | 8.5일 | 3.9일 / $64 | 3.9일 / $92 | 2.7일 / $210 | 2.4일 / $251 | 1.9일 / $317 | +| 2024 | 21,208,505 | 7.5일 | 3.4일 / $57 | 3.4일 / $81 | 2.3일 / $185 | 2.1일 / $222 | 1.7일 / $280 | +| 2025 | 22,694,289 | 8.0일 | 3.7일 / $60 | 3.7일 / $87 | 2.5일 / $198 | 2.3일 / $237 | 1.8일 / $300 | +| 2026 | 2,735,733 | 23.2시간 | 10.6시간 / $7 | 10.6시간 / $10 | 7.3시간 / $24 | 6.6시간 / $29 | 5.2시간 / $36 | +| 전체 official train+val | 89,656,982 | 31.7일 | 14.4일 / $239 | 14.4일 / $343 | 9.9일 / $783 | 9.1일 / $938 | 7.1일 / $1,183 | +| 전체 windows reference | 104,738,650 | 37.1일 | 16.9일 / $279 | 16.9일 / $400 | 11.6일 / $915 | 10.6일 / $1,096 | 8.2일 / $1,382 | + +해석: + +- **2025년 small 학습**은 RTX 5090으로 약 3.7일, 비용은 약 $60~$87 수준이다. +- **전체 official small 학습**은 RTX 5090으로 약 14.4일, 비용은 약 $239~$343 수준이다. +- H100/H200/B200은 빠르지만 비용은 RTX 5090보다 대체로 높다. + +--- + +## 7. 2025년 기준 모델별/GPU별 시간·비용 + +2025년 train+val 22,694,289 sample 기준이다. + +| 모델 | 4080S 로컬 | RTX 5090 $0.69/hr | H100 $3.29/hr | H200 $4.31/hr | B200 $6.99/hr | +|---|---:|---:|---:|---:|---:| +| Kronos-mini | 3.6일 | 1.6일 / $27 | 1.1일 / $89 | 1.0일 / $107 | 19.3시간 / $135 | +| Kronos-small | 8.0일 | 3.7일 / $60 | 2.5일 / $198 | 2.3일 / $237 | 1.8일 / $300 | +| Kronos-base | 30.5일 | 13.9일 / $230 | 9.5일 / $753 | 8.7일 / $902 | 6.8일 / $1,138 | +| Kronos-large, if available | 144.6일 | 65.7일 / $1,089 | 45.2일 / $3,568 | 41.3일 / $4,274 | 32.1일 / $5,391 | + +해석: + +- **Kronos-base 2025년 학습은 5090에서 약 14일**로 추정된다. 비용은 낮지만 중단 없이 2주간 안정 운용해야 한다. +- H100/H200/B200은 base 학습 시간을 7~10일대로 줄일 수 있지만, 비용은 수백~천 달러 수준으로 올라간다. +- large는 현재 공개/사용 가능성이 불명확하므로 계획상 제외하는 것이 맞다. + +--- + +## 8. 예측 성능 상승 가능성 평가 + +현재 official 200k 결과: + +- direction accuracy: 약 41.88% +- random baseline과 차이가 작음 +- 25bp 비용 gate 통과 실패 +- 실전 승인 불가, 연구용 기준선 + +더 많은 데이터/더 큰 모델이 좋아질 수 있는 이유: + +1. 현재 200k는 전체 official train 가능량 대비 약 0.27%뿐이다. +2. 2025년만 해도 train+val sample이 약 2,269만으로 200k 대비 113배 수준이다. +3. 종목/날짜/장세 다양성이 늘면 단순 overfit이 줄 수 있다. +4. Kronos-base는 small보다 표현력이 높아, 충분한 데이터에서는 패턴 포착 가능성이 더 높다. + +하지만 성능이 안 오를 수 있는 이유: + +1. 1초봉은 microstructure noise가 매우 크다. +2. 거래대금 상위 종목 universe가 매일 바뀐다. +3. 오래된 데이터와 최신 데이터의 시장 regime이 다르다. +4. 방향 정확도 50%를 넘어도 수수료/슬리피지 후 수익성이 음수일 수 있다. +5. 큰 모델은 데이터가 충분해도 잘못된 split이나 누수/과최적화가 있으면 실전 성과가 악화될 수 있다. + +따라서 목표 지표는 단순 정확도 하나가 아니라 다음이어야 한다. + +| 지표 | 통과 기준 예시 | +|---|---| +| direction accuracy | random 대비 안정적 우위 | +| Top-K actual return | persistence/random보다 우위 | +| net return after cost | 0 이상 또는 baseline 대비 의미 있는 개선 | +| rolling validation | 여러 fold에서 반복 개선 | +| 2026 out-of-sample | 2025 학습 모델이 2026에도 유지되는지 확인 | + +--- + +## 9. 추천 실행 로드맵 + +### Phase A: 클라우드 이전 준비, 로컬 + +목표: 대여 시간을 낭비하지 않도록 클라우드 실행 전 준비 완료. + +1. 2025년 전용 processed dataset 생성 또는 기존 DB 기반 export command 확정 +2. checkpoint/resume 기능 확인 +3. 200k benchmark를 클라우드에서 30~60분 돌려 실제 samples/sec 측정 +4. 비용 상한 설정 + +### Phase B: RTX 5090 2025-small 본학습 + +권장 provider: + +- 1순위: RunPod/Neev/Runcrate 등 RTX 5090 on-demand +- 2순위: Vast.ai 저가 spot, 단 중단/보안/스토리지 위험 감수 + +목표: + +- Kronos-small 2025 train+val 학습 +- 예상 시간: 약 3.7일 +- 예상 비용: 약 $60~$87 +- 평가: 2025 test, 가능하면 2026 holdout + +### Phase C: 성과 판단 + +통과 조건: + +- official 200k보다 direction accuracy 상승 +- random/persistence 대비 유의미한 우위 +- Top-K net return 개선 +- rolling validation에서 손실 축소가 아니라 실제 positive net에 접근 + +실패하면: + +- 전체 학습 확대보다 feature/target/조건식/비용 구조 개선 우선 + +### Phase D: Kronos-base 비교 + +조건: Phase B에서 개선 확인 시. + +- RTX 5090: 약 14일, $230~$330 +- H100/H200: 약 9일 전후, $750~$900 +- B200: 약 7일, $1,100 이상 + +목표: + +- small 대비 base가 성능을 높이는지 동일 test에서 비교 + +### Phase E: 전체 학습 + +조건: 2025-small/base에서 out-of-sample 우위 확인 시. + +- RTX 5090 small 전체: 약 14~17일, $240~$400 +- H100/H200 small 전체: 약 9~12일, $780~$1,100 +- B200 small 전체: 약 7~8일, $1,180~$1,380 +- base 전체는 RTX 5090 기준 약 55일 이상 가능성이 있어 우선순위 낮음 + +--- + +## 10. 데이터/보안/운영 리스크 + +클라우드 대여 시 주의사항: + +1. DB 원본 `stock_tick_back.db`는 약 29.7GB다. 업로드/다운로드 시간이 별도로 든다. +2. 가능하면 원본 DB 대신 2025년 processed dataset만 업로드한다. +3. Vast.ai 같은 marketplace는 저렴하지만 host 신뢰/중단 위험이 있다. +4. 민감한 전략/데이터라면 managed provider 또는 encrypted volume을 사용한다. +5. spot instance는 중단 가능성이 있어 checkpoint를 자주 저장해야 한다. +6. 장기 학습은 WandB/Comet 없이도 로컬 JSON 로그와 checkpoint를 주기적으로 외부 저장소에 복사해야 한다. + +--- + +## 11. 최종 선택지 + +| 선택지 | 목적 | 권장도 | +|---|---|---:| +| 로컬 4080S로 2025-small | 비용 0, 단 8일 소요 | 중 | +| RTX 5090 대여로 2025-small | 가장 합리적인 첫 본학습 | 매우 높음 | +| RTX 5090 대여로 2025-base | small 개선 후 비교 | 높음 | +| H100/H200으로 2025-base | 시간 단축, 비용 증가 | 중상 | +| B200으로 small/base | 빠르지만 현재 코드 최적화 전에는 과투자 가능 | 중 | +| 전체 데이터를 바로 base로 학습 | 비용/시간/검증 리스크 큼 | 낮음 | + +최종 권장: + +```text +1차: RTX 5090에서 2025년 Kronos-small 학습 +2차: 2025 test + 2026 holdout 검증 +3차: 개선 확인 시 Kronos-base 비교 +4차: base도 개선되면 전체 학습 검토 +``` + +이 순서가 비용 대비 가장 안전하고, “학습량 증가가 예측 가능성 상승으로 이어지는가”를 가장 빠르게 검증할 수 있다. + +--- + +## 12. 참고 출처 + +- NVIDIA RTX 5090 공식 사양: https://www.nvidia.com/en-us/geforce/graphics-cards/50-series/rtx-5090/ +- NVIDIA H100 공식 설명: https://www.nvidia.com/en-us/data-center/h100/ +- NVIDIA H200 공식 설명: https://www.nvidia.com/en-us/data-center/h200/ +- RTX 4080 Super 사양 참고: https://www.techspot.com/specs/gpu/290834-nvidia-geforce-rtx-4080-super.html +- Lambda GPU Cloud pricing: https://lambda.ai/pricing +- RunPod pricing: https://www.runpod.io/pricing +- DeployBase RTX 5090 cloud pricing overview: https://deploybase.ai/articles/rtx-5090-cloud +- gpus.io RTX 5090 price comparison: https://gpus.io/en/gpus/rtx5090 +- Runcrate RTX 5090 pricing/spec overview: https://www.runcrate.ai/pricing/gpu/rtx-5090 +- NeevCloud RTX 5090 pricing: https://www.neevcloud.com/nvidia-rtx-5090.php diff --git a/docs/stom_independent_rl_lab_plan_2026-05-22.md b/docs/stom_independent_rl_lab_plan_2026-05-22.md new file mode 100644 index 000000000..ff474afad --- /dev/null +++ b/docs/stom_independent_rl_lab_plan_2026-05-22.md @@ -0,0 +1,1104 @@ +# STOM Tick DB 독립 강화학습 실험실 설계 계획서 + +작성일: 2026-05-22 KST +대상 저장소: `D:\Chanil_Park\Project\Programming\Kronos` +계획 방식: `$ralplan --deliberate` 성격의 설계 문서 +핵심 방향: **Kronos를 사용하지 않고**, 현재 존재하는 STOM tick/1초봉 DB를 이용해 독립 강화학습 기능과 웹 대시보드 탭을 구축한다. + +--- + +## 1. 한 줄 결론 + +가능하다. 다만 첫 목표는 “강화학습으로 바로 수익 보장”이 아니라, **STOM tick DB에서 비용 차감 후 실제로 baseline보다 나은 매매 행동을 학습할 수 있는지 검증하는 독립 실험실**을 만드는 것이다. + +추천 구조는 다음과 같다. + +> **Gymnasium 호환 STOM 전용 trading environment + RLTrader식 주식 매매 상태/행동/보상 아이디어 + 자체 walk-forward/cost-gate 검증 + 신규 웹 탭** + +--- + +## 2. 배경과 문제 정의 + +기존 작업에서 STOM 2025년 1초봉 데이터는 Kronos fine-tuning 파이프라인에 연결됐고, 학습/평가/대시보드까지 구축됐다. 그러나 평가 결과는 실전 신호로 바로 사용하기 어렵다. + +| 항목 | 현재 상태 | +|---|---| +| Kronos fine-tuning 파이프라인 | 정상 동작 | +| STOM 2025 1초봉 export | 완료 | +| 60초 방향 적중률 | 약 44.79%, random과 유사 | +| 300초 horizon | 상대적으로 유망하지만 cost gate 미통과 | +| 결론 | 가격 경로 예측 모델만으로는 비용 차감 후 수익성이 부족 | + +따라서 다음 질문으로 전환한다. + +> “미래 가격을 예측하는 모델”이 아니라, **주어진 상태에서 매수/관망/청산 행동을 선택해 비용 차감 후 수익을 높이는 모델**을 만들 수 있는가? + +이 질문은 강화학습 또는 contextual bandit 계열의 실험 대상이다. + +--- + +## 3. 참고 오픈소스 검토 요약 + +### 3.1 Gymnasium + +- 저장소: +- 문서: +- 역할: 강화학습 환경 API 표준 +- 핵심 API: `reset()`, `step()`, `action_space`, `observation_space` + +우리 프로젝트 적용 판단: + +| 판단 | 내용 | +|---|---| +| 채택 수준 | 핵심 기반으로 채택 권장 | +| 이유 | Stable-Baselines3, RLlib 등과 연결하기 쉬움 | +| 한계 | 주식/체결/수수료/슬리피지는 직접 구현해야 함 | +| 적용 방식 | `StomTickTradingEnv`를 Gymnasium 호환 형태로 설계 | + +### 3.2 RLTrader + +- 저장소: +- 역할: 한국 주식 강화학습 예제/학습 프로젝트 +- 장점: 주식 매매의 행동, 포트폴리오, 보상 구조 아이디어가 있음 +- 한계: 오래된 의존성, 최신 Gymnasium 표준 아님, STOM 초단위 tick/1초봉 구조와 직접 맞지 않음 + +우리 프로젝트 적용 판단: + +| 판단 | 내용 | +|---|---| +| 채택 수준 | 코드 이식보다 설계 아이디어 참고 | +| 참고할 점 | 매수/매도/관망, 보유 비율, 거래비용, 포트폴리오 상태 | +| 피할 점 | 구버전 학습 루프를 그대로 가져오기 | +| 적용 방식 | Gymnasium API 안에 RLTrader식 상태/행동/보상 아이디어를 흡수 | + +--- + +## 4. RALPLAN-DR 요약 + +### 4.1 원칙 + +1. **Kronos와 완전 분리** + Kronos tokenizer, predictor, fine-tuning loop, checkpoint에 의존하지 않는다. + +2. **수익성은 비용 차감 후 판단** + 수수료, 세금, 슬리피지, 과매매 패널티를 반영한 net return을 1급 지표로 둔다. + +3. **미래 데이터 누수 금지** + 상태값에는 현재와 과거 정보만 사용한다. 미래 수익률은 보상/평가에만 사용한다. + +4. **baseline보다 못하면 실패** + random, no-trade, buy-and-hold, momentum, mean-reversion보다 나아야 다음 단계로 간다. + +5. **재현 가능한 실험 우선** + seed, config, 데이터 범위, artifact, report를 모두 남긴다. + +### 4.2 Top decision drivers + +| 순위 | 의사결정 기준 | 이유 | +|---:|---|---| +| 1 | STOM tick/1초봉 DB를 직접 읽는 환경 | Kronos 비의존 목표의 핵심 | +| 2 | 거래비용·슬리피지·체결 제약 반영 | 초단기 데이터에서는 비용이 성과를 크게 좌우 | +| 3 | 웹 대시보드 해석 가능성 | 사용자가 학습/성과/리스크를 직접 확인해야 함 | + +### 4.3 선택지 비교 + +#### Option A. Gymnasium-compatible custom environment + +| 항목 | 내용 | +|---|---| +| 설명 | STOM 전용 환경을 Gymnasium 표준 API로 직접 구현 | +| 장점 | 표준 알고리즘 연결 쉬움, 테스트/재현성 좋음, 확장성 높음 | +| 단점 | 주식 체결/비용/보상은 직접 구현 필요 | +| 판단 | 단독으로도 가능하지만 도메인 설계 참고가 필요 | + +#### Option B. RLTrader 스타일 자체 루프 + +| 항목 | 내용 | +|---|---| +| 설명 | RLTrader처럼 주식 매매 전용 학습 루프를 직접 구성 | +| 장점 | 주식 매매 맥락 이해가 쉽고 빠른 프로토타입 가능 | +| 단점 | 최신 RL 생태계와 호환성 낮음, 유지보수 위험 | +| 판단 | 그대로 이식은 비추천 | + +#### Option C. 하이브리드: Gymnasium API + RLTrader식 매매 아이디어 + +| 항목 | 내용 | +|---|---| +| 설명 | 외부 알고리즘 호환은 Gymnasium, 상태/행동/보상은 STOM/RLTrader식으로 설계 | +| 장점 | 표준성과 주식 도메인 적합성 균형 | +| 단점 | 경계와 artifact schema를 명확히 해야 함 | +| 판단 | **추천안** | + +--- + +## 5. ADR: 추천 결정 + +### Decision + +**Option C를 채택한다.** + +STOM 강화학습 실험실은 Gymnasium 호환 custom environment로 만들고, RLTrader에서 얻은 주식 매매 상태/행동/포트폴리오 아이디어를 STOM tick/1초봉 데이터 구조에 맞게 재설계한다. + +### Drivers + +- Kronos와 독립된 새 기능이어야 한다. +- 장기적으로 DQN/PPO/A2C/RLlib/Stable-Baselines3 계열을 붙일 수 있어야 한다. +- 실전 수익성 판단은 비용 차감 후 walk-forward로 해야 한다. + +### Alternatives considered + +| 대안 | 기각/보류 이유 | +|---|---| +| FinRL/TensorTrade 전체 도입 | 무겁고 STOM 초단위 구조에 맞추려면 오히려 복잡해질 수 있음 | +| RLTrader 직접 이식 | 오래된 의존성과 비표준 루프 때문에 유지보수 위험 | +| 순수 자체 구현만 사용 | 외부 RL 알고리즘 생태계와 연결성이 떨어짐 | + +### Consequences + +- 1차 구현은 모델보다 환경/보상/검증을 먼저 만든다. +- 새 의존성 `gymnasium`은 실제 구현 단계에서 도입 후보로 둔다. 단, 초기에는 의존성 추가 없이 Gymnasium식 인터페이스만 맞추는 방식도 가능하다. +- Stable-Baselines3는 바로 필수는 아니다. baseline 검증 이후 도입한다. + +--- + +## 6. 데이터 범위와 계약 + +### 6.1 원본 데이터 + +| 항목 | 계획 | +|---|---| +| 원본 | STOM tick DB / 1초봉 DB | +| 읽기 방식 | 원본 DB 수정 금지, read-only | +| 우선 범위 | 2025년 09:00~09:30 1초봉부터 시작 | +| 확장 범위 | 전체 연도, 전체 tick, 여러 시간대 | +| 종목 universe | 매일 달라질 수 있음. 종목 고정 모델이 아니라 날짜-종목 episode로 처리 | + +### 6.2 데이터 단위 + +초기 구현은 다음 단위가 안전하다. + +```text +episode = 특정 날짜 + 특정 종목 + 09:00~09:30 1초봉 구간 +``` + +이후 확장: + +```text +episode = 특정 날짜 + 거래대금 상위 N개 종목 포트폴리오 +``` + +### 6.3 누수 방지 규칙 + +| 금지 | 이유 | +|---|---| +| 미래 30/60/300초 수익률을 observation에 포함 | 정답 누수 | +| 하루 전체 거래대금 순위를 현재 시점 feature로 사용 | 미래 체결량 포함 가능 | +| 테스트 기간으로 reward/feature scaler를 fit | 평가 오염 | +| 종목별 전체 기간 통계를 현재 시점에 사용 | 미래 분포 누수 | + +### 6.4 STOM 데이터 자원 실측 (Kronos export 기준) + +기존 Kronos 파이프라인이 동일 데이터에서 만든 통계를 그대로 강화학습 환경의 episode 자원 추정치로 활용한다. 근거 파일: `finetune/qlib_exports/stom_1s_grid_pred60_2025/stom_qlib_export_report.json` + +| 항목 | 값 | +|---|---:| +| 원본 DB | `_database/stock_tick_back.db` (read-only) | +| 적용 구간 | 2025-01-03 ~ 2025-12-30 09:00~09:30 | +| compatible tables | 2,425 / 2,427 | +| 2025 row 보유 tables | 1,638 | +| sessions | 240 (train 168 / validation 36 / test 36) | +| exported group count | 18,750 (episode 단위 후보) | +| exported row count | 33,360,325 | +| train rows | 23,579,043 | +| validation rows | 4,920,437 | +| test rows | 4,860,845 | + +함의: + +1. **episode pool 규모**: train 13,256 / val 2,764 / test 2,730 group이 그대로 RL episode 후보가 된다. baseline 충돌 없이 1차 실험이 가능한 규모다. +2. **session split 재사용**: Kronos가 사용한 168/36/36 session split을 그대로 사용하면 후속 비교가 쉽다. +3. **read-only 강제**: 학습 코드는 `_database/stock_tick_back.db`를 SQLite `mode=ro` URI 또는 OS read-only 권한으로만 열어야 하며, 단위 테스트로 쓰기 호출 부재를 확인한다. + +### 6.5 누수 방지 검증 메커니즘 + +규칙 명시뿐 아니라 다음 자동화된 검증 단계를 1차 환경 구현에 포함한다. + +| 검증 | 도구 | 기대값 | +|---|---|---| +| observation에 미래 timestamp 없음 | unit test (`tests/test_rl_env_no_leakage.py`) | observation t에 t+1 이후 row 0건 | +| feature scaler train fit | pipeline assertion | test set fit 호출 0건 | +| 종목별 통계 cutoff | manifest 검사 | `as_of_ts <= current_step_ts` 100% | +| reward에 사용된 future 가격 별도 분리 | env 내 검사 | observation 텐서와 reward 입력 텐서 dtype/source 다름 | +| read-only DB | DB connect mock | 모든 `sqlite3.connect`가 `mode=ro` 사용 | + +--- + +## 7. 상태 공간 설계 + +### 7.1 1차 observation 후보 + +| 그룹 | 변수 예시 | 설명 | +|---|---|---| +| 가격 | open, high, low, close | 1초봉 OHLC | +| 거래 | volume, amount | 거래량/거래대금 | +| 수익률 | ret_1s, ret_5s, ret_30s, ret_60s | 과거 수익률만 사용 | +| 변동성 | rolling_std_30s, high_low_range_30s | 최근 변동성 | +| 거래 강도 | volume_z_30s, amount_z_30s | 최근 평균 대비 강도 | +| 캔들 구조 | body_ratio, upper_shadow, lower_shadow | 순간 매수/매도 압력 proxy | +| 시간 | seconds_from_open, minute_bucket | 장초반 시간 위치 | +| 포지션 | position, entry_price, unrealized_pnl | 현재 보유 상태 | +| 리스크 | drawdown, trade_count, time_in_position | 과매매/손실 관리 | + +### 7.2 observation shape + +초기 권장: + +```text +lookback_window = 60~300초 +feature_count = 20~40개 +observation = [lookback_window, feature_count] +``` + +초기에는 300초 lookback이 무겁다면 60초/120초부터 시작하고, 이후 300초로 확장한다. + +--- + +## 8. 행동 공간 설계 + +### 8.1 1차 단순 행동 + +```text +0 = 관망 / 유지 +1 = 매수 또는 롱 진입 +2 = 청산 +``` + +국내 주식 현물 기준에서는 공매도 없이 롱 전용이 현실적이다. + +### 8.2 2차 확장 행동 + +```text +0 = 현금 0% +1 = 보유 25% +2 = 보유 50% +3 = 보유 100% +``` + +이 방식은 position target 방식이라 PPO/A2C와 연결하기 쉽다. + +### 8.3 3차 포트폴리오 행동 + +여러 종목 동시 선택은 후순위다. 초기에는 단일 종목 episode에서 환경과 보상 검증을 완료한 뒤 확장한다. + +--- + +## 9. 보상 함수 설계 + +### 9.1 기본 보상 + +```text +reward_t = realized_or_mark_to_market_return_t + - transaction_cost_t + - slippage_t + - turnover_penalty_t + - drawdown_penalty_t + - invalid_action_penalty_t +``` + +### 9.2 보상 구성 요소 + +| 항목 | 설명 | +|---|---| +| realized_or_mark_to_market_return | 보유 중 평가손익 또는 청산 손익 | +| transaction_cost | 수수료/세금/기타 비용 | +| slippage | 체결 가격 불리함 | +| turnover_penalty | 너무 잦은 매매 억제 | +| drawdown_penalty | 큰 손실과 급격한 낙폭 억제 | +| invalid_action_penalty | 보유 중 재매수, 미보유 청산 등 이상 행동 억제 | + +### 9.3 추천 비용 기본값 + +초기 문서/실험에서는 보수적으로 여러 비용 시나리오를 병렬 계산한다. + +| 시나리오 | 총 비용 가정 | +|---|---:| +| 낙관 | 5bp | +| 중립 | 10~15bp | +| 보수 | 25bp | + +기존 Kronos 평가에서 25bp 비용이 성과를 크게 악화시켰으므로, RL에서도 25bp cost gate를 반드시 포함한다. + +--- + +## 10. Baseline 비교군 + +강화학습 모델은 다음보다 좋아야 한다. + +| baseline | 목적 | +|---|---| +| no-trade | 아무것도 하지 않는 기준 | +| random | 모델이 랜덤보다 나은지 확인 | +| buy-and-hold | 장초반 구간 단순 보유 대비 | +| simple momentum | 최근 상승 종목을 따라가는 규칙 | +| mean reversion | 급등/급락 후 되돌림 규칙 | +| volume/amount filter | 거래대금/거래량 조건식 기반 규칙 | + +보고서는 반드시 다음을 분리한다. + +1. 비용 미차감 gross return +2. 비용 차감 net return +3. 거래 수 +4. 승률 +5. MDD +6. turnover +7. split별 성과 + +### 10.1 Kronos checkpoint 결과를 외부 비교군으로 활용 + +Kronos 의존을 학습 단계에서는 끊되, **비교 기준점**으로는 기존 결과를 그대로 활용한다. 같은 STOM 2025 test split(2025-11-07 ~ 2025-12-30, 36 sessions, 비용 25bp)에서 측정된 수치를 재현 없이 baseline 표에 고정한다. + +근거 파일: `webui/qlib_backtests/stom_1s_2025_full_small_horizon_comparison.json` + +| horizon | Kronos 방향 적중률 | random 방향 적중률 | Top-K net | 최적 필터 net | rolling net | rolling 방향 | gate | +|---:|---:|---:|---:|---:|---:|---:|---| +| 30초 | 39.21% | 38.77% | -0.2522% | -0.0465% | -0.2398% | 32.08% | 실패 | +| 60초 | 44.79% | 44.93% | -0.2041% | -0.0168% | -0.2043% | 39.39% | 실패 | +| 120초 | 45.67% | 42.73% | -0.1735% | -0.0335% | -0.2903% | 49.35% | 실패 | +| 300초 | 49.19% | 46.26% | -0.1145% | +0.0922% | -0.0052% | 44.29% | 실패 | + +RL 모델은 같은 split·비용 조건에서 **300초 horizon Kronos rolling net -0.0052%를 양수로 끌어올리는 것**을 1차 우위 기준으로 둔다. 즉 RL이 Kronos 모델보다 명확히 나은지 보려면 cost gate 통과 + 300초 행 대비 rolling net delta가 양수여야 한다. + +--- + +## 11. 모델 구축 로드맵 + +### 11.0 reward horizon 우선순위 + +기존 Kronos 실험에서 30/60/120/300초 중 **300초가 비용 차감 후 손익분기에 가장 가까운 horizon**이었다(섹션 10.1). RL 환경의 mark-to-market 평가도 동일 단위를 1순위로 둔다. + +| 우선순위 | reward horizon | 이유 | +|---:|---|---| +| 1 | 300초 청산 또는 mark-to-market | Kronos 비교군에서 cost gate에 가장 근접 | +| 2 | 120초 | 방향 edge +2.94%p 확보 구간 | +| 3 | 60초 | 기존 기본 horizon이지만 random 대비 edge 없음 | +| 후순위 | 30초 | 노이즈가 커 1차 검증에서 제외 가능 | + +30초는 환경 단위 시간이 1초이므로 step 단위로는 항상 측정 가능하지만, **보상/평가의 1차 horizon은 300초**로 시작한다. + +### 11.1 0단계: 모델 없는 환경 검증 + +가장 먼저 환경이 맞는지 확인한다. + +- `reset()`이 같은 seed에서 같은 episode를 반환하는가? +- `step()`이 포지션/현금/수익률을 정확히 갱신하는가? +- 수수료/슬리피지가 제대로 차감되는가? +- 미래 데이터를 observation에 넣지 않는가? + +### 11.2 1단계: 규칙 기반 baseline + +강화학습 이전에 규칙 전략으로 시장에 edge가 있는지 확인한다. + +### 11.3 2단계: Contextual Bandit + +초기 RL 후보로 가장 현실적이다. + +| 장점 | 이유 | +|---|---| +| 단순 | 각 시점의 행동 선택 문제로 시작 가능 | +| 과최적화 위험 낮음 | 긴 episode credit assignment 부담이 적음 | +| 대시보드 설명 쉬움 | 어떤 feature에서 어떤 행동을 골랐는지 해석 가능 | + +### 11.4 3단계: DQN + +행동이 관망/매수/청산처럼 discrete일 때 적합하다. + +### 11.5 4단계: PPO/A2C + +position target 방식으로 확장할 때 적합하다. + +### 11.6 5단계: 다종목 포트폴리오 RL + +단일 종목에서 비용 차감 후 baseline 우위를 확인한 뒤 진행한다. + +--- + +## 12. 실험 산출물 schema + +각 실험은 최소한 아래 파일을 남긴다. + +```text +webui/rl_runs/{run_id}/ + config.json + data_manifest.json + train_metrics.jsonl + eval_summary.json + walk_forward_splits.json + trades.csv + equity_curve.csv + actions.csv + risk_report.json + artifacts_manifest.json +``` + +### 12.1 핵심 metric + +| 지표 | 설명 | +|---|---| +| total_net_return_pct | 비용 차감 총수익률 | +| period_return | 기간 기준 성과 | +| max_drawdown_pct | 최대 낙폭 | +| hit_rate | 거래 승률 | +| avg_trade_return_pct | 거래당 평균 수익률 | +| trade_count | 거래 수 | +| turnover | 회전율 | +| cost_paid_pct | 비용으로 사라진 수익 | +| baseline_delta_pct | 기준 전략 대비 차이 | +| pass_cost_gate | 비용 gate 통과 여부 | +| rolling_overfit_gap_pct | train net - test net. Kronos 60초 실험에서 0.2874% 관측, RL은 0.15% 이내 목표 | +| positive_fold_rate | rolling fold 중 net return 양수 비율. Kronos 60초 실험 0.2857, RL은 0.50 이상 목표 | +| baseline_delta_300s_pct | 동일 split에서 Kronos 300초 rolling net (-0.0052%) 대비 차이 | +| cost_gate_pass_at_25bp | 25bp 비용 시나리오에서 양수 net return 달성 여부 | +| invalid_action_rate | 보유 중 재매수, 미보유 청산 등 무효 행동 비율 (1% 미만 목표) | +| episode_completion_rate | 강제 청산 없이 종료된 episode 비율 | + +--- + +## 13. 웹 대시보드 신규 탭 설계 + +탭 이름 후보: + +```text +강화학습 실험실 +``` + +내부 컴포넌트명 후보: + +```text +IndependentRLLabTab.svelte +``` + +### 13.1 화면 구성 + +| 섹션 | 내용 | +|---|---| +| 실험 개요 | run id, 데이터 범위, 종목 수, row 수, 모델 종류 | +| 데이터 계약 | 사용 feature, lookback, episode 정의, 누수 체크 결과 | +| 학습 진행 | episode, step, reward, loss, ETA, GPU/CPU 상태 | +| 성과 요약 | net return, MDD, 승률, 거래 수, baseline 대비 | +| 그래프 | reward curve, equity curve, drawdown curve | +| 거래 위치 | 실제 가격 차트 위 매수/청산 마커 | +| baseline 비교 | no-trade/random/momentum/mean-reversion 대비 | +| cost gate | 5/10/15/25bp 비용 시나리오 결과 | +| artifact | config/report/trades/actions 다운로드 또는 링크 | +| 경고 | overfit, high turnover, low trade count, leakage risk | + +### 13.2 API 후보 + +| endpoint | 역할 | +|---|---| +| `GET /api/rl/runs` | 실험 목록 | +| `GET /api/rl/runs/{run_id}` | 실험 상세 | +| `GET /api/rl/runs/{run_id}/metrics` | 학습 곡선 | +| `GET /api/rl/runs/{run_id}/equity` | 수익곡선 | +| `GET /api/rl/runs/{run_id}/trades` | 거래 내역 | +| `GET /api/rl/runs/{run_id}/baseline-comparison` | baseline 비교 | +| `GET /api/rl/runs/{run_id}/cost-gate` | 비용 gate | + +--- + +## 14. 성공 기준 + +### 14.1 1차 성공 기준: 플랫폼 구축 + +| 기준 | 통과 조건 | +|---|---| +| DB read-only loader | 원본 DB 수정 없이 episode 생성 | +| 환경 reset/step | deterministic test 통과 | +| 보상 계산 | 수수료/슬리피지/포지션 갱신 test 통과 | +| baseline runner | 최소 4개 baseline 실행 | +| artifact 생성 | config, metrics, trades, equity 저장 | +| 대시보드 | 새 탭에서 run 결과 확인 | + +### 14.2 2차 성공 기준: 모델 유효성 + +| 기준 | 통과 조건 | +|---|---| +| walk-forward | train/val/test 시간 분리 | +| leakage check | 미래 데이터 누수 없음 | +| net return | 비용 차감 후 baseline 2개 이상 대비 우위 | +| MDD | 허용 기준 이하 | +| turnover | 과매매 경고 기준 이하 | +| stability | split별 성과가 한 구간에만 몰리지 않음 | + +### 14.3 3차 성공 기준: 확장 승인 + +다음 조건을 모두 만족할 때만 전체 연도/전체 tick/포트폴리오 RL로 확장한다. + +1. 25bp 비용 기준에서 rolling net return이 양수 +2. random/no-trade/momentum 대비 유의미한 우위 +3. trade count가 너무 적지 않음 +4. 과최적화 gap이 과도하지 않음 +5. 대시보드에서 사용자가 거래 위치와 손익을 확인 가능 + +--- + +## 15. 구현 페이지별 단계 + +| 페이지 | 단계 | 목표 | 완료 기준 | +|---:|---|---|---| +| 1 | 설계/문서 | 상태·행동·보상·검증 기준 고정 | 본 문서 커밋 | +| 2 | DB loader | STOM tick/1초봉 episode manifest 생성 | row/session/symbol 통계 report | +| 3 | Env | `StomTickTradingEnv` reset/step 구현 | unit test 통과 | +| 4 | Baseline | no-trade/random/momentum/mean-reversion 실행 | baseline report 생성 | +| 5 | Reward/cost gate | 비용/슬리피지/turnover/MDD 계산 | cost gate report 생성 | +| 6 | 1차 모델 | contextual bandit 또는 DQN prototype | walk-forward eval report | +| 7 | Backend API | RL run artifact API | pytest/API smoke 통과 | +| 8 | Web tab | `강화학습 실험실` 탭 | build + browser smoke 통과 | +| 9 | Review | 성과/위험/확장 판단 | 확장/보류 결정 문서화 | + +현재 이 문서는 **페이지 1 완료**에 해당한다. + +--- + +## 16. 예상 리스크와 완화책 + +| 리스크 | 설명 | 완화책 | +|---|---|---| +| 강화학습 과최적화 | 과거 데이터에서만 수익 | strict walk-forward, unseen date 검증 | +| 거래비용 압박 | 초단기 수익이 비용에 잠식 | 25bp gate, 거래 횟수 penalty | +| 데이터 누수 | 미래 통계가 feature에 섞임 | feature builder 테스트, scaler split 분리 | +| 종목 universe 변화 | 매일 종목이 달라짐 | episode를 날짜-종목 단위로 분리 | +| 모델 해석 어려움 | 왜 매수했는지 알기 어려움 | action attribution, feature snapshot 저장 | +| 속도 문제 | tick 전체 학습 시간이 김 | 1초봉/2025 subset → 전체 확장 | + +--- + +## 17. 다음 권장 OMX 명령어 + +### 17.1 구현 전 확정 검토 + +```text +$ralplan --deliberate STOM 독립 강화학습 실험실 1차 구현 범위를 확정한다. 범위는 DB loader, Gymnasium 호환 StomTickTradingEnv, baseline runner, cost gate report, 웹 대시보드 탭 skeleton까지로 제한한다. +``` + +### 17.2 바로 구현 진행 + +```text +$ralph feature/stom-rl-lab 브랜치에서 STOM tick DB 기반 독립 강화학습 실험실 1차 구현을 진행하세요. 1차 범위는 DB loader, Gymnasium 호환 인터페이스의 StomTickTradingEnv, no-trade/random/momentum/mean-reversion baseline, cost-gate report, 웹 대시보드 '강화학습 실험실' 탭 skeleton, 테스트와 문서 업데이트입니다. +``` + +### 17.3 병렬 구현이 필요할 때 + +```text +$team STOM 독립 강화학습 실험실을 병렬 구현하세요. Lane A는 DB loader/env/test, Lane B는 baseline/cost-gate/evaluator, Lane C는 backend API/dashboard tab, Leader는 통합 검증과 문서/커밋 관리를 담당합니다. +``` + +--- + +## 18. 이번 문서의 stop condition + +이 문서는 구현이 아니라 방향 고정 문서다. 완료 조건은 다음이다. + +- Kronos 비의존 강화학습 방향이 명확함 +- Gymnasium과 RLTrader의 역할이 구분됨 +- 상태/행동/보상/비용/검증 기준이 문서화됨 +- 새 웹 탭과 artifact/API 구조가 제안됨 +- 다음 구현 명령어가 명확함 + +따라서 다음 단계는 **페이지 2: DB loader + episode manifest** 또는 **페이지 3: `StomTickTradingEnv` skeleton** 구현이다. + +--- + +## 19. 2026-05-22 보완 업데이트 기록 + +최초 작성(commit 8188284) 이후 동일 일자에 진행한 상세 검토에서 다음 항목이 보완되었다. 본문 내 해당 섹션은 모두 업데이트 완료 상태이며, 이 섹션은 변경 사항을 한 곳에서 추적하기 위한 색인이다. + +### 19.1 추가/보강된 섹션 + +| 위치 | 변경 | 출처/근거 | +|---|---|---| +| 6.4 | STOM 데이터 자원 실측치(18,750 group / 33.36M row / 168·36·36 session) 추가 | `finetune/qlib_exports/stom_1s_grid_pred60_2025/stom_qlib_export_report.json` | +| 6.5 | 누수 방지의 자동화 검증 메커니즘(5종 unit test) 추가 | 기존 Kronos 실험에서 rolling overfit gap 0.2874% 관측에 따른 강화 | +| 10.1 | Kronos checkpoint horizon별 결과를 외부 비교군으로 고정 | `webui/qlib_backtests/stom_1s_2025_full_small_horizon_comparison.json` | +| 11.0 | reward horizon 우선순위(300→120→60→30) 및 근거 추가 | 동일 horizon 비교표 | +| 12.1 | `rolling_overfit_gap_pct`, `positive_fold_rate`, `baseline_delta_300s_pct`, `cost_gate_pass_at_25bp`, `invalid_action_rate`, `episode_completion_rate` 신규 metric 6종 추가 | Kronos 60초 rolling 실험치 0.2874%/0.2857 등 | + +### 19.2 보완 후에도 남는 후속 결정 + +다음 항목은 구현 단계(페이지 2 이후)에서 결정한다. 본 문서에서는 의도적으로 결론을 내리지 않는다. + +1. **단위 시간 선택**: 1초봉 step을 그대로 쓰는지 또는 5초/10초 down-sampling을 쓰는지. 환경 검증 0단계 결과로 결정. +2. **invalid action 처리**: penalty만 줄지, 행동을 강제 보정할지. baseline runner 단계에서 비교. +3. **slippage 모델**: 고정 bp / 거래대금 비율 / 호가 기반 중 어떤 모델을 1차로 쓸지. 페이지 5 cost gate 단계에서 결정. +4. **종목 universe 결정 규칙**: 거래대금 상위 N의 N과 컷오프 시각. 종목 universe 변화 리스크(섹션 16)와 함께 페이지 2에서 결정. + +### 19.3 본문에서 손대지 않은 영역 + +다음은 검토했지만 문서 변경이 필요 없다고 판단한 항목이다. + +- 섹션 5 ADR 의사결정 자체: Option C(Gymnasium API + RLTrader식 매매 아이디어) 채택은 유지. +- 섹션 13 웹 대시보드 탭 명칭(`강화학습 실험실`)과 컴포넌트명(`IndependentRLLabTab.svelte`): 변경 사유 없음. +- 섹션 17 다음 권장 명령어 3종: 구현 단계로 진입할 때 그대로 사용 가능. + +### 19.4 검증 + +문서 변경은 markdown 구조 무결성과 한글 인코딩 보존을 기준으로 확인한다. + +| 검증 | 결과 | +|---|---| +| UTF-8 한글 보존 (물음표 손상 부재) | OK | +| 신규 섹션 헤더 레벨 일관성 | OK (`###` 하위, `##` 최상위 유지) | +| 표 컬럼 정렬 문법 | OK | +| 기존 섹션 번호 유지 | OK (18까지 동일, 19 신규 추가) | + +--- + +## 20. 2026-05-22 페이지 2 구현 착수 기록 + +장기 goal을 활성화하고 페이지 2를 구현 범위로 확정했다. + +### 20.1 페이지 2 범위 + +| 항목 | 결정 | +|---|---| +| 목적 | 모델 학습 전 episode 계약 고정 | +| 원본 DB | `_database/stock_tick_back.db` read-only | +| episode 기준 | 기존 2025 1초봉 Qlib CSV group | +| split 기준 | 기존 Kronos export의 train/val/test session split 재사용 | +| reward horizon | 300초 우선 | +| 산출물 | episode manifest JSON/CSV, summary JSON | + +### 20.2 구현 파일 + +| 파일 | 역할 | +|---|---| +| `stom_rl/episode_manifest.py` | read-only DB 검증과 episode manifest 생성 | +| `tests/test_stom_rl_episode_manifest.py` | read-only, split overlap, manifest artifact 테스트 | +| `docs/stom_rl_lab_goal_pages_2026-05-22.md` | 장기 goal 페이지별 구현 계획 | + +### 20.3 완료 기준 + +페이지 2는 다음을 모두 만족해야 완료로 본다. + +1. `sqlite3` 연결이 `mode=ro`와 `PRAGMA query_only=ON`을 사용한다. +2. 쓰기 probe가 실패하여 원본 DB 보호가 증명된다. +3. train/validation/test session overlap이 0이다. +4. manifest episode 수가 기존 export report의 group 수와 일치한다. +5. 테스트와 실제 manifest smoke 실행이 통과한다. + +### 20.4 완료 결과 + +페이지 2 구현 후 실제 smoke 실행에서 다음 결과를 확인했다. + +| 지표 | 결과 | +|---|---:| +| episode_count | 18,750 | +| symbol_count | 1,638 | +| session_count | 240 | +| train episodes | 13,256 | +| validation episodes | 2,764 | +| test episodes | 2,730 | +| manifest delta vs export report | 0 | +| split overlap | 0 | +| unknown split episodes | 0 | + +생성 산출물은 `webui/rl_runs/stom_1s_2025_episode_manifest/` 아래에 기록된다. 해당 디렉터리는 런타임 대용량 산출물이므로 `.gitignore`에 추가하고 커밋 대상에서 제외한다. + +--- + +## 21. 2026-05-22 페이지 3 환경 구현 기록 + +페이지 3에서는 실제 강화학습 모델이 상호작용할 수 있는 환경 skeleton을 추가했다. 이 단계도 아직 수익 모델 학습이 아니라, **행동-보상-상태 전이 계약을 검증하는 기반 작업**이다. + +### 21.1 구현 범위 + +| 항목 | 결정 | +|---|---| +| 환경 클래스 | `stom_rl.trading_env.StomTickTradingEnv` | +| 입력 | 페이지 2 episode manifest | +| API | Gymnasium 호환 `reset(seed, options)`, `step(action)` | +| 행동 | `hold`, `buy`, `sell` | +| 포지션 | long-only, 단일 포지션 | +| 기본 reward horizon | 300초 | +| observation | 과거 `lookback_window` row만 사용 | +| 누수 방지 | observation 마지막 timestamp가 action timestamp보다 항상 이전 | +| invalid action | penalty와 count로 기록 | + +### 21.2 기본 observation feature + +| feature | 설명 | +|---|---| +| open/high/low/close | 1초봉 가격 | +| volume/amount | 거래량/거래대금 | +| position | 현재 보유 여부 | +| unrealized_return | 미실현 수익률 | +| time_in_position | 포지션 유지 step 수 | + +### 21.3 검증 결과 + +| 검증 | 결과 | +|---|---| +| reset/step shape | OK | +| 300초 horizon timestamp | OK | +| no future observation | OK | +| invalid buy/sell 처리 | OK | +| deterministic replay | OK | +| 실제 manifest smoke | OK | + +페이지 3 완료 후 다음 단계는 **페이지 4: baseline runner** 이다. baseline runner는 이 환경을 사용하여 no-trade, random, buy-and-hold, momentum, mean-reversion, volume/amount filter를 같은 episode 계약에서 비교해야 한다. + +--- + +## 22. 2026-05-22 페이지 4 baseline runner 구현 기록 + +페이지 4에서는 강화학습 모델이 비교해야 할 모델 없는 기준선을 구현했다. 이 단계는 수익 모델 학습이 아니라, **향후 RL 모델이 반드시 넘어야 하는 기준표와 산출물 계약을 고정하는 작업**이다. + +### 22.1 구현 범위 + +| 항목 | 결정 | +|---|---| +| 구현 모듈 | `stom_rl.baselines` | +| 기반 환경 | `StomTickTradingEnv` | +| 기본 split | `test` | +| 기본 비용 | 25bp | +| 기본 reward horizon | 300초 | +| 기본 산출 위치 | `webui/rl_runs/stom_1s_2025_baselines*` | +| 커밋 대상 여부 | 런타임 산출물은 제외, 코드/테스트/문서만 커밋 | + +### 22.2 구현 baseline + +| 정책 | 목적 | +|---|---| +| `no_trade` | 아무것도 하지 않는 무위험 기준 | +| `random` | 모델이 랜덤 매매보다 나은지 확인 | +| `buy_and_hold` | 장초반 구간 단순 보유 기준 | +| `momentum` | 최근 수익률 추종 기준 | +| `mean_reversion` | 최근 하락 후 반등 가정 기준 | +| `volume_filter` | 거래대금 강도 조건식 기준 | + +### 22.3 산출물 + +각 정책은 다음 artifact를 생성한다. + +| 파일 | 설명 | +|---|---| +| `actions.csv` | step별 action, env reward, mark equity | +| `trades.csv` | 체결 단위 진입/청산/순수익/강제청산 여부 | +| `equity.csv` | 시간별 mark-to-market equity | +| `episodes.csv` | episode별 최종 equity, 거래 수, forced exit | +| `baseline_summary.json` | 전체 baseline 비교 summary | +| `baseline_summary.csv` | 대시보드/분석용 summary table | + +### 22.4 실제 smoke 결과 + +실제 2025 STOM test split에서 3개 episode만 사용해 경로를 검증했다. 이 결과는 성능 확정이 아니라 smoke 기준이다. + +| 정책 | episode | 거래 수 | 평균 episode net | hit rate | MDD | +|---|---:|---:|---:|---:|---:| +| `no_trade` | 3 | 0 | 0.0000% | 0.0000 | 0.0000% | +| `random` | 3 | 885 | -77.2541% | 0.0124 | -98.8246% | +| `buy_and_hold` | 3 | 3 | +3.3240% | 1.0000 | 0.0000% | +| `momentum` | 3 | 214 | -34.1359% | 0.0421 | -71.5770% | +| `mean_reversion` | 3 | 195 | -22.5387% | 0.0359 | -53.5298% | +| `volume_filter` | 3 | 224 | -31.2288% | 0.0045 | -67.6145% | + +### 22.5 검증 + +| 검증 | 결과 | +|---|---| +| baseline unit test | `10 passed` | +| env/manifest 회귀 | 통과 | +| 실제 manifest smoke | `baseline_summary.json/csv` 및 정책별 artifact 생성 | +| `py_compile` | 통과 | + +다음 단계는 **페이지 5: reward / cost gate**다. 페이지 5에서는 smoke가 아니라 전체 test split 또는 제한된 검증 범위를 명시하고 5/10/15/25bp 비용별로 baseline을 비교해 “비용 차감 후 살아남는 전략이 있는가?”를 판단한다. + +--- + +## 23. 2026-05-22 페이지 5 reward / cost gate 구현 기록 + +페이지 5에서는 baseline runner 위에 비용·슬리피지·회전율·MDD·rolling validation gate를 추가했다. 이 단계는 RL 모델의 성과를 좋게 보이게 만드는 작업이 아니라, **향후 모델이 통과해야 할 현실 비용 기준을 먼저 고정하는 작업**이다. + +### 23.1 구현 범위 + +| 항목 | 결정 | +|---|---| +| 구현 모듈 | `stom_rl.cost_gate` | +| 기반 모듈 | `stom_rl.baselines` | +| 기본 split | `test` | +| 기본 비용 시나리오 | 5bp, 10bp, 15bp, 25bp | +| 기본 target gate | 25bp | +| rolling validation | session chunk 단위 fold | +| 산출 위치 | `webui/rl_runs/stom_1s_2025_cost_gate*` | + +### 23.2 gate 판정 기준 + +| 기준 | 기본값 | 의미 | +|---|---:|---| +| `min_avg_episode_net_pct` | 0.0% | 비용 차감 평균 episode 수익률이 양수여야 함 | +| `max_drawdown_pct` | 20.0% | MDD가 -20%보다 나쁘면 실패 | +| `max_trades_per_episode` | 50.0 | 과도한 초단타/비용 민감 전략 실패 | +| `min_trade_count` | 1 | no-trade는 비교 기준일 뿐 gate 통과 전략이 아님 | +| `min_positive_fold_rate` | 0.5 | rolling fold 절반 이상에서 양수여야 함 | + +### 23.3 산출물 + +| 파일 | 설명 | +|---|---| +| `cost_gate_report.json` | 전체 설정, 비용 scenario, rolling, 최종 gate summary | +| `scenario_summary.csv` | cost/slippage/policy별 요약 | +| `rolling_folds.csv` | fold별 policy 성과 | +| `gate_summary.csv` | target 25bp 기준 최종 통과 여부 | + +### 23.4 실제 smoke 결과 + +실제 2025 STOM test split에서 3개 episode, 2개 rolling fold만 사용해 검증 경로를 확인했다. + +| 정책 | 25bp 평균 episode net | 거래/episode | hit rate | MDD | positive fold rate | gate | +|---|---:|---:|---:|---:|---:|---| +| `buy_and_hold` | +3.3240% | 1.00 | 1.0000 | 0.0000% | 0.5000 | 통과 | +| `no_trade` | 0.0000% | 0.00 | 0.0000 | 0.0000% | 0.0000 | 실패 | +| `mean_reversion` | -22.5387% | 65.00 | 0.0359 | -53.5298% | 0.0000 | 실패 | +| `volume_filter` | -31.2288% | 74.67 | 0.0045 | -67.6145% | 0.0000 | 실패 | +| `momentum` | -34.1359% | 71.33 | 0.0421 | -71.5770% | 0.0000 | 실패 | +| `random` | -77.2541% | 295.00 | 0.0124 | -98.8246% | 0.0000 | 실패 | + +이 smoke 결과는 전체 test split에 대한 결론이 아니다. 다만 다음 사실은 확인했다. + +1. 비용이 올라갈수록 random/momentum/volume 계열의 과매매 전략이 빠르게 붕괴한다. +2. 회전율 gate가 없으면 5bp에서 일시적으로 좋아 보이는 mean-reversion도 통과로 오판할 수 있다. +3. 25bp target gate에서는 거래 수가 낮고 수익이 큰 buy-and-hold만 smoke에서 통과했다. +4. 따라서 1차 RL 모델은 단순히 거래를 많이 하는 방향이 아니라 **낮은 회전율 + 선택적 진입 + 300초 horizon 수익**을 목표로 해야 한다. + +### 23.5 검증 + +| 검증 | 결과 | +|---|---| +| cost gate unit test | 통과 | +| baseline/env/manifest 회귀 | 통과 | +| 실제 manifest smoke | `cost_gate_report.json/csv` 산출물 생성 | +| `py_compile` | 통과 | + +다음 단계는 **페이지 6: 1차 RL 모델**이다. 추천 출발점은 300초 reward horizon, 25bp target gate 기준으로 contextual bandit 또는 단순 DQN prototype을 만든 뒤, Page 5의 `gate_summary.csv`와 동일 기준으로 모델 결과를 비교하는 것이다. + +--- + +## 24. 2026-05-22 페이지 6 1차 RL 모델 구현 기록 + +페이지 6에서는 Kronos와 완전히 분리된 첫 학습 모델을 추가했다. 이 단계의 목적은 최종 수익 모델을 확정하는 것이 아니라, **STOM episode에서 학습 → 모델 저장 → 모델 로드/사용 → 평가 artifact 생성**이 가능한 최소 학습 루프를 실제 데이터로 증명하는 것이다. + +### 24.1 구현 범위 + +| 항목 | 결정 | +|---|---| +| 구현 모듈 | `stom_rl.contextual_bandit` | +| 모델 | ridge regression 기반 contextual bandit | +| 의존성 | 신규 의존성 없음, NumPy/Pandas만 사용 | +| action | `buy` 또는 `hold` | +| reward target | 300초 fixed-horizon round-trip net return | +| 비용 | 25bp 기본 | +| 평가 | Page 5와 같은 net return, trade count, hit rate, MDD, cost gate | +| 산출 위치 | `webui/rl_runs/stom_1s_2025_contextual_bandit*` | + +### 24.2 사용 feature + +feature는 현재와 과거 정보만 사용한다. 미래 가격은 label/reward 계산에만 사용된다. + +| feature 그룹 | 예시 | +|---|---| +| 과거 수익률 | `ret_1s`, `ret_5s`, `ret_30s`, `ret_60s`, `ret_120s`, `ret_300s` | +| 변동성/range | `range_30s`, `range_120s` | +| 추세 이격 | `close_vs_ma_30s`, `close_vs_ma_120s` | +| 거래 강도 | `volume_ratio_30s`, `amount_ratio_30s` | +| 시간 위치 | `seconds_from_episode_start` | + +### 24.3 산출물 + +| 파일 | 설명 | +|---|---| +| `config.json` | 학습/평가 설정 | +| `model.json` | feature mean/std, weights, intercept, train summary | +| `train_metrics.jsonl` | train sample/target/오차 기록 | +| `eval_summary.json` | 평가 요약과 artifact 경로 | +| `actions.csv` | 각 의사결정 시점의 예측 점수와 action | +| `trades.csv` | 실제 진입/청산과 net return | +| `equity_curve.csv` | 평가 equity 흐름 | +| `episodes.csv` | episode별 최종 성과 | + +### 24.4 실제 smoke 결과 + +실제 2025 STOM train 5 episode로 학습하고 test 3 episode로 평가했다. + +| 항목 | 값 | +|---|---:| +| train episode | 5 | +| train sample | 299 | +| train target mean | -0.6087% | +| train target positive rate | 27.42% | +| train RMSE | 1.3035% | +| predicted positive rate | 11.04% | +| eval episode | 3 | +| eval trade count | 11 | +| trades / episode | 3.67 | +| avg episode net | +1.8960% | +| compounded return | +5.7773% | +| avg trade net | +0.5240% | +| hit rate | 63.64% | +| MDD | -2.6211% | +| 25bp cost gate | 통과 | + +해석: + +1. 모델은 실제 STOM episode에서 학습되고 `model.json`으로 저장된다. +2. 저장된 모델은 다시 로드해 예측 점수를 계산할 수 있다. +3. 3개 episode smoke에서는 25bp 비용 후에도 양수 성과를 냈다. +4. 그러나 같은 smoke 구간에서 단순 `buy_and_hold`가 +3.3240%였으므로, 아직 “baseline 우위”가 증명된 것은 아니다. +5. 다음 단계에서는 이 artifact를 backend/API와 웹 대시보드에서 확인할 수 있게 연결해야 한다. + +### 24.5 검증 + +| 검증 | 결과 | +|---|---| +| contextual bandit unit test | 통과 | +| model save/load test | 통과 | +| cost gate/baseline/env/manifest 회귀 | 통과 | +| 실제 manifest smoke | `model.json`, `eval_summary.json`, `trades.csv`, `equity_curve.csv` 생성 | +| `py_compile` | 통과 | + +다음 단계는 **페이지 7: backend API**다. API는 `webui/rl_runs` 아래의 baseline, cost gate, contextual bandit 산출물을 읽어 웹 대시보드에서 사용자가 강화학습 진행과 성과를 확인할 수 있게 해야 한다. + +--- + +## 25. 2026-05-22 페이지 7 backend API 구현 기록 + +페이지 7에서는 독립 강화학습 실험실의 런타임 산출물을 웹에서 조회할 수 있는 read-only API를 추가했다. 이 API는 학습을 시작하거나 원본 DB를 수정하지 않고, `webui/rl_runs` 아래의 재생성 가능한 artifact만 읽는다. + +### 25.1 구현 범위 + +| 항목 | 결정 | +|---|---| +| 구현 helper | `webui.rl_dashboard` | +| Flask 연결 | `webui.app` | +| API prefix | `/api/rl` | +| 대상 artifact | episode manifest, baseline, cost gate, contextual bandit | +| 보안 원칙 | run/policy path traversal 차단, direct child만 허용 | +| table limit | 기본 500 row, 최대 5,000 row | + +### 25.2 API 목록 + +| endpoint | 설명 | +|---|---| +| `GET /api/rl/runs` | RL run artifact 목록 | +| `GET /api/rl/runs/` | run 상세, summary, artifact 목록, 모델 요약 | +| `GET /api/rl/runs//actions` | action table | +| `GET /api/rl/runs//trades` | trade table | +| `GET /api/rl/runs//equity` | equity curve table | +| `GET /api/rl/runs//episodes` | episode summary table | +| `GET /api/rl/runs//table/` | generic table 조회 | +| `GET /api/rl/runs//cost-gate` | cost gate compact payload | + +baseline run은 정책별 subdirectory에 table이 있으므로 `?policy=buy_and_hold` 같은 query를 지원한다. policy를 생략하면 가능한 policy table을 limit 범위 안에서 합쳐 반환한다. + +### 25.3 실제 smoke 결과 + +실제 런타임 산출물 기준으로 Flask test client smoke를 수행했다. + +| endpoint | 결과 | +|---|---| +| `/api/rl/runs?limit=10` | 200, 4개 RL run 탐지 | +| `/api/rl/runs/stom_1s_2025_contextual_bandit_smoke` | 200, contextual bandit summary 반환 | +| `/api/rl/runs/stom_1s_2025_contextual_bandit_smoke/trades?limit=3` | 200, trade rows 반환, truncated=true | +| `/api/rl/runs/stom_1s_2025_contextual_bandit_smoke/equity?limit=3` | 200, equity rows 반환, truncated=true | +| `/api/rl/runs/stom_1s_2025_cost_gate_smoke/cost-gate?limit=3` | 200, passing policy `buy_and_hold` 반환 | + +### 25.4 검증 + +| 검증 | 결과 | +|---|---| +| helper list/detail/table test | 통과 | +| Flask `/api/rl/*` route smoke | 통과 | +| path traversal 거부 test | 통과 | +| contextual bandit/cost gate/baseline/env/manifest 회귀 | 통과 | +| 실제 runtime artifact smoke | 통과 | + +다음 단계는 **페이지 8: 웹 대시보드**다. 이제 프론트엔드에서 `/api/rl/runs`, `/api/rl/runs/`, `/api/rl/runs//trades`, `/api/rl/runs//equity`, `/api/rl/runs//cost-gate`를 사용해 “강화학습 실험실” 탭을 만들 수 있다. + + +--- + +## 26. 2026-05-23 페이지 8 웹 대시보드 구현 기록 + +페이지 8에서는 강화학습 산출물을 사용자가 직접 확인할 수 있도록 v2 대시보드에 RL 실험실 탭을 연결했다. 이 단계는 Kronos 예측/파인튜닝 화면과 별개로 독립 RL 모델 산출물, 비용 관문, 거래 위치, STOM DB 기반 episode 정보를 해석하는 화면이다. + +### 26.1 구현 파일 + +| 파일 | 역할 | +|---|---| +| `webui/v2_src/src/tabs/RLLabTab.svelte` | 강화학습 실험실 화면, 차트/표/해석 UI | +| `webui/v2_src/src/lib/api.ts` | RL API 호출용 fetch wrapper 추가 | +| `webui/v2_src/src/App.svelte` | `rl-lab` 탭 등록 | +| `webui/v2_src/src/layout/Sidebar.svelte` | 좌측 메뉴에 `강화학습 실험실` 추가 | +| `webui/v2_src/src/layout/Header.svelte` | 현재 위치 breadcrumb 표시 보강 | +| `tests/test_stom_rl_dashboard_tab.py` | 프론트 탭/API/dist marker 회귀 테스트 | +| `webui/static/v2/dist/*` | 빌드된 v2 정적 파일 갱신 | + +### 26.2 화면 구성 + +| 영역 | 표시 내용 | +|---|---| +| KPI 카드 | run 수, 평균 순수익률, 거래 수, cost gate 통과 정책 수 | +| Run selector | contextual bandit, cost gate, baseline, episode manifest 산출물 선택 | +| Model usage flow | `model.json`을 어디에 넣고 어떤 feature로 추론하는지 안내 | +| Cost gate chart/table | 25bp 기준 비용 반영 후 baseline/policy 통과 여부 | +| Equity curve | 선택 run의 누적 성과 흐름 | +| Trade chart/table | 진입/청산 위치와 gross net/cost net 차이 | +| Artifact table | 생성된 파일과 크기 확인 | + +### 26.3 검증 결과 + +| 검증 | 결과 | +|---|---| +| Svelte check | 0 errors, 기존 DocsTab/Forecast 경고 4개만 유지 | +| Vite build | 통과 | +| Pytest | `tests/test_stom_rl_dashboard_tab.py tests/test_stom_rl_dashboard_api.py tests/test_v2_route.py` 총 8 passed | +| Browser smoke | Playwright headless Chromium으로 `/` 접속 후 `rl-lab` 탭 확인 | +| Smoke screenshot | `.omx/tmp/rl-lab-page8-smoke.png` | + +### 26.4 해석 주의 + +현재 웹에 표시되는 contextual bandit 결과는 smoke 성과다. 이 수치는 플랫폼 연결과 모델 사용 흐름을 증명하기 위한 것이며 실거래 성과를 보장하지 않는다. 최종 QA에서는 full test split, baseline 대비 우위, cost gate 안정성, 자동 매매 보류 판단을 명확히 구분해야 한다. + +### 26.5 다음 단계 + +다음 페이지는 **페이지 9: 통합 QA / 리뷰**다. 권장 실행 명령은 다음과 같다. + +```powershell +omx ultragoal complete-goals +``` + +페이지 9에서는 전체 Python 테스트, v2 build, browser smoke를 다시 확인하고, `ai-slop-cleaner`와 code review 기준으로 구현 품질을 검토한 뒤 자동 매매 보류 또는 확장 판단을 문서화한다. + + +--- + +## 27. 2026-05-23 페이지 9 통합 QA / 리뷰 기록 + +페이지 9에서는 STOM 독립 강화학습 실험실을 최종 검증 대상으로 닫았다. 상세 보고서는 `docs/stom_rl_lab_final_qa_report_2026-05-23.md`에 남겼다. + +### 27.1 검증 요약 + +| 검증 | 결과 | +|---|---| +| 전체 pytest | 92 passed, 2 skipped, 2 warnings | +| npm build | 통과, Svelte error 0 | +| Python compile | 통과 | +| Browser smoke | 통과 | +| API smoke | 통과 | +| path traversal probe | 400으로 차단 | +| Code review | APPROVE / CLEAR | + +### 27.2 최종 판단 + +구현 목표는 달성했다. 독립 RL 플랫폼, 데이터 manifest, trading env, baseline, cost gate, 첫 모델, API, 웹 대시보드, 최종 QA 흐름이 모두 존재한다. + +단, 현재 1차 contextual bandit은 smoke 모델이므로 실거래 자동화에는 아직 충분하지 않다. 다음 확장에서는 full test split 기준으로 baseline과 RL 모델을 비교하고 비용 반영 후에도 반복적으로 우위가 유지되는지 검증해야 한다. diff --git a/docs/stom_kronos_conversation_work_summary.md b/docs/stom_kronos_conversation_work_summary.md new file mode 100644 index 000000000..b0950f452 --- /dev/null +++ b/docs/stom_kronos_conversation_work_summary.md @@ -0,0 +1,1444 @@ +# STOM tick Kronos 작업/대화/조사 통합 기록 + +작성일: 2026-05-10 KST +대상 저장소: `D:\Chanil_Park\Project\Programming\Kronos` +작성 브랜치: `work/stom-kronos-conversation-summary` +기준 원격: `origin/master` = `67b630e67f6a` +작업 기준 HEAD: `55650e600375` + +## 1. 문서 목적 + +이 문서는 지금까지 대화에서 결정한 방향, 실제 조사 결과, 코드/문서 작업, 학습 성과, 남은 단계, 그리고 `master`에 누적되어 있던 커밋의 의미를 한 번에 추적하기 위한 기록이다. + +핵심 목표는 다음 세 가지다. + +1. STOM 1tick/1초봉 DB를 Kronos 공식 학습 흐름에 맞게 사용할 수 있는지 검증한다. +2. 실제 학습 모델의 예측값을 웹 대시보드에서 실제값과 비교하고, 종목별/전체 통계로 성과를 판단한다. +3. 4080 Super 로컬 학습과 RTX 5090 이상 클라우드 GPU 대여를 비교해, 전체 STOM tick 학습을 어느 범위까지 확대할지 결정한다. + +--- + +## 2. 대화 흐름 요약 + +| 구간 | 질문/요구 | 정리된 결론 | +|---|---|---| +| 초기 검토 | STOM tick DB로 Kronos 학습/예측이 가능한가 | DB 테이블별 OHLCV 추출 후 Kronos 입력 형식으로 변환 가능. 원본 DB는 수정하지 않고 read-only로 사용해야 함. | +| GPU/실시간 예측 | GPU 사용 여부, 1분/실시간 예측 가능성 | CUDA GPU 사용 가능. 실시간 예측은 가능하지만 정확도 검증, 지연시간, 데이터 누수 방지, 운영 안정성이 별도 필요. | +| 일봉/종가매매 | 일봉 종가매매와 점수화 활용 | 예측 등락률, 방향성, confidence, Top-K ranking, 조건식 필터를 결합해 점수화 가능. 단, backtest와 holdout 검증이 필요. | +| 다른 프로그램 연동 | Future_trading 등 외부 추천 프로그램 적용 | 중간에 외부 연동을 검토했으나, 이후 사용자가 STOM tick 학습 자체를 우선하라고 하여 범위에서 제외. | +| 여러 종목 학습 | 여러 종목을 하나의 모델로 학습하는지 | Kronos는 여러 종목/여러 날짜를 하나의 시계열 학습 데이터셋으로 묶어 학습 가능. 매일 거래대금 상위 100위처럼 종목 구성이 바뀌어도 공통 모델 방식이 적합. | +| 1초봉/1분봉 | 09:00~09:30 1초봉 OHLCV 학습 가능 여부 | lookback 300초, pred30/pred60 같은 고정 horizon으로 학습 가능. 전체 길이/날짜/테이블을 window sample로 변환해야 함. | +| Qlib 논의 | 공식 가이드가 Qlib을 언급하는 이유 | QlibDataset 기반 학습은 Kronos 공식 파인튜닝 흐름에 더 가깝고, split/normalization/dataloader 재현성이 좋아 전체 학습 확대에 적합. | +| 정확도 0.4 문제 | 0.4면 랜덤보다 나은가 | 방향 정확도 0.4 수준은 단독 매매 신호로 부족. 더 많은 데이터, 공식 학습, rolling/holdout 검증, score 필터가 필요하다고 판단. | +| 공식 200k 학습 | Kronos 공식 순서 준수 여부 | tokenizer fine-tune 후 predictor fine-tune 순서로 200k 공식 실험을 완료. 전체 가능 샘플 대비 약 0.27% 데이터 coverage라 최종 판단에는 부족. | +| 대시보드 | 실제값/예측값/종목별 통계 시각화 | `/stom` 대시보드, 종목별 통계 API, 예측 CSV 기반 차트/필터/진단 기능을 구현하고 브라우저로 직접 확인. | +| 전체 학습/클라우드 | 2025년/전체 STOM tick 학습 시간과 5090 대여 | 2025년 Kronos-small은 4080S 약 8일, RTX 5090 약 3.7일 추정. 전체는 4080S 약 31.7일, RTX 5090 약 14.4일 추정. | + +--- + +## 3. 현재까지 구현/검증된 주요 산출물 + +### 3.1 STOM tick 데이터 변환 + +- `finetune_csv/stom_tick_dataset.py` +- `finetune_csv/prepare_stom_1tick.py` +- `finetune_csv/stom_ohlcv_pipeline.py` +- `finetune_csv/configs/config_stom_1tick*.yaml` + +기능: + +- STOM SQLite tick DB를 read-only로 읽음 +- 각 종목 테이블의 날짜/session을 분리 +- 09:00~09:30 구간 1초봉 기준으로 정규화 +- `lookback=300`, `pred=30/60` window 학습 샘플 생성 +- 기존 DB를 수정하지 않고 CSV/manifest/학습 입력으로 변환 + +### 3.2 Kronos 공식 파인튜닝 경로 + +- `finetune/train_tokenizer.py` +- `finetune/train_predictor.py` +- `finetune/dataset.py` +- `finetune/config.py` +- `finetune/run_stom_1s_finetune.py` +- `finetune/model_source.py` + +확인된 공식 순서: + +1. STOM 데이터로 tokenizer fine-tune +2. fine-tuned tokenizer를 사용해 predictor fine-tune +3. predictor checkpoint로 예측 CSV 생성 +4. holdout/test 및 대시보드에서 실제값과 비교 + +최근 완료 기준: + +- tokenizer: `NeoQuasar/Kronos-Tokenizer-base` +- predictor: `NeoQuasar/Kronos-small` +- 학습명: `stom_1s_grid_pred60_official_200k` +- train: 200,000 samples +- validation: 40,000 samples +- 전체 train 가능량 대비 coverage: 약 0.2713% +- train+val 가능량 대비 coverage: 약 0.2677% + +### 3.3 평가/필터/통계 + +- `finetune/evaluate_stom_1s_checkpoint.py` +- `finetune/search_stom_1s_filters.py` +- `finetune_csv/stom_prediction_eval.py` +- `tests/test_stom_*` + +확인한 내용: + +- 단순 방향 정확도 0.4 수준은 실제 사용에 부족 +- pred60 대형 walk-forward와 rolling 조건식 검증에서 비용 대비 확대 gate가 필요하다고 판단 +- 조건식/score band/Top-K를 보완층으로 두되, 과최적화 방지를 위해 rolling validation 필요 + +### 3.4 웹 대시보드 + +- `webui/stom_dashboard.py` +- `webui/templates/stom_dashboard.html` +- `webui/app.py` +- `webui/run.py` +- `webui/README_STOM_DASHBOARD.md` + +기능: + +- 예측 CSV 목록 조회 +- 실제값/예측값 그래프 확인 +- 종목별 통계 진단 +- 전체 통계 요약 +- 추천/score export 확인 +- 브라우저 직접 실행 검증 완료 + +현재 직접 확인용 서버 기록: + +- URL: `http://127.0.0.1:5000/stom` +- 마지막 확인 당시 Flask 서버가 정상 응답 +- 브라우저/Playwright fallback으로 화면 요소 검증 + +--- + +## 4. STOM tick DB 분석 결과 + +분석 기준: + +- DB: `_database/stock_tick_back.db` +- 원본 DB read-only +- 1초 정규화 +- 09:00:00~09:30:00 +- lookback 300초 +- pred60 +- `price_mode=close_only` + +연도별 직접 계산 결과: + +| 연도 | 거래일/session | 전체 pred60 sample | 70/15/15 기준 train+val sample | +|---|---:|---:|---:| +| 2022 | 193 | 21,959,234 | 18,637,997 | +| 2023 | 244 | 28,176,371 | 24,002,988 | +| 2024 | 240 | 24,712,681 | 21,208,505 | +| 2025 | 240 | 26,569,619 | 22,694,289 | +| 2026 | 34 | 3,320,745 | 2,735,733 | +| 전체 reference | - | 104,738,650 | - | +| 전체 official manifest train+val | - | - | 89,656,982 | + +2025년 상세: + +- compatible tables: 2,425 / 2,427 +- 2025년 row가 있는 tables: 1,638 +- sessions: 240 +- raw distinct rows: 30,435,244 +- regularized rows: 33,319,260 +- pred60 possible samples: 26,569,619 +- train+val samples: 22,694,289 + +--- + +## 5. 학습 시간/비용 조사 요약 + +### 5.1 로컬 RTX 4080 Super 실측 기준 + +공식 200k 실험 실측: + +| 단계 | samples | 시간 | +|---|---:|---:| +| tokenizer | train 200k + val 40k | 3,211초, 약 53.5분 | +| predictor | train 200k + val 40k | 4,129초, 약 68.8분 | +| 합계 | 240k | 7,340초, 약 2.04시간 | + +이를 기준으로 선형 환산하면: + +| 대상 | 4080 Super 예상 | +|---|---:| +| 2025년 Kronos-small train+val | 약 8.0일 | +| 전체 official train+val | 약 31.7일 | +| 전체 reference window | 약 37.1일 | + +### 5.2 RTX 5090 이상 GPU 대여 검토 + +웹 조사 기준 가격은 2026-05-10 확인값이며 변동 가능성이 크다. + +| 대상 | RTX 5090 예상 | 비용 추정 | +|---|---:|---:| +| 2025년 Kronos-small | 약 3.7일 | 약 $60~$87 | +| 전체 official train+val | 약 14.4일 | 약 $239~$343 | +| 전체 reference window | 약 16.9일 | 약 $279~$400 | + +권장 판단: + +1. RTX 5090 1대에서 200k benchmark를 먼저 실행한다. +2. 실제 samples/sec로 2025년 전체 학습 시간을 재계산한다. +3. 2025년 Kronos-small 전체 학습을 우선 수행한다. +4. 성과가 좋아질 때만 Kronos-base/H100/H200/B200으로 확대한다. + +주의: + +- 현재 코드에는 AMP/autocast/BF16/FP16/DDP 최적화가 충분히 적용되어 있지 않다. +- H100/H200/B200은 빠르지만, 현재 코드 상태에서는 비용 대비 효율이 RTX 5090보다 낮을 수 있다. + +참고 출처: + +- NVIDIA RTX 5090: https://www.nvidia.com/en-us/geforce/graphics-cards/50-series/rtx-5090/ +- RunPod Pricing: https://www.runpod.io/pricing +- Lambda GPU Cloud: https://lambda.ai/pricing +- DeployBase RTX 5090 Cloud: https://deploybase.ai/articles/rtx-5090-cloud +- gpus.io RTX 5090: https://gpus.io/en/gpus/rtx5090 +- Runcrate RTX 5090: https://www.runcrate.ai/pricing/gpu/rtx-5090 +- NeevCloud RTX 5090: https://www.neevcloud.com/nvidia-rtx-5090.php + +--- + +## 6. Kronos 모델별 판단 + +| 모델 | 파라미터 | context | 현재 판단 | +|---|---:|---:|---| +| Kronos-mini | 4.1M | 2048 | 빠른 실험/파이프라인 검증용 | +| Kronos-small | 24.7M | 512 | 현재 주력 기준 모델. 4080S/5090에서 현실적 | +| Kronos-base | 102.3M | 512 | small 개선 신호 확인 후 확대 검토 | +| Kronos-large | 499.2M | 512 | 공개/실행 가능성/비용 측면에서 현재 비권장 | + +2025년 기준 추정: + +| 모델 | 4080 Super | RTX 5090 | +|---|---:|---:| +| mini | 약 3.6일 | 약 1.6일 | +| small | 약 8.0일 | 약 3.7일 | +| base | 약 30.5일 | 약 13.9일 | +| large 추정 | 약 144.6일 | 약 65.7일 | + +--- + +## 7. 현재까지 master 커밋 전체 정리 + +현재 작업 시작 전 `master`는 `origin/master` 대비 43개 커밋 ahead 상태였다. 아래 표는 각 커밋의 의도를 작업 흐름별로 재분류한 것이다. + +| # | commit | 날짜 | 분류 | 커밋 의도 | +|---:|---|---|---|---| +| 1 | `3e8508d` | 2026-05-06 | 데이터/DB 이해와 변환 기반 | Kronos가 STOM tick 데이터를 누수 없이 학습할 수 있게 준비하다 | +| 2 | `54ba6f9` | 2026-05-06 | 데이터/DB 이해와 변환 기반 | STOM 예측 결과를 눈으로 검증할 수 있게 하다 | +| 3 | `afaa267` | 2026-05-06 | 데이터/DB 이해와 변환 기반 | STOM 파이프라인에서 불필요한 셸 실행 위험을 줄이다 | +| 4 | `ebc0a34` | 2026-05-06 | 데이터/DB 이해와 변환 기반 | STOM 파일럿 export 진행 상태를 재현 가능하게 남기다 | +| 5 | `a30b414` | 2026-05-06 | GPU 파일럿과 대시보드 검증 | RTX 4080 SUPER에서 STOM GPU 파일럿 학습 경로를 입증하다 | +| 6 | `3908ba9` | 2026-05-06 | GPU 파일럿과 대시보드 검증 | GPU 파일럿 checkpoint가 예측 산출물을 만들 수 있음을 기록하다 | +| 7 | `e9b4f3b` | 2026-05-06 | GPU 파일럿과 대시보드 검증 | STOM 대시보드가 실제 예측 CSV를 표시함을 검증하다 | +| 8 | `37c51cd` | 2026-05-06 | GPU 파일럿과 대시보드 검증 | STOM 학습 확대를 300개 테이블에서 먼저 검증하다 | +| 9 | `0d73b40` | 2026-05-06 | GPU 파일럿과 대시보드 검증 | STOM 학습 확대를 1000개 테이블까지 검증하다 | +| 10 | `e6d935b` | 2026-05-07 | GPU 파일럿과 대시보드 검증 | 전체 STOM universe를 bounded 방식으로 학습 가능하게 만들다 | +| 11 | `19e6842` | 2026-05-07 | 추천/점수화와 외부 연동 검토 | 낮은 raw 방향정확도를 보완할 score 추천층을 만들다 | +| 12 | `74266b5` | 2026-05-07 | 추천/점수화와 외부 연동 검토 | Adapter 연결 전 score 필터의 검증 근거를 분해하다 | +| 13 | `8b8cfde` | 2026-05-07 | 추천/점수화와 외부 연동 검토 | Kronos 추천 결과를 외부 프로그램이 읽을 수 있게 내보내다 | +| 14 | `1c3974e` | 2026-05-07 | 검토/문서화/파인튜닝 정합성 | Kronos 개발 과정을 검토 가능하게 정리하다 | +| 15 | `710afc9` | 2026-05-07 | 검토/문서화/파인튜닝 정합성 | STOM Kronos 파인튜닝 검증 근거를 분리하다 | +| 16 | `3c3017c` | 2026-05-07 | 검토/문서화/파인튜닝 정합성 | STOM 데이터를 Qlib 검증 흐름으로 보낼 수 있게 하다 | +| 17 | `158e904` | 2026-05-07 | 검토/문서화/파인튜닝 정합성 | Qlib 실제 실행 전 환경 게이트를 명확히 하다 | +| 18 | `9996847` | 2026-05-07 | 검토/문서화/파인튜닝 정합성 | Qlib 실제 변환 가능성을 증거로 고정하다 | +| 19 | `540e540` | 2026-05-08 | 검토/문서화/파인튜닝 정합성 | 1초봉 전체 재학습 전에 시간 기준 데이터를 고정하다 | +| 20 | `c8636ba` | 2026-05-08 | 검토/문서화/파인튜닝 정합성 | STOM 1초봉 전체 export 근거를 고정하다 | +| 21 | `bd5d5ca` | 2026-05-08 | 검토/문서화/파인튜닝 정합성 | STOM 1초봉 전체 데이터를 실제 파인튜닝 루프에 연결하다 | +| 22 | `94c2ab3` | 2026-05-08 | 평가/필터/확대 gate | STOM 1초봉 checkpoint를 실제 holdout 방향성 평가로 연결하다 | +| 23 | `ab07709` | 2026-05-08 | 평가/필터/확대 gate | STOM 1초봉 60초 모델의 walk-forward 필터 한계를 고정하다 | +| 24 | `0f76ea4` | 2026-05-08 | 평가/필터/확대 gate | STOM 1초봉 조건식 과최적화를 rolling 검증으로 분리하다 | +| 25 | `bbb0dbf` | 2026-05-09 | 추천/점수화와 외부 연동 검토 | 외부 trading-program 연동을 제외하고 STOM tick 학습 범위를 고정하다 | +| 26 | `60be1e3` | 2026-05-09 | 평가/필터/확대 gate | 전체 데이터 학습 확대를 단계형 실행 계획으로 고정하다 | +| 27 | `56c38bd` | 2026-05-09 | 평가/필터/확대 gate | pred60 대형 walk-forward가 학습 확대 조건을 충족하지 못함을 고정하다 | +| 28 | `9cef1eb` | 2026-05-09 | 평가/필터/확대 gate | 비용 민감도 gate로 학습 확대 결정을 자동화하다 | +| 29 | `2153f9f` | 2026-05-09 | 공식 Kronos 200k 학습 | Kronos 공식 tokenizer 학습 경로를 Windows 단일 GPU에 맞추다 | +| 30 | `9ef5207` | 2026-05-09 | 공식 Kronos 200k 학습 | STOM pred60 tokenizer 20k 실측으로 확대 시간을 고정하다 | +| 31 | `66dd204` | 2026-05-09 | 공식 Kronos 200k 학습 | STOM pred60 tokenizer 200k 학습을 공식 순서에 고정하다 | +| 32 | `6fc9bf2` | 2026-05-09 | 공식 Kronos 200k 학습 | STOM tokenizer 기반 predictor 200k 학습을 완료하다 | +| 33 | `79c5e6c` | 2026-05-09 | 공식 Kronos 200k 학습 | Official 200k 예측 그래프와 필터 검증 속도를 확보하다 | +| 34 | `60dc759` | 2026-05-09 | 평가/필터/확대 gate | Official 200k 성과를 비용 gate 기준으로 판정하다 | +| 35 | `eb3f9ba` | 2026-05-09 | 평가/필터/확대 gate | pred30 확대를 cost gate 이후로 미루다 | +| 36 | `3dbb149` | 2026-05-09 | 평가/필터/확대 gate | 1M 이후 확대를 목적별 실행안으로 분리하다 | +| 37 | `a488b0e` | 2026-05-09 | 기타 | Ralph 마무리 전 공식 학습 변경부를 정리하다 | +| 38 | `4f2bc02` | 2026-05-10 | 통계 대시보드와 GPU 대여 연구 | 통계 대시보드 고도화 계획을 autopilot 흐름으로 고정하다 | +| 39 | `480803e` | 2026-05-10 | 통계 대시보드와 GPU 대여 연구 | 예측 CSV의 종목별 통계 진단 API를 추가하다 | +| 40 | `3da9438` | 2026-05-10 | 통계 대시보드와 GPU 대여 연구 | 종목별 통계 진단을 STOM 대시보드 화면에 연결하다 | +| 41 | `a1b6ae7` | 2026-05-10 | 통계 대시보드와 GPU 대여 연구 | 통계 대시보드 변경을 리뷰하고 출력 안전성을 보강하다 | +| 42 | `df683a1` | 2026-05-10 | 통계 대시보드와 GPU 대여 연구 | 브라우저 검증에서 발견한 표시 품질 문제를 없애다 | +| 43 | `55650e6` | 2026-05-10 | 통계 대시보드와 GPU 대여 연구 | GPU 대여 기반 Kronos 확대 학습 판단 기준을 남기다 | + +--- + +## 8. 단계별 완료율 + +| 단계 | 내용 | 상태 | 완료율 | +|---:|---|---|---:| +| 1 | STOM DB 구조 파악과 OHLCV 추출 가능성 확인 | 완료 | 100% | +| 2 | tick/1초봉 학습 데이터셋 변환기 구현 | 완료 | 100% | +| 3 | 파일럿 export/GPU 학습/예측 CSV 생성 | 완료 | 100% | +| 4 | 웹 대시보드 실제값/예측값 시각화 | 완료 | 100% | +| 5 | 전체 테이블 bounded 학습 가능성 검증 | 완료 | 100% | +| 6 | score/ranking/조건식 보완층 검토 | 완료 | 100% | +| 7 | Qlib/Kronos 공식 파인튜닝 방향 검토 | 완료 | 100% | +| 8 | 공식 tokenizer→predictor 200k 학습 | 완료 | 100% | +| 9 | 종목별 통계 대시보드 고도화 | 완료 | 100% | +| 10 | 2025년/전체 학습량과 GPU 대여 비용 산정 | 완료 | 100% | +| 11 | 2025년 전체 Kronos-small 학습 | 남음 | 0% | +| 12 | 2025년 holdout 및 2026년 forward 검증 | 남음 | 0% | +| 13 | 성과 개선 시 Kronos-base 확대 실험 | 남음 | 0% | +| 14 | 전체 2022~2026 학습 및 실전 적용 판단 | 남음 | 0% | + +전체 연구/개발 관점 진행률: + +```text +██████████████░░░░░░ 약 70% +``` + +해석: + +- 파이프라인, 대시보드, 공식 200k 학습, 비용 산정은 상당히 진행됨. +- 그러나 사용자가 가장 보고 싶어 한 “전체 STOM tick 학습 후 실제 그래프/통계 성과 확인”은 아직 남아 있음. +- 따라서 다음 핵심은 **2025년 전체 학습**이다. + +--- + +## 9. 남은 핵심 작업 + +우선순위 순서: + +1. **RTX 4080S 또는 RTX 5090에서 2025년 Kronos-small 전체 학습 실행** + - 4080S 예상: 약 8~9일 + - RTX 5090 예상: 약 3~5일 +2. 학습 완료 checkpoint로 전체 종목 예측 CSV 생성 +3. `/stom` 대시보드에서 종목별 실제값/예측값 비교 +4. 전체 통계 확인 + - direction accuracy + - MAE/RMSE/MAPE + - pred_return bucket별 실제 return + - Top-K 추천 성과 + - 날짜별/종목별 분산 +5. 2026년 또는 최근 미사용 기간 forward test +6. 성과가 baseline/random/persistence보다 개선될 경우 Kronos-base 확대 +7. 개선이 없으면 feature/label/horizon/market regime 분리 재설계 + +--- + +## 10. 브랜치/커밋 정리 계획 기록 + +사용자는 현재 `master`에서 직접 누적된 작업을 새 브랜치에 이동하고, `master`에는 그 브랜치를 merge하는 방식으로 정리하라고 요청했다. + +승인된 정리 방식: + +1. 현재 `master` HEAD를 안전 백업 브랜치로 보존 +2. 현재 HEAD에서 작업 브랜치 `work/stom-kronos-conversation-summary` 생성 +3. 이 문서를 작업 브랜치에서 커밋 +4. `master`를 `origin/master`로 되돌림 +5. 작업 브랜치를 `master`로 `--no-ff` merge +6. 최종 `master`는 “원격 기준 + 작업 브랜치 merge commit” 형태가 됨 + +이 방식의 장점: + +- 기존 43개 커밋을 잃지 않음 +- 작업 이력을 하나의 feature branch로 묶어 볼 수 있음 +- `master` 첫 번째 부모 이력이 원격 기준에서 merge commit으로 정리됨 + +주의: + +- 원격 push는 수행하지 않음 +- `.omx/` runtime 파일은 커밋하지 않음 +- reset 전 backup/work branch가 현재 HEAD와 문서 커밋을 포함하는지 검증해야 함 + +--- + +## 11. 최종 판단 + +지금까지의 작업은 “STOM tick을 Kronos로 학습할 수 있는가?”라는 질문에는 상당 부분 답했다. + +- 데이터 변환 가능: 예 +- 여러 종목 통합 학습 가능: 예 +- 공식 tokenizer→predictor fine-tune 가능: 예 +- 4080S GPU 사용 가능: 예 +- 실제값/예측값 대시보드 확인 가능: 예 +- 현재 200k 모델만으로 실전 판단 가능: 아니오 +- 전체/2025년 학습을 통해 성과 개선을 검증할 필요: 매우 높음 + +따라서 다음 방향은 명확하다. + +> **2025년 전체 STOM tick 데이터로 Kronos-small을 공식 방식으로 학습하고, 대시보드에서 실제값/예측값/종목별 통계/Top-K 성과를 확인한다. 그 결과가 개선될 때만 Kronos-base 또는 전체 연도 학습으로 확대한다.** + +--- + +## 12. 2026-05-11 업데이트: 2025년 전체 학습 preflight 완료 + +다음 단계로 2025년 STOM tick 전체 학습 전 preflight를 완료했다. + +추가된 핵심 기능: + +- `finetune_csv/stom_tick_dataset.py`: `session_start`, `session_end` 기반 날짜/session 필터 추가 +- `finetune/qlib_stom_pipeline.py`: 2025년 전용 Qlib/Kronos export를 위한 `--session-start`, `--session-end` 인자 추가 +- `finetune/preflight_stom_2025_full.py`: DB read-only, CUDA, 디스크, 샘플 수, export/training 명령을 자동 점검 +- `docs/stom_2025_full_training_preflight.md`: preflight 결과와 다음 실행 명령 기록 + +실제 preflight 결과: + +- 상태: `ready_with_actions` +- blocker: 0개 +- DB: `_database/stock_tick_back.db`, 27.69GB, 2,427 tables, read-only/query_only 통과 +- CUDA: RTX 4080 SUPER, PyTorch 2.9.0+cu128, VRAM 15.99GB +- D: 여유 공간: 약 538.64GB +- 2025년 train samples: 18,771,531 +- 2025년 validation samples: 3,922,758 +- train+validation: 22,694,289 +- 4080S 예상 학습 시간: 약 192.81시간, 약 8.03일 + +현재 단계 판단: + +```text +전체 진행률: ████████████████░░░░ 80% +현재 단계: 2025년 전체 학습 preflight 완료 +다음 단계: 2025년 processed dataset export +남은 단계: 2025년 full training → 예측 CSV → 대시보드/통계 검증 → base 확대 여부 판단 +``` + +다음 권장 OMX 명령: + +```text +$ralph 2025년 STOM tick pred60 processed dataset export를 실행하고 export report, train/val/test pkl 생성 여부, session split, row/sample 수를 검증한 뒤 문서와 commit으로 남기세요. +``` + +--- + +## 13. 2026-05-11 업데이트: 2025년 processed dataset export 완료 + +2025년 STOM tick pred60 전체 학습을 위한 processed dataset export를 완료했다. + +핵심 결과: + +- export duration: 1,433.24초, 약 23분 53초 +- output: `finetune/qlib_exports/stom_1s_grid_pred60_2025/processed_datasets` +- `train_data.pkl`: 약 1.234GB +- `val_data.pkl`: 약 0.258GB +- `test_data.pkl`: 약 0.254GB +- export report: `finetune/qlib_exports/stom_1s_grid_pred60_2025/stom_qlib_export_report.json` +- split 기준: session chronological split +- train sessions: 168, 20250103~20250910 +- val sessions: 36, 20250911~20251106 +- test sessions: 36, 20251107~20251230 +- train samples: 18,806,883 +- val samples: 3,925,397 +- train+val samples: 22,732,280 + +현재 단계 판단: + +```text +전체 진행률: ██████████████████░░ 90% +현재 단계: 2025년 processed dataset export 완료 +다음 단계: 2025년 Kronos-small tokenizer→predictor full training +남은 단계: checkpoint 검증 → 예측 CSV 생성 → 대시보드 실제/예측 비교 → 성과 판단 +``` + +다음 권장 OMX 명령: + +```text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습을 checkpoint/resume와 절전 방지 조건을 확인한 뒤 실행하고, tokenizer/predictor 로그와 checkpoint 생성 여부를 주기적으로 점검하며 완료 후 문서와 commit으로 남기세요. +``` + +--- + +## 14. 2026-05-11 업데이트: 실시간 학습 모니터링 선행 구현 + +2025년 STOM tick pred60 Kronos-small 전체 학습은 RTX 4080 SUPER 기준 약 8~9일이 예상되므로, 본 학습을 바로 시작하기 전에 실시간 관측 기능을 먼저 추가했다. + +추가/변경 내용: + +- `finetune/run_stom_1s_finetune.py`: child process stdout을 실시간으로 로그 파일에 기록하고 progress JSON을 갱신하도록 변경 +- `finetune/training_progress.py`: Kronos 학습 로그 parser와 progress sidecar writer 추가 +- `webui/training_monitor.py`: `finetune/outputs` run 목록, status, log tail, GPU 상태 helper 추가 +- `webui/app.py`: `/training` 및 `/api/training/*` route 추가 +- `webui/templates/training_dashboard.html`: 학습 단계, 진행률, ETA, loss, GPU/전력, 로그 tail 표시 +- `docs/stom_training_monitor_dashboard.md`: 사용법과 본 학습 전 체크포인트 문서화 + +현재 단계 판단: + +```text +전체 진행률: ███████████████████░ 95% +현재 단계: ████████████████████ 100% 실시간 학습 모니터링 구현 완료 +남은 단계: █░░░░░░░░░░░░░░░░░░░ 5% 2025 full training → 평가/대시보드 검증 +``` + +다음 권장 OMX 명령: + +```text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습을 실행하기 전에 /training 대시보드가 실시간 progress/log/GPU 상태를 표시하는지 브라우저로 확인하고, 확인되면 2025 full training을 시작한 뒤 checkpoint와 progress를 주기적으로 점검하세요. +``` +--- + +## 15. 2026-05-11 업데이트: /training 서버·API·브라우저 검증 완료 + +실시간 학습 모니터 구현 후 실제 서버와 브라우저에서 검증했다. + +검증 결과: + +- 서버: `http://127.0.0.1:5070/training` +- API: `/api/training/runs`, `/status`, `/logs`, `/gpu` 모두 200 OK +- 브라우저: Playwright headless 검증 통과 +- 표시 확인: run 목록, 전체 진행률, tokenizer/predictor 단계, GPU, log tail +- console/page error: 0개 +- 추가 수정: dry-run progress가 50%처럼 보이지 않도록 0% 유지, dry-run log placeholder 생성 + +현재 단계 판단: + +```text +전체 진행률: ███████████████████░ 96% +현재 단계: ████████████████████ 100% /training 실사용 검증 완료 +남은 단계: █░░░░░░░░░░░░░░░░░░░ 4% 2025 full training → checkpoint → 예측/성과 검증 +``` + +다음 권장 OMX 명령: + +```text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습을 시작하고, /training 대시보드에서 tokenizer 단계의 progress/log/GPU 상태가 실제로 갱신되는지 1차 확인한 뒤 장기 학습 상태로 전환하세요. +``` +--- + +## 16. 2026-05-11 업데이트: 2025년 STOM full training 실제 시작 + +2025년 STOM tick pred60 Kronos-small 전체 학습을 실제로 시작했다. + +실행 상태: + +- run: `stom_1s_grid_pred60_2025_full_small` +- runner PID: 3944 +- tokenizer child PID: 70448 +- stage: tokenizer running +- train samples: 18,806,883 +- validation samples: 3,925,397 +- tokenizer train steps/epoch: 4,701,721 +- 확인된 로그: `Step 2000/4701721`, loss `-0.0299` +- `/training`: `http://127.0.0.1:5070/training`에서 running/progress/log/GPU 표시 확인 +- GPU: RTX 4080 SUPER, VRAM 약 3.1GB 사용, utilization 약 37~40% 관측 + +현재 단계 판단: + +```text +전체 진행률: ███████████████████░ 97% +현재 단계: ████████████████████ 100% full training 시작 및 초기 live 검증 완료 +남은 단계: █░░░░░░░░░░░░░░░░░░░ 3% 장기 학습 완료 → checkpoint → 예측/성과 검증 +``` + +다음 권장 OMX 명령: + +```text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습의 tokenizer 단계가 계속 정상 진행 중인지 progress/log/GPU를 재점검하고, checkpoint 생성 전까지 중간 상태를 문서와 commit으로 주기적으로 남기세요. +``` +--- + +## 17. 2026-05-11 업데이트: full training tokenizer 중간 진행 확인 + +실행 중인 2025년 STOM tick pred60 Kronos-small 전체 학습을 중간 점검했다. + +확인 결과: + +- runner PID: 3944 실행 중 +- tokenizer PID: 70448 실행 중 +- stage: tokenizer running +- 1차 샘플: step 28,000 / 4,701,721, loss -0.0274 +- 2차 샘플: step 30,000 / 4,701,721, loss -0.0316 +- 문서 검증 직전 추가 확인: step 31,000 / 4,701,721, loss -0.0306 +- tokenizer stage progress: 0.6381% +- overall progress: 0.3190% +- samples/sec: 약 72.63 +- GPU: RTX 4080 SUPER, utilization 약 35~37%, VRAM 약 3.1GB, 온도 약 44~46°C + +현재 단계 판단: + +```text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 중간 progress/log/GPU 확인 완료 +실제 학습 진행률: ░░░░░░░░░░░░░░░░░░░░ 0.3190% +``` + +다음 권장 OMX 명령: + +```text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 progress/log/GPU를 다시 점검하고, step 증가와 이상 여부를 문서화한 뒤 checkpoint 생성 전까지 중간 commit으로 남기세요. +``` +--- + +## 18. 2026-05-11 업데이트: full training tokenizer 두 번째 중간 진행 확인 + +실행 중인 2025년 STOM tick pred60 Kronos-small 전체 학습을 두 번째로 중간 점검했다. + +확인 결과: + +- runner PID: 3944 실행 중 +- tokenizer PID: 70448 실행 중 +- stage: tokenizer running +- 1차 샘플: step 132,000 / 4,701,721, loss -0.0251 +- 2차 샘플: step 134,000 / 4,701,721, loss -0.0308 +- 문서 검증 직전 추가 확인: step 140,000 / 4,701,721, loss -0.0339 +- tokenizer stage progress: 2.9776% +- overall progress: 1.4888% +- samples/sec: 약 76.81 +- GPU: RTX 4080 SUPER, 최신 관측 NVIDIA GeForce RTX 4080 SUPER, 38, 3004, 16376, [N/A], 43, 이전 샘플 기준 utilization 약 38~46%, VRAM 약 3.1~3.3GB, 온도 약 44~50°C + +현재 단계 판단: + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 두 번째 중간 progress/log/GPU 확인 완료 +실제 학습 진행률: ░░░░░░░░░░░░░░░░░░░░ 1.4888% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ +--- + +## 19. 2026-05-11 업데이트: full training tokenizer 세 번째 중간 진행 확인 + +실행 중인 2025년 STOM tick pred60 Kronos-small 전체 학습을 세 번째로 중간 점검했다. + +확인 결과: + +- stage: tokenizer running +- 이전 commit 후 최종 관측: step 142,000 / 4,701,721, overall 1.5101% +- 이번 1차 샘플: step 164,000 / 4,701,721, loss -0.0329 +- 이번 2차 샘플: step 165,000 / 4,701,721, loss -0.0339 +- 문서 작성 직전: step 167,000 / 4,701,721, loss -0.0269 +- tokenizer stage progress: 3.5519% +- overall progress: 1.7759% +- samples/sec: 약 76.5 +- checkpoint/model file count: 0 +- predictor 전환: 아직 전 +- GPU: NVIDIA GeForce RTX 4080 SUPER, 39, 2995, 16376, [N/A], 44 + +현재 단계 판단: + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 세 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: ░░░░░░░░░░░░░░░░░░░░ 1.7759% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ +--- + +## 20. 2026-05-11 업데이트: full training tokenizer 네 번째 중간 진행 확인 + +실행 중인 2025년 STOM tick pred60 Kronos-small 전체 학습을 네 번째로 중간 점검했다. + +확인 결과: + +- stage: tokenizer running +- 이전 commit 최종 관측: step 167,000 / 4,701,721, overall 1.7759% +- 이번 1차 샘플: step 460,000 / 4,701,721, loss -0.0303, overall 4.8918% +- 이번 2차 샘플: step 462,000 / 4,701,721, loss -0.0191, overall 4.9131% +- 문서 작성 직전: step 462,000 / 4,701,721, loss -0.0191 +- tokenizer stage progress: 9.8262% +- overall progress: 4.9131% +- samples/sec: 약 78.04 +- checkpoint/model/predictor file count: 0 +- predictor 전환: 아직 전 +- GPU: NVIDIA GeForce RTX 4080 SUPER, 38, 3018, 16376, [N/A], 42 + +현재 단계 판단: + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 네 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: ░░░░░░░░░░░░░░░░░░░░ 4.9131% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ +--- + +## 21. 2026-05-11 업데이트: full training tokenizer 다섯 번째 중간 진행 확인 + +실행 중인 2025년 STOM tick pred60 Kronos-small 전체 학습을 다섯 번째로 중간 점검했다. + +확인 결과: + +- stage: tokenizer running +- 이전 commit 최종 관측: step 463,000 / 4,701,721, overall 4.9237% +- 이번 1차 샘플: step 662,000 / 4,701,721, loss -0.0297, overall 7.0400% +- 이번 2차 샘플: step 663,000 / 4,701,721, loss -0.0298, overall 7.0506% +- 문서 작성 직전: step 664,000 / 4,701,721, loss -0.0324 +- tokenizer stage progress: 14.1225% +- overall progress: 7.0612% +- samples/sec: 약 73.42 +- checkpoint/model/predictor file count: 0 +- predictor 전환: 아직 전 +- GPU: NVIDIA GeForce RTX 4080 SUPER, 38, 3179, 16376, [N/A], 42 + +현재 단계 판단: + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 다섯 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: █░░░░░░░░░░░░░░░░░░░ 7.0612% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ +--- + +## 22. 2026-05-12 업데이트: full training tokenizer 여섯 번째 중간 진행 확인 + +실행 중인 2025년 STOM tick pred60 Kronos-small 전체 학습을 날짜 전환 후 여섯 번째로 중간 점검했다. + +확인 결과: + +- stage: tokenizer running +- 이전 commit 최종 관측: step 665,000 / 4,701,721, overall 7.0719% +- 이번 1차 샘플: step 682,000 / 4,701,721, loss -0.0332, overall 7.2527% +- 이번 2차 샘플: step 683,000 / 4,701,721, loss -0.0233, overall 7.2633% +- 문서 작성 직전: step 684,000 / 4,701,721, loss -0.0261 +- tokenizer stage progress: 14.5479% +- overall progress: 7.2739% +- samples/sec: 약 72.85 +- checkpoint/model/predictor file count: 0 +- predictor 전환: 아직 전 +- GPU: NVIDIA GeForce RTX 4080 SUPER, 38, 3076, 16376, [N/A], 42 + +현재 단계 판단: + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 여섯 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: █░░░░░░░░░░░░░░░░░░░ 7.2739% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ + +--- + +## 23. 2026-05-12 업데이트: full training tokenizer 일곱 번째 중간 진행 확인 + +실행 중인 2025년 STOM tick pred60 Kronos-small 전체 학습을 일곱 번째로 중간 점검했다. + +확인 결과: + +- stage: tokenizer running +- 이전 commit 최종 관측: step 685,000 / 4,701,721, overall 7.2846% +- 이번 1차 샘플: step 689,000 / 4,701,721, loss -0.0326, overall 7.3271% +- 이번 2차 샘플: step 690,000 / 4,701,721, loss -0.0299, overall 7.3377% +- 문서 작성 직전: step 691,000 / 4,701,721, loss -0.0238 +- tokenizer stage progress: 14.6967% +- overall progress: 7.3484% +- samples/sec: 약 72.62 +- checkpoint/model/predictor file count: 0 +- predictor 전환: 아직 전 +- GPU: NVIDIA GeForce RTX 4080 SUPER, 41, 3082, 16376, [N/A], 42 + +현재 단계 판단: + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +현재 점검 완료율: ████████████████████ 100% 일곱 번째 progress/log/GPU/checkpoint 확인 완료 +실제 학습 진행률: █░░░░░░░░░░░░░░░░░░░ 7.3484% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ + +--- + +## 24. 2026-05-12 업데이트: 학습 대시보드 자동 새로고침/전체 웹 통합 계획 + +Autopilot `ralplan` 단계로 다음 개발 목표를 확정했다. + +목표: + +- `/training`에서 학습 running 중 설정 가능한 간격으로 자동 새로고침 +- `/`, `/stom`에서도 현재 학습 요약과 `/training` 링크 제공 +- 학습 프로세스는 종료하지 않고 관측만 수행 +- 구현 후 테스트와 code-review까지 진행 + +현재 단계 판단: + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +Autopilot 계획 단계: ████████████████████ 100% +구현/검증/리뷰: ░░░░░░░░░░░░░░░░░░░░ 0% +~~~ + +--- + +## 25. 2026-05-12 업데이트: 학습 대시보드 자동 새로고침/전체 웹 통합 구현 + +Autopilot `ralph` 단계로 웹 통합 구현을 완료했다. + +구현 완료: + +- `/training`: 자동 새로고침 ON/OFF, 설정 가능한 초 단위 interval, localStorage 저장 +- `/`: 실시간 학습 상태 요약 카드와 `/training` 링크 +- `/stom`: 예측/성과 대시보드 상단 학습 상태 strip과 `/training` 링크 +- Flask: `refresh_interval` query parameter와 min/max clamp +- 테스트: `tests/test_training_monitor.py`, `tests/test_training_progress.py` 통과 + +검증: + +~~~text +python -m pytest tests/test_training_monitor.py tests/test_training_progress.py -q +8 passed +~~~ + +현재 단계 판단: + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +Autopilot 구현 단계: ████████████████████ 100% +검증 단계: ████████████████████ 100% +code-review 단계: ░░░░░░░░░░░░░░░░░░░░ 0% +~~~ + +--- + +## 26. 2026-05-12 업데이트: 학습 대시보드 자동 새로고침 코드 리뷰 완료 + +Autopilot `code-review` 단계에서 변경 diff를 검토했다. + +리뷰 결과: + +- Recommendation: APPROVE +- Architectural status: CLEAR +- Critical/High/Medium/Low: 0 + +리뷰 중 보완: + +- `/training` 자동 갱신 렌더링에서 log/API/GPU 값이 innerHTML에 들어가는 경로를 HTML escape 처리 +- badge CSS class는 안전한 문자만 사용하도록 제한 + +검증: + +~~~text +python -m pytest tests/test_training_monitor.py tests/test_training_progress.py -q +8 passed +~~~ + +현재 단계 판단: + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +Autopilot 계획 단계: ████████████████████ 100% +Autopilot 구현 단계: ████████████████████ 100% +Autopilot 리뷰 단계: ████████████████████ 100% +~~~ + +--- + +## 27. 2026-05-12 업데이트: STOM 전체 학습 여덟 번째 중간 진행 점검 + +`$ralph` 다음 단계로 STOM 2025 pred60 Kronos-small 전체 학습이 실제 진행 중인지 재검증했다. + +확인 결과: + +- 현재 stage: `tokenizer` +- status: `running` +- step: `920000 / 4701721` +- tokenizer 진행률: 19.5673% +- 전체 both-stage 진행률: 9.7837% +- 최신 로그: `[Rank 0, Epoch 1/1, Step 920000/4701721] LR 0.000186, Loss: -0.0307` +- 70초 재확인: `918000 -> 919000` step 증가 확인 +- GPU: `NVIDIA GeForce RTX 4080 SUPER`, util 약 42%, VRAM 약 3130 / 16376 MiB, temp 약 42C +- checkpoint/model weight: 아직 0개 +- predictor 전환: 아직 전 +- 대시보드: `/training`, `/`, `/stom` 자동 갱신 UI 응답 확인 + +현재 판단: + +~~~text +개발/대시보드 준비 [███████████████████░] 97.00% +tokenizer 학습 [████░░░░░░░░░░░░░░░░] 19.57% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 9.78% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0.00% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ + +--- + +## 28. 2026-05-12 업데이트: STOM 전체 학습 아홉 번째 중간 진행 점검 + +`$ralph` 다음 단계로 STOM 2025 pred60 Kronos-small 전체 학습 진행 상태를 다시 점검했다. + +확인 결과: + +- 현재 stage: `tokenizer` +- status: `running` +- step: `1,020,000 / 4,701,721` +- tokenizer 진행률: 21.6942% +- 전체 both-stage 진행률: 10.8471% +- 최신 로그: `[Rank 0, Epoch 1/1, Step 1020000/4701721] LR 0.000182, Loss: -0.0311` +- checkpoint 08 대비 증가량: `+98,000 step` +- 75초 재확인: `1,019,000 -> 1,020,000` step 증가 확인 +- GPU: `NVIDIA GeForce RTX 4080 SUPER`, util 약 40%, VRAM 약 3241 / 16376 MiB, temp 약 42C +- checkpoint/model weight: 아직 0개 +- predictor 전환: 아직 전 +- 대시보드: `/training`, `/`, `/stom` 자동 갱신 UI 응답 확인 + +현재 판단: + +~~~text +개발/대시보드 준비 [███████████████████░] 97.00% +tokenizer 학습 [████░░░░░░░░░░░░░░░░] 21.69% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 10.85% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0.00% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ + +--- + +## 29. 2026-05-12 업데이트: STOM 전체 학습 열 번째 중간 진행 점검 + +`$ralph` 다음 단계로 STOM 2025 pred60 Kronos-small 전체 학습 진행 상태를 다시 점검했다. + +확인 결과: + +- 현재 stage: `tokenizer` +- status: `running` +- step: `1,079,000 / 4,701,721` +- tokenizer 진행률: 22.9490% +- 전체 both-stage 진행률: 11.4745% +- 최신 로그: `[Rank 0, Epoch 1/1, Step 1079000/4701721] LR 0.000180, Loss: -0.0291` +- checkpoint 09 대비 증가량: `+59,000 step` +- 75초 재확인: `1,078,000 -> 1,079,000` step 증가 확인 +- GPU: `NVIDIA GeForce RTX 4080 SUPER`, util 약 35%, VRAM 약 3316 / 16376 MiB, temp 약 50C +- checkpoint/model weight: 아직 0개 +- predictor 전환: 아직 전 +- 대시보드: `/training`, `/`, `/stom` 자동 갱신 UI 응답 확인 + +현재 판단: + +~~~text +개발/대시보드 준비 [███████████████████░] 97.00% +tokenizer 학습 [█████░░░░░░░░░░░░░░░] 22.95% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 11.47% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0.00% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ + +--- + +## 30. 2026-05-12 업데이트: STOM 전체 학습 열한 번째 중간 진행 점검 + +`$ralph` 다음 단계로 STOM 2025 pred60 Kronos-small 전체 학습 진행 상태를 다시 점검했다. + +확인 결과: + +- 현재 stage: `tokenizer` +- status: `running` +- step: `1,136,000 / 4,701,721` +- tokenizer 진행률: 24.1614% +- 전체 both-stage 진행률: 12.0807% +- 최신 로그: `[Rank 0, Epoch 1/1, Step 1136000/4701721] LR 0.000177, Loss: -0.0311` +- checkpoint 10 대비 증가량: `+57,000 step` +- 75초 재확인: `1,134,000 -> 1,136,000` step 증가 확인 +- GPU: `NVIDIA GeForce RTX 4080 SUPER`, util 약 43%, VRAM 약 3294 / 16376 MiB, temp 약 50C +- checkpoint/model weight: 아직 0개 +- predictor 전환: 아직 전 +- 대시보드: `/training`, `/`, `/stom` 자동 갱신 UI 응답 확인 + +현재 판단: + +~~~text +개발/대시보드 준비 [███████████████████░] 97.00% +tokenizer 학습 [█████░░░░░░░░░░░░░░░] 24.16% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 12.08% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0.00% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 2026-05-12 기준 STOM tick pred60 Kronos-small 전체 학습 tokenizer 단계의 다음 중간 진행률을 다시 점검하고, checkpoint 생성 여부와 predictor 전환 여부를 확인한 뒤 문서와 commit으로 남기세요. +~~~ + +--- + +## 31. 2026-05-12 업데이트: 학습 유지 상태의 웹 대시보드 병렬 개선 계획 + +`$ralplan` 단계로 현재 STOM full training은 계속 유지하면서 웹 대시보드/프론트엔드 통합 개선을 병렬로 진행하기 위한 안전 계획을 수립했다. + +현재 live 기준선: + +- status: `running` +- stage: `tokenizer` +- step: `1,168,000 / 4,701,721` +- tokenizer 진행률: 24.8420% +- 전체 both-stage 진행률: 12.4210% +- checkpoint/predictor: 아직 전 + +채택한 방향: + +- running 학습, DB, CUDA/PyTorch, 현재 run output은 수정 금지 +- `/training`, `/`, `/stom` 웹/API는 읽기 전용으로 개선 +- checkpoint/predictor 전에는 예측 성과를 표시하지 않고 “학습 중/성과 대기”로 표시 +- 단계별 테스트 후 Lore commit + +진행률: + +~~~text +안전 계획 수립 [████████████████████] 100% +학습 모니터링 체계 [████████████████████] 100% +웹 기본 통합 [███████████████████░] 95% +프론트엔드 고도화 [████████░░░░░░░░░░░░] 40% +read-only artifacts API [░░░░░░░░░░░░░░░░░░░░] 0% +진행률 히스토리 시각화 [░░░░░░░░░░░░░░░░░░░░] 0% +학습 산출물 전체 진행률 [██░░░░░░░░░░░░░░░░░░] 12.42% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획에 따라 1단계만 구현하세요. 목표는 /training, /, /stom의 학습 상태 UI를 공통 기준으로 정리하고 checkpoint/predictor 전환 전 성과 오해를 막는 표시를 추가하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고, live HTTP 확인 후 Lore commit으로 남기세요. +~~~ + +--- + +## 32. 2026-05-12 업데이트: 학습 유지 웹 대시보드 개선 1단계 구현 + +`$ralph` 단계로 현재 STOM full training은 계속 유지하면서 `/training`, `/`, `/stom`의 학습 상태 UI를 공통 readiness 기준으로 정리했다. + +구현 완료: + +- `/api/training/status`에 공통 `readiness` 필드 추가 +- tokenizer 단계에서는 `성과 대기: tokenizer 학습 중`으로 표시 +- predictor 진행 중/완료 상태를 공통 정책으로 분리 +- `/training`: `예측 성과 준비 상태` 카드 추가 +- `/`: 상단 학습 상태 카드에 `성과 상태`와 안내 메시지 추가 +- `/stom`: 상단 strip에 `성과 상태`와 predictor 전 성과 대기 안내 추가 + +검증: + +~~~text +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py tests\test_training_progress.py -q +9 passed in 1.70s + +C:\Python\64\Python3119\python.exe -m compileall webui +통과 + +Live HTTP: +/training?refresh_interval=10: trainingReadinessCard 확인 +/?refresh_interval=10: trainingInlineReadiness 확인 +/stom?refresh_interval=10: stomTrainingReadiness 확인 +~~~ + +현재 live 학습 상태: + +~~~text +status: running +stage: tokenizer +step: 1,177,000 / 4,701,721 +tokenizer 진행률: 25.0334% +전체 both-stage 진행률: 12.5167% +readiness: 성과 대기: tokenizer 학습 중 +~~~ + +진행률: + +~~~text +1단계 공통 readiness UI/API [████████████████████] 100% +학습 모니터링 체계 [████████████████████] 100% +웹 기본 통합 [████████████████████] 100% +프론트엔드 고도화 [██████████░░░░░░░░░░] 50% +read-only artifacts API [░░░░░░░░░░░░░░░░░░░░] 0% +진행률 히스토리 시각화 [░░░░░░░░░░░░░░░░░░░░] 0% +학습 산출물 전체 진행률 [███░░░░░░░░░░░░░░░░░] 12.52% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획의 2단계를 구현하세요. 목표는 checkpoint/predictor/weight artifact 상태를 읽기 전용 API로 노출하고, /training과 /stom에서 checkpoint 생성 여부를 명확히 표시하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고 live HTTP 확인 후 Lore commit으로 남기세요. +~~~ + +--- + +## 33. 2026-05-12 업데이트: 학습 유지 웹 대시보드 개선 2단계 구현 + +`$ralph` 단계로 현재 STOM full training은 계속 유지하면서 checkpoint/predictor/model weight artifact 상태를 읽기 전용 API와 웹 UI로 표시했다. + +구현 완료: + +- `/api/training/artifacts` 추가 +- tokenizer/predictor checkpoint directory와 weight 파일 수 확인 +- `/training`: `Checkpoint / Predictor Artifact` 카드 추가 +- `/stom`: 상단 학습 strip 아래 artifact 상태 문구 추가 +- checkpoint/predictor 전에는 `checkpoint 대기`로 명확히 표시 + +검증: + +~~~text +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py tests\test_training_progress.py -q +10 passed in 2.17s + +C:\Python\64\Python3119\python.exe -m compileall webui +통과 + +Live HTTP: +/api/training/status: readiness 확인 +/api/training/artifacts: checkpoint 대기, weight 0개 확인 +/training?refresh_interval=10: trainingArtifactCard 확인 +/stom?refresh_interval=10: stomTrainingArtifacts 확인 +~~~ + +현재 live 학습 상태: + +~~~text +status: running +stage: tokenizer +step: 1,198,000 / 4,701,721 +tokenizer 진행률: 25.4800% +전체 both-stage 진행률: 12.7400% +artifact: checkpoint 대기, model weight 0개, checkpoint 0개 +~~~ + +진행률: + +~~~text +1단계 공통 readiness UI/API [████████████████████] 100% +2단계 read-only artifacts API [████████████████████] 100% +학습 모니터링 체계 [████████████████████] 100% +웹 기본 통합 [████████████████████] 100% +프론트엔드 고도화 [████████████░░░░░░░░] 60% +진행률 히스토리 시각화 [░░░░░░░░░░░░░░░░░░░░] 0% +학습 산출물 전체 진행률 [███░░░░░░░░░░░░░░░░░] 12.74% +~~~ + +다음 권장 OMX 명령: + +~~~text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획의 3단계를 구현하세요. 목표는 tokenizer progress JSON/log를 읽기 전용으로 사용해 /training에 간단한 진행률 히스토리 카드 또는 표를 추가하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고 live HTTP 확인 후 Lore commit으로 남기세요. +~~~ + +--- + +## 34. 2026-05-12 업데이트: 학습 진행률 히스토리 대시보드 구현 + +현재 STOM full training을 중단하지 않고 `$ralph` 단계로 `/training` 대시보드에 최근 학습 step 히스토리를 추가했습니다. + +구현 완료: + +- `/api/training/history` 추가 +- tokenizer stdout log에서 step, total_steps, LR, loss, stage %, overall % 파싱 +- `/training`에 진행률 히스토리 카드와 테이블 추가 +- 자동 새로고침 시 history도 함께 갱신 +- predictor처럼 아직 stdout log가 없는 명시 단계는 tokenizer log로 fallback하지 않도록 안전장치 추가 + +검증: + +```text +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py tests\test_training_progress.py -q +12 passed in 2.07s + +C:\Python\64\Python3119\python.exe -m compileall webui +통과 + +Live HTTP: +/api/training/history?limit=5: point_count 5, latest_point.step 1,320,000 +/api/training/history?stage=predictor&limit=5: point_count 0, no stdout log found +/training?refresh_interval=10: historyRows/historySummary/refreshIntervalSeconds 확인 +``` + +현재 live 학습 상태: + +```text +status: running +stage: tokenizer +step: 1,320,000 / 4,701,721 +tokenizer 진행률: 28.0748% +전체 both-stage 진행률: 14.0374% +readiness: 성과 대기(tokenizer 학습 중, predictor 미시작) +``` + +진행률: + +```text +1단계 공통 readiness UI/API [██████████] 100% +2단계 read-only artifacts API [██████████] 100% +3단계 진행률 히스토리 표시 [██████████] 100% +4단계 GPU/ETA/속도 카드 개선 [░░░░░░░░░░] 0% +5단계 /stom 성과 준비 상태 연결 [░░░░░░░░░░] 0% +6단계 최종 code-review 문서 [░░░░░░░░░░] 0% +프론트엔드 고도화 전체 [██████░░░░] 60% +학습 산출물 전체 진행률 [█░░░░░░░░░] 14.04% +``` + +다음 권장 OMX 명령: + +```text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획의 4단계를 구현하세요. 목표는 /training의 GPU/ETA/속도 카드를 개선해 samples/sec, ETA, GPU util/VRAM/온도/전력 상태를 더 명확히 표시하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고 live HTTP 확인 후 Lore commit으로 남기세요. +``` + +--- + +## 35. 2026-05-12 업데이트: GPU/ETA/속도 카드 개선 + +현재 STOM full training을 중단하지 않고 `$ralph` 단계로 `/training` 대시보드의 GPU/ETA/속도 표시를 개선했습니다. + +구현 완료: + +- `/training`에 `학습 속도 / ETA` 카드 추가 +- 현재 단계, samples/sec, 경과 시간, 남은 시간, 예상 완료 시각, 최근 loss 표시 +- `/api/training/gpu` summary 확장 +- 평균 GPU Util, 총 VRAM 사용률, 총 전력 제한, power draw 실측 가능 여부 표시 +- power draw 실측이 불가한 환경에서는 `실측 불가`로 명확히 표시 +- GPU 테이블에 VRAM %, Power, Limit, Temp 컬럼 확장 + +검증: + +```text +C:\Python\64\Python3119\python.exe -m pytest tests\test_training_monitor.py tests\test_training_progress.py -q +13 passed in 1.94s + +C:\Python\64\Python3119\python.exe -m compileall webui +통과 + +Live HTTP: +/api/training/gpu: available true, avg util 39.0%, VRAM 19.61%, power limit 320W +/api/training/status: tokenizer running, step 1,329,000, overall 14.1331% +/training?refresh_interval=10: runtimeSummaryCard/gpuSummaryMetrics 확인 +``` + +현재 live 학습 상태: + +```text +status: running +stage: tokenizer +step: 1,329,000 / 4,701,721 +tokenizer 진행률: 28.2662% +전체 both-stage 진행률: 14.1331% +samples/sec: 약 65.37 +readiness: 성과 대기(tokenizer 학습 중, predictor 미시작) +``` + +진행률: + +```text +1단계 공통 readiness UI/API [██████████] 100% +2단계 read-only artifacts API [██████████] 100% +3단계 진행률 히스토리 표시 [██████████] 100% +4단계 GPU/ETA/속도 카드 개선 [██████████] 100% +5단계 /stom 성과 준비 상태 연결 [░░░░░░░░░░] 0% +6단계 최종 code-review 문서 [░░░░░░░░░░] 0% +프론트엔드 고도화 전체 [████████░░] 80% +학습 산출물 전체 진행률 [█░░░░░░░░░] 14.13% +``` + +다음 권장 OMX 명령: + +```text +$ralph 현재 실행 중인 STOM full training은 중단하지 말고, docs/stom_dashboard_safe_parallel_improvement_plan.md 계획의 5단계를 구현하세요. 목표는 /stom 성과 대시보드 상단에도 predictor 미시작/성과 대기/학습 진행 상태와 artifact readiness를 더 명확히 연결해, predictor 완료 전에는 예측 성과를 오해하지 않도록 하는 것입니다. tests/test_training_monitor.py와 tests/test_training_progress.py를 통과시키고 live HTTP 확인 후 Lore commit으로 남기세요. +``` + + +--- + +## 36. 2026-05-12 ????: /stom ?? ?? ?? ?? + +?? STOM full training? ???? ?? `$ralph` ??? `/stom` ?? ???? ??? readiness/artifact gate? ??????. + +?? ??: + +- `/stom` ??? `?? ?? Gate`, `Predictor`, `Checkpoint`, `?? / ETA` ?? ?? +- predictor/checkpoint ?? ??? `LOCKED: predictor/checkpoint ?? ? ?? ?? ??` ?? +- `/api/training/status`? readiness ??? `/stom` ?? ??? ?? ?? +- `/api/training/artifacts`? tokenizer/predictor checkpoint ??? predictor started ?? ?? +- `/stom` route template marker ?? ??? ?? + +??: + +```text +C:\Python4\Python3119\python.exe -m pytest tests est_training_monitor.py tests est_training_progress.py -q +13 passed in 1.99s + +C:\Python4\Python3119\python.exe -m compileall webui +?? + +Live HTTP: +/stom?refresh_interval=10: stomPerformanceGate/stomPredictorGate/stomCheckpointGate/stomRuntimeGate ?? +/api/training/status: tokenizer running, step 1,383,000, overall 14.7074%, performance_ready false +/api/training/artifacts: checkpoint 0?, predictor_started false +``` + +?? live ?? ??: + +```text +status: running +stage: tokenizer +step: 1,383,000 / 4,701,721 +tokenizer ???: 29.4148% +?? both-stage ???: 14.7074% +samples/sec: ? 65.05 +readiness: ?? ??(tokenizer ?? ?, predictor ???) +``` + +???: + +```text +1?? ?? readiness UI/API [??????????] 100% +2?? read-only artifacts API [??????????] 100% +3?? ??? ???? ?? [??????????] 100% +4?? GPU/ETA/?? ?? ?? [??????????] 100% +5?? /stom ?? ?? ?? ?? [??????????] 100% +6?? ?? code-review ?? [??????????] 0% +????? ??? ?? [??????????] 90% +?? ??? ?? ??? [??????????] 14.71% +``` + +?? ?? OMX ??: + +```text +$code-review ???? STOM full training? ???? ?? ??? ???? read-only ?? ??? ?????. ??? docs/stom_dashboard_safe_parallel_improvement_plan.md? 1~5??? ?? commit???. ??? predictor ?? ? ?? ?? ??, /training ? /stom readiness/artifact/ETA/GPU ?? ???, ??? ???, ?? ??? ???? ?? review ??? Lore commit? ??? ????. +``` + + +--- + +## 37. 2026-05-12 ????: ?? code-review ?? ?? + +`$code-review` ??? STOM full training ???? read-only ?? 1~5??? ?? ??????. + +??: + +```text +Recommendation: APPROVE +Architectural Status: CLEAR +CRITICAL: 0 +HIGH: 0 +MEDIUM: 0 +LOW: 3? ?? ?? +``` + +??: + +```text +C:\Python4\Python3119\python.exe -m pytest tests est_training_monitor.py tests est_training_progress.py -q +13 passed in 2.22s + +C:\Python4\Python3119\python.exe -m compileall webui +?? + +git diff --check aed6967..HEAD +?? + +Live HTTP: +/training?refresh_interval=10: readiness/artifact/runtime/gpu/history marker ?? +/stom?refresh_interval=10: performance/predictor/checkpoint/runtime gate ?? +/?refresh_interval=10: inline training panel ?? +/api/training/status/history/artifacts/gpu ?? +``` + +?? live ?? ??: + +```text +status: running +stage: tokenizer +step: 1,389,000 / 4,701,721 +tokenizer ???: 29.5424% +?? both-stage ???: 14.7712% +samples/sec: ? 65.00 +readiness: ?? ??(tokenizer ?? ?, predictor ???) +checkpoint/model weight: 0? +``` + +???: + +```text +1?? ?? readiness UI/API [??????????] 100% +2?? read-only artifacts API [??????????] 100% +3?? ??? ???? ?? [??????????] 100% +4?? GPU/ETA/?? ?? ?? [??????????] 100% +5?? /stom ?? ?? ?? ?? [??????????] 100% +6?? ?? code-review ?? [??????????] 100% +????? ??? ?? [??????????] 100% +?? ??? ?? ??? [??????????] 14.77% +``` + +?? ?? OMX ??: + +```text +$ralph ?? ?? ?? STOM full training? ???? ??, ????? /api/training/status, /api/training/history, /api/training/artifacts? ??? tokenizer ??? predictor ?? ??? ???????. predictor? ???? /training ? /stom gate? training ??? ????? live HTTP? ????, predictor checkpoint? ??? ??? ??? vs ??? ?? ???? ?? ??? ?????. +``` + + +--- + +## 38. 2026-05-12 ????: ?? ?? ???? ????? + +?? code-review ?? ?? STOM full training? read-only? ?? ??????. + +?? live ??: + +```text +run: stom_1s_grid_pred60_2025_full_small +status: running +stage: tokenizer +step: 1,442,000 / 4,701,721 +tokenizer ???: 30.6696% +?? both-stage ???: 15.3348% +samples/sec: ? 65.07 +ETA: ? 200,393? +predictor_started: false +checkpoint/model weight: 0? +``` + +??: + +```text +?? ?? ??: ?? ?? +??: predictor ???, checkpoint/model weight ?? +?? ? ?: ???? ?? ? predictor ?? ?? ?? +``` + +?? ?? OMX ??: + +```text +$ralph ?? ?? ?? STOM full training? ???? ??, 1~2?? ? ?? tokenizer ???? ?? ?? ? /api/training/status, /api/training/history, /api/training/artifacts? ?? ?????. predictor? ?? ???? ???? ???? ?????? ???, predictor? ?????? /training ? /stom gate? training ??? ????? live HTTP? ??? ? commit?? ?????. +``` diff --git a/docs/stom_kronos_development_review_report.md b/docs/stom_kronos_development_review_report.md new file mode 100644 index 000000000..154b3e4ce --- /dev/null +++ b/docs/stom_kronos_development_review_report.md @@ -0,0 +1,542 @@ +# STOM Kronos 개발/학습/예측 검토 보고서 + +작성 기준: 2026-05-07 KST +대상 branch: `master` +비교 기준: `origin/master..HEAD` +검토 목적: 영어 commit 메시지 한글화, 초기 계획 대비 개발 품질 검토, 현재 단계 확인, 추가 개발 여부 확인, Kronos/STOM 학습·예측 과정 검증 + +--- + +## 1. 결론 요약 + +### 1-1. 전체 결론 + +현재 fork repository의 STOM/Kronos 작업은 **초기 목표였던 1~5단계**를 넘어서, 실제 STOM 1tick DB 분석 → OHLCV 변환 → CUDA/GPU 학습 → Kronos 예측 CSV 생성 → 웹 대시보드 검증 → score/ranking → 조건식 backtest → 외부 score export까지 진행되어 있다. + +검토 결과: + +```text +학습 인프라: 완료, 100% +실전 활용 확장: 3 / 5 완료, 60% +현재 단계: 활용 3단계 완료 — STOM ?? ??용 CSV/JSON score export +다음 단계: 외부 추천 프로그램 import 예시 또는 실전 조건식 강화 +``` + +가장 중요한 판단: + +```text +1. STOM tick DB를 Kronos 입력 형식으로 변환하는 구조는 코드와 테스트 기준으로 타당하다. +2. 실제 RTX 4080 SUPER + CUDA 환경에서 fine-tuning checkpoint가 생성되었다. +3. 생성 checkpoint를 사용한 Kronos 예측 CSV도 생성되었고 dashboard/API로 확인되었다. +4. 다만 예측 성능은 아직 “자동 매매 신호로 충분하다”고 볼 수준은 아니다. +5. 특히 all-table bounded 모델의 direction_accuracy는 0.40으로 낮다. +6. 따라서 현재 결과는 실전 매수/매도 신호가 아니라 “조건식·score·추가 필터와 결합할 후보 생성기”로 보는 것이 정확하다. +``` + +--- + +## 2. Commit 메시지 한글화 검토 + +### 2-1. 안전 조치 + +영어 commit 메시지를 한글로 바꾸는 작업은 git history rewrite에 해당하므로, 작업 전 복구용 backup branch를 만들었다. + +```text +backup branch: backup/pre-korean-commit-messages-20260507-103421 +rewrite 범위: origin/master..HEAD +remote push: 수행하지 않음 +DB/대용량 산출물 수정: 없음 +``` + +### 2-2. 한글화 후 commit 목록 + +```text +3e8508d Kronos가 STOM tick 데이터를 누수 없이 학습할 수 있게 준비하다 +54ba6f9 STOM 예측 결과를 눈으로 검증할 수 있게 하다 +afaa267 STOM 파이프라인에서 불필요한 셸 실행 위험을 줄이다 +ebc0a34 STOM 파일럿 export 진행 상태를 재현 가능하게 남기다 +a30b414 RTX 4080 SUPER에서 STOM GPU 파일럿 학습 경로를 입증하다 +3908ba9 GPU 파일럿 checkpoint가 예측 산출물을 만들 수 있음을 기록하다 +e9b4f3b STOM 대시보드가 실제 예측 CSV를 표시함을 검증하다 +37c51cd STOM 학습 확대를 300개 테이블에서 먼저 검증하다 +0d73b40 STOM 학습 확대를 1000개 테이블까지 검증하다 +e6d935b 전체 STOM universe를 bounded 방식으로 학습 가능하게 만들다 +19e6842 낮은 raw 방향정확도를 보완할 score 추천층을 만들다 +74266b5 Score 연결 전 score 필터의 검증 근거를 분해하다 +8b8cfde Kronos 추천 결과를 외부 프로그램이 읽을 수 있게 내보내다 +``` + +### 2-3. old hash → new hash 대응 + +| 기존 영어 commit | 변경 후 한글 commit | 의미 | +|---|---|---| +| `f2a02cd` | `3e8508d` | STOM OHLCV fine-tuning 준비 | +| `4ef5987` | `54ba6f9` | 예측 검증 dashboard 추가 | +| `052a10e` | `afaa267` | 미사용 shell helper 제거 | +| `b833d48` | `ebc0a34` | 파일럿 export 진행 ledger | +| `4a6ba7d` | `a30b414` | GPU 파일럿 학습 검증 | +| `22595a4` | `3908ba9` | pilot checkpoint 예측 산출물 | +| `8771b58` | `e9b4f3b` | dashboard 실제 표시 검증 | +| `4fbfb15` | `37c51cd` | 300개 table 확대 | +| `aa0ee7e` | `0d73b40` | 1,000개 table 확대 | +| `850adf2` | `e6d935b` | 전체 table bounded 학습 | +| `6dc8dc3` | `19e6842` | score/ranking 추천층 | +| `c2f14ef` | `74266b5` | score filter backtest | +| `2bc6390` | `8b8cfde` | CSV/JSON score export | + +주의: + +```text +commit hash는 history rewrite 때문에 바뀌었다. +원래 hash로 돌아가야 하면 backup/pre-korean-commit-messages-20260507-103421 branch를 사용하면 된다. +``` + +--- + +## 3. 초기 계획 대비 개발 진행 검토 + +### 3-1. 초기 계획 + +초기 계획은 다음 5개 축이었다. + +```text +1. STOM DB 구조 분석 +2. STOM tick DB를 Kronos OHLCV 학습 데이터로 변환 +3. GPU/CUDA 환경 확인 및 학습 준비 +4. 학습 모델로 예측 CSV 생성 +5. 웹 대시보드에서 실제값과 예측값 시각화 +``` + +### 3-2. 실제 완료된 내용 + +초기 계획 대비 실제 결과는 다음과 같다. + +| 구분 | 초기 계획 | 현재 결과 | 판단 | +|---|---|---|---| +| DB 분석 | STOM tick DB 구조 파악 | 2,427개 table scan, 2,425개 stock table 학습 가능 확인 | 완료 | +| 데이터 변환 | OHLCV CSV 생성 | `close_only`, grouped symbol/session, lookback 300, predict 60 변환 구현 | 완료 | +| GPU 학습 | CUDA 환경 학습 | torch `2.9.0+cu128`, RTX 4080 SUPER, cuda:0 학습 완료 | 완료 | +| 예측 | checkpoint 기반 예측 | pilot/300/1000/all bounded prediction CSV 및 metrics 생성 | 완료 | +| 시각화 | dashboard actual vs predicted | `/stom`, prediction API, headless screenshot 검증 | 완료 | +| 추가 활용 | 초기 계획 밖 | score/ranking, backtest report, score export | 후속 요청에 의해 진행됨 | + +판단: + +```text +초기 1~5 목표는 충족했다. +초기 계획보다 더 진행된 부분은 있지만, 이전 대화에서 사용자가 score화, 조건식 보완, 외부 프로그램 적용 가능성을 계속 요청했기 때문에 무단 개발로 보기는 어렵다. +``` + +--- + +## 4. 현재 페이지/단계 위치 + +현재 dashboard page는 다음 URL이다. + +```text +http://localhost:7070/stom +또는 포트 충돌 시 +http://localhost:7071/stom +``` + +현재 문서상 단계: + +```text +학습 인프라: 9C / 9 완료, 100% +실전 활용 확장: 3 / 5 완료, 60% +현재 완료 단계: 활용 3단계 — CSV/JSON score export +``` + +현재 `/stom` dashboard 구성: + +```text +Page 1. 목표/상태 +Page 2~5 진행 계획 +예측 파일 선택 +Kronos Score Export +Page 5. 실제값 vs 예측값 chart +Top-K 예상등락률 검증 +Kronos Score Top-K 추천 +조건식 필터 백테스트 리포트 +``` + +현재 다음 단계 후보: + +```text +1. D:\Chanil_Park\Project\Programming\?? ?? ?? 쪽에서 score export CSV/JSON을 읽는 import 예시 구현 +2. 거래대금/거래량/변동성/가격대 조건식 추가 +3. 수수료/슬리피지 반영 +4. 실시간/종가 추천 workflow 연결 +``` + +--- + +## 5. 추가로 시키지 않은 개발이 있었는지 검토 + +### 5-1. 명확히 요청된 범위 + +다음은 사용자가 명시적으로 요청했거나 이전 요청의 자연스러운 후속이다. + +```text +- STOM 1tick DB 분석 +- OHLCV만 사용한 학습 가능성 검토 +- 파인튜닝 가능 여부 및 직접 학습 준비 +- GPU/CUDA 세팅 및 RTX 4080 SUPER 사용 검증 +- 실제값/예측값 dashboard 시각화 +- 여러 종목 학습 및 매일 다른 종목 universe 대응 +- score 또는 예상 등락률 점수화 +- 조건식/다른 프로그램 보완 가능성 +- ?? ?? ?? 같은 외부 프로그램 적용 예시 검토 +``` + +### 5-2. 초기 계획보다 확장된 개발 + +초기 1~5 이후 다음 기능이 추가되었다. + +```text +- Kronos score/ranking 추천 API +- 조건식 필터 backtest report +- score band / 종목 / 시간대 segment 성과 +- CSV/JSON score export +``` + +판단: + +```text +초기 계획 밖 기능이지만, 이후 사용자가 score화, 조건식 보완, 다른 프로그램 적용을 반복 요청했기 때문에 “시키지 않은 개발”이라기보다 “후속 요구사항 반영”으로 보는 것이 맞다. +``` + +### 5-3. 하지 않은 것 + +```text +- ?? ?? ?? repository에 직접 파일을 쓰지 않았다. +- live trading/order 실행을 하지 않았다. +- 원본 STOM DB를 수정하지 않았다. +- 대용량 CSV/checkpoint/prediction output을 commit하지 않았다. +- fee/slippage를 반영한 실전 수익률 확정 판단을 하지 않았다. +``` + +--- + +## 6. Kronos 시스템 이해 검토 + +### 6-1. 이 repository에서 사용한 Kronos 이해 + +이 작업에서 Kronos는 다음 방식으로 이해하고 사용되었다. + +```text +Kronos는 OHLCV + time feature sequence를 입력으로 받아 미래 close path를 예측하는 time-series model이다. +STOM tick DB는 그대로 모델에 넣지 않고, symbol/session별로 시간 순서를 유지한 OHLCV sequence로 변환했다. +lookback_window=300, predict_window=60 구조를 사용했다. +학습은 NeoQuasar/Kronos-small predictor와 NeoQuasar/Kronos-Tokenizer-base tokenizer를 기반으로 했다. +``` + +구현상 핵심 파일: + +```text +finetune_csv/stom_tick_dataset.py +finetune_csv/prepare_stom_1tick.py +finetune_csv/configs/config_stom_1tick*.yaml +finetune_csv/train_sequential.py +finetune_csv/finetune_base_model.py +finetune_csv/stom_prediction_eval.py +webui/stom_dashboard.py +``` + +### 6-2. STOM tick DB → Kronos OHLCV 변환 이해 + +STOM DB는 종목별 table 구조다. 전체 scan 근거는 다음과 같다. + +```text +DB: _database\stock_tick_back.db +DB size: 29,727,162,368 bytes +전체 table: 2,427개 +학습 가능 stock table: 2,425개 +비학습 table: moneytop, stockinfo +전체 stock rows: 122,522,300 +전체 symbol/session group: 73,968 +lookback 300 + predict 60 기준 eligible group: 73,650 +예상 training window: 95,946,764 +``` + +중요한 선택: + +```text +price_mode=close_only +``` + +이유: + +```text +STOM tick table에는 종가 column이 없는 경우가 있고 현재가를 close로 사용한다. +초/틱 단위에서 DB의 시가/고가/저가 의미가 확실하지 않으면, open/high/low/close를 모두 현재가 기반으로 맞추는 close_only가 더 보수적이다. +``` + +판단: + +```text +Kronos가 요구하는 OHLCV 입력 형식에 맞추기 위한 변환은 적절하다. +다만 이것은 “진짜 일봉/분봉 OHLC”가 아니라 “STOM tick 현재가 sequence를 Kronos OHLCV 형태로 맞춘 close-only representation”이다. +따라서 모델 성능 해석 시 이 한계를 반드시 고려해야 한다. +``` + +--- + +## 7. STOM tick DB 학습이 정확히 되었는지 검토 + +### 7-1. CUDA/GPU 환경 + +검증 evidence: + +```text +torch version: 2.9.0+cu128 +cuda_available: true +cuda_version: 12.8 +GPU: NVIDIA GeForce RTX 4080 SUPER +VRAM: 15.99 GB +``` + +판단: + +```text +이 워크스테이션에서 GPU 학습이 실제로 가능한 상태로 세팅되었다. +``` + +### 7-2. 학습 scale-up 결과 + +| 단계 | 데이터 | device | validation loss | 학습 시간 | checkpoint | +|---|---:|---|---:|---:|---| +| GPU pilot | 100-table pilot | cuda:0 | 2.4311 | 1.40분 | 생성됨 | +| 300-table | 300개 table | cuda:0 | 2.3258 | 9.32분 | 생성됨 | +| 1,000-table | 1,000개 table | cuda:0 | 2.3030 | 35.22분 | 생성됨 | +| all-table bounded | 전체 universe bounded | cuda:0 | 2.3983 | 약 55.95분 | 생성됨 | + +checkpoint 위치 예: + +```text +finetune_csv/finetuned/stom_1tick_all_lookback300_pred60/basemodel/best_model/model.safetensors +``` + +판단: + +```text +학습 프로세스 자체는 정상 완료되었다. +validation loss와 checkpoint 저장 근거가 있고, RTX 4080 SUPER에서 cuda:0으로 실행되었다. +``` + +### 7-3. 전체 DB 학습 방식의 한계 + +전체 원본 CSV는 다음 규모였다. + +```text +full export rows: 122,345,828 +full CSV size: 약 9GB +``` + +직접 학습 문제: + +```text +9GB CSV를 pandas read_csv로 직접 학습하면 7시간 이상 loading 단계에서 정체되었고 GPU 사용률이 0%였다. +``` + +대응: + +```text +각 symbol/session group을 연속 420 row로 제한한 bounded export 사용 +bounded rows: 30,902,629 +bounded groups: 73,582 +max_rows_per_group: 420 +``` + +판단: + +```text +전체 universe의 종목/세션 coverage는 유지했지만, 각 group 전체 history를 모두 학습한 것은 아니다. +현재 all-table 학습은 “전체 종목/세션을 포함한 bounded 대표 구간 학습”이라고 표현하는 것이 정확하다. +``` + +--- + +## 8. 학습 모델을 잘 사용해 예측했는지 검토 + +### 8-1. Kronos mode 예측 사용 여부 + +`stom_prediction_eval.py`는 baseline mode와 kronos mode를 구분한다. 실제 학습 모델 예측은 다음처럼 수행되었다. + +```powershell +python finetune_csv\stom_prediction_eval.py ` + --data finetune_csv\data\stom_1tick_kline_all_bounded.csv ` + --output webui\stom_predictions\kronos_all_predictions.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 300 ` + --stride 120 ` + --mode kronos ` + --model-path finetune_csv\finetuned\stom_1tick_all_lookback300_pred60\basemodel\best_model ` + --tokenizer-path NeoQuasar/Kronos-Tokenizer-base ` + --device cuda:0 +``` + +판단: + +```text +학습된 checkpoint를 지정해서 mode=kronos로 예측했으므로, baseline이 아니라 실제 Kronos model path를 사용한 예측이다. +``` + +### 8-2. 예측 metrics + +| 예측 파일 | mode | windows | rows | MAPE | 방향정확도 | 평균 예측등락률 | 평균 실제등락률 | +|---|---|---:|---:|---:|---:|---:|---:| +| pilot_predictions.csv | baseline | 5 | 300 | 0.7366 | 0.20 | 0.0000 | 0.4960 | +| kronos_gpu_pilot_predictions.csv | kronos | 20 | 1,200 | 0.8289 | 0.50 | -0.2807 | 0.1523 | +| kronos_300_predictions.csv | kronos | 100 | 6,000 | 0.6687 | 0.58 | -0.1967 | 0.0202 | +| kronos_1000_predictions.csv | kronos | 200 | 12,000 | 0.5583 | 0.49 | -0.1879 | -0.0481 | +| kronos_all_predictions.csv | kronos | 300 | 18,000 | 0.2720 | 0.40 | -0.0751 | 0.0094 | + +해석: + +```text +MAPE는 all-table bounded에서 0.2720으로 가장 낮다. +그러나 direction_accuracy는 0.40으로 낮아, 방향성 예측을 그대로 매매 신호로 쓰기 어렵다. +MAE/RMSE는 종목 가격대 차이가 섞여 있어 scale 비교에 주의해야 한다. +``` + +### 8-3. Score/ranking으로 보완한 결과 + +raw prediction을 그대로 쓰지 않고, 다음 live 사용 가능 field만 score에 사용했다. + +```text +pred_return_window +prediction_consistency +pred_range_pct +``` + +실제값 기반 field는 diagnostic으로만 분리했다. + +```text +actual_return_window +direction_hit_window +realized_mape +``` + +Top-K/조건식 evidence: + +```text +Top-10 recommendation hit_rate: 0.50 +buy_candidate_score60: count 61, profit_factor 1.2632 +score65_consistency80: count 17, profit_factor 1.4872 +stable_positive_filter: count 7, hit/win 57.14% +``` + +주의: + +```text +profit_factor는 수수료/슬리피지를 반영하지 않은 diagnostic이다. +표본 수가 작은 조건식은 과최적화 위험이 있다. +실전 적용 전 walk-forward 검증이 필요하다. +``` + +--- + +## 9. “잘 예측하는가?”에 대한 최종 판단 + +### 9-1. 긍정 근거 + +```text +1. 실제 fine-tuned checkpoint를 사용한 kronos mode 예측이 생성되었다. +2. dashboard/API에서 prediction CSV가 정상 표시된다. +3. MAPE 기준으로 all-table bounded 모델은 0.2720까지 낮아졌다. +4. score/ranking과 조건식 filter를 적용하면 raw 방향정확도보다 나은 후보군을 찾을 수 있는 신호가 있다. +``` + +### 9-2. 부정/한계 근거 + +```text +1. all-table bounded direction_accuracy는 0.40으로 낮다. +2. 1 epoch bounded 학습이므로 충분한 일반화 검증이라고 보기는 어렵다. +3. all-table은 전체 history 전부가 아니라 group당 420 row 제한 대표 구간이다. +4. 수수료/슬리피지/체결 가능성/거래대금 조건이 아직 반영되지 않았다. +5. test 기간이 walk-forward/live simulation 형태로 충분히 분리되어 있지 않다. +``` + +### 9-3. 결론 + +```text +“학습과 예측 파이프라인이 정상 작동한다”는 판단은 가능하다. +“이 모델이 단독으로 실전 매매에 충분히 잘 예측한다”는 판단은 아직 불가능하다. +현재 가장 정확한 표현은 “Kronos fine-tuned model을 이용한 후보 생성 + score/조건식 보완 단계까지 도달했다”이다. +``` + +--- + +## 10. 코드 구조 검토 + +### 10-1. 잘 된 점 + +```text +- 원본 DB read-only 원칙을 지켰다. +- symbol/session grouping으로 종목/일자 간 leakage를 줄였다. +- lookback/predict window를 명시적으로 관리한다. +- 대용량 산출물을 .gitignore로 제외했다. +- CUDA 환경 검증과 실제 training log를 남겼다. +- dashboard route가 helper import 실패 시 전체 app을 죽이지 않도록 처리했다. +- score는 미래 실제값을 쓰지 않고 prediction field만 사용한다. +- diagnostic 실제값은 명확히 분리했다. +``` + +### 10-2. 개선 필요 + +```text +- GroupedKlineDataset이 pandas eager read 구조라 9GB full CSV 직접 학습이 어렵다. +- all-table bounded는 group 앞쪽 420 row 중심이므로 장중 전체 패턴 학습에는 제한이 있다. +- 현재 score threshold는 경험적이고, walk-forward optimization이 아니다. +- 수수료/슬리피지/거래대금/호가/체결량 조건이 없다. +- 현재 dashboard는 검증용이며 운영용 권한/보안/로그 관리가 부족하다. +``` + +--- + +## 11. 검증 명령 및 결과 + +이번 검토 기준으로 사용한 주요 검증 명령: + +```powershell +git log --reverse --oneline origin/master..HEAD +python -m compileall -q finetune_csv webui tests docs +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py tests/test_cli_import_paths.py -q +python -m pip check +``` + +예상/기록된 최신 회귀 결과: + +```text +17 passed, 1 warning +No broken requirements found. +``` + +--- + +## 12. 최종 권고 + +다음 단계는 둘 중 하나를 먼저 하는 것이 좋다. + +### 권고 A: 외부 추천 프로그램 import 연결 + +```text +D:\Chanil_Park\Project\Programming\?? ?? ?? 프로그램에서 +/api/stom/recommendation-export 또는 CSV/JSON export 파일을 읽어 +기존 종목 추천 점수에 Kronos score를 보조 점수로 반영한다. +``` + +### 권고 B: 예측 신뢰도 강화 + +```text +거래대금, 거래량, 변동성, 가격대, 시간대, 수수료, 슬리피지를 조건식에 넣고 +walk-forward 방식으로 threshold를 검증한다. +``` + +가장 중요한 운영 원칙: + +```text +Kronos score는 지금 단계에서 “자동 매수 신호”가 아니라 “후보 종목을 줄이는 보조 점수”로만 사용해야 한다. +``` \ No newline at end of file diff --git a/docs/stom_kronos_experience_report_2026-05-22.md b/docs/stom_kronos_experience_report_2026-05-22.md new file mode 100644 index 000000000..a83a94816 --- /dev/null +++ b/docs/stom_kronos_experience_report_2026-05-22.md @@ -0,0 +1,442 @@ +# STOM 1초봉 Kronos 파인튜닝 · 평가 · 대시보드 경험치 보고서 + +작성일: 2026-05-22 KST +대상 저장소: `D:\Chanil_Park\Project\Programming\Kronos` + +## 1. 한 줄 결론 + +STOM 2025년 1초봉 전체 데이터를 Kronos fine-tuning 파이프라인에 연결하고, tokenizer/predictor 학습과 웹 대시보드 시각화까지 구축했다. 파이프라인은 정상 동작하지만, 현재 fine-tuned model은 실전 매매 신호로 바로 쓰기에는 부족하다. 지금까지의 실험에서는 **300초 horizon이 가장 유망**하지만 아직 rolling cost gate를 통과하지 못했다. + +## 2. 이번 작업의 목표 + +처음 목표는 단순 예측 데모가 아니라 다음까지 확인하는 것이었다. + +1. STOM tick DB를 Kronos fine-tuning 데이터로 사용할 수 있는지 확인 +2. 2025년 STOM 1초봉 데이터를 Qlib/Kronos 형태로 변환 +3. tokenizer와 predictor를 실제로 학습 +4. 학습 상태를 웹 대시보드에서 실시간 확인 +5. 학습된 checkpoint로 실제값과 예측값을 비교 +6. 방향 적중률, 비용 반영 수익률, 조건식/필터 성능을 검증 +7. 다음 학습 방향을 잃지 않도록 문서와 커밋으로 관리 + +## 3. 최종 상태 요약 + +| 영역 | 상태 | 핵심 결과 | +| --- | --- | --- | +| STOM DB 분석 | 완료 | 2025년 1초봉 정규화 학습셋 생성 가능 확인 | +| Qlib/Kronos 변환 | 완료 | `finetune/qlib_exports/stom_1s_grid_pred60_2025` 생성 | +| tokenizer 학습 | 완료 | checkpoint 생성 완료 | +| predictor 학습 | 완료 | best validation loss `2.3711` | +| 학습 모니터 대시보드 | 완료 | 진행률, 손실, GPU/CPU, 로그, ETA 표시 | +| 예측 진단 대시보드 | 완료 | 실제값/예측값, 산점도, Top-K, horizon 비교 표시 | +| 60초 평가 | 완료 | 방향 적중률 44.79%, random 44.93% | +| 조건식/비용 검증 | 완료 | 손실은 감소했지만 rolling gate 실패 | +| 30/60/120/300초 비교 | 완료 | 300초가 가장 유망, rolling net -0.005% | +| 실전 사용 판단 | 보류 | 비용/rolling 안정성 미통과 | + +## 4. 사용 데이터와 학습 범위 + +### 4.1 원본 데이터 + +| 항목 | 값 | +| --- | --- | +| DB | `_database\stock_tick_back.db` | +| 데이터 형태 | STOM tick DB의 종목별 테이블 | +| 사용 구간 | 2025-01-03 ~ 2025-12-30 | +| 장중 시간 | 09:00:00 ~ 09:30:00 | +| 정규화 주기 | 1초봉 | +| 기본 feature | open, high, low, close, volume, amount | +| 가격 모드 | `close_only`; close가 없으면 현재가를 close로 사용 | + +### 4.2 Qlib export 설정 + +근거 파일: + +- `finetune/qlib_exports/stom_1s_grid_pred60_2025/stom_qlib_export_report.json` + +| 항목 | 값 | +| --- | ---: | +| exported group count | 18,750 | +| exported row count | 33,360,325 | +| train groups | 13,256 | +| validation groups | 2,764 | +| test groups | 2,730 | +| train rows | 23,579,043 | +| validation rows | 4,920,437 | +| test rows | 4,860,845 | +| train sessions | 168 | +| validation sessions | 36 | +| test sessions | 36 | +| lookback window | 300초 | +| 기본 predict window | 60초 | + +## 5. 학습 결과 + +### 5.1 predictor summary + +근거 파일: + +- `finetune/outputs/stom_1s_grid_pred60_2025_full_small/finetune_predictor/summary.json` + +| 항목 | 값 | +| --- | --- | +| run | `stom_1s_grid_pred60_2025_full_small` | +| predictor 시작 | `2026-05-20T22-28-57` | +| world size | 1 | +| best validation loss | `2.3711213504856223` | +| best checkpoint | `finetune/outputs/stom_1s_grid_pred60_2025_full_small/finetune_predictor/checkpoints/best_model` | +| predictor model size | 약 98.98 MB | +| tokenizer checkpoint | `finetune/outputs/stom_1s_grid_pred60_2025_full_small/finetune_tokenizer/checkpoints/latest_train_model` | +| tokenizer model size | 약 15.84 MB | + +### 5.2 학습 중 얻은 운영 노하우 + +1. 학습은 장시간 실행되므로 절전/재부팅/터미널 종료와 무관하게 프로세스 상태를 별도로 확인해야 한다. +2. 99%대에서 멈춘 것처럼 보여도 checkpoint 저장/validation 단계일 수 있다. +3. GPU 사용률 100%가 항상 목표는 아니다. small model, batch, dataloader, validation, checkpoint I/O 단계에서는 GPU가 낮게 보일 수 있다. +4. 학습 상태는 cmd 로그만으로는 불안하므로 웹 대시보드에 진행률, loss, rolling 평균, GPU/CPU, 로그 tail을 분리해서 보여주는 것이 효과적이었다. +5. 학습 완료 여부는 진행률보다 checkpoint와 summary 파일, best model 존재 여부로 확인하는 것이 안전하다. + +## 6. 60초 기본 평가 결과 + +근거 파일: + +- `webui/stom_predictions/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_comparison.json` +- `webui/qlib_backtests/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos.qlib_topk5.json` + +평가 조건: + +| 항목 | 값 | +| --- | --- | +| split | test | +| 기간 | 2025-11-07 ~ 2025-12-30 | +| session | 36 | +| as-of | session당 최대 3개 | +| symbols | as-of당 최대 50개 | +| windows | 681 | +| rows | 40,860 | +| 비용 | 수수료 15bp + 슬리피지 10bp = 25bp | + +| 지표 | Kronos | Persistence | Random | +| --- | ---: | ---: | ---: | +| direction accuracy | 44.79% | 12.04% | 44.93% | +| MAPE | 0.8730 | 0.3852 | 0.3906 | +| Top-K hit rate | 46.67% | 12.41% | 43.52% | +| Top-K 평균 실제 등락률 | 0.0459% | 0.0711% | 0.0758% | + +비용 반영 Top-K: + +| 항목 | 값 | +| --- | ---: | +| period count | 108 | +| trade count | 540 | +| avg gross return | 0.0459% | +| avg net return | -0.2041% | +| cumulative return | -19.8646% | +| max drawdown | -20.4773% | + +### 6.1 60초 평가 해석 + +- Kronos 60초 방향 적중률은 44.79%로 50% 미만이다. +- random baseline 44.93%와 거의 같아 방향성 우위가 없다. +- MAPE는 persistence/random보다 높아 가격 경로 예측도 불리하다. +- 비용 25bp를 반영하면 Top-K 수익률은 명확히 음수다. +- 따라서 60초 모델은 “실전 신호”가 아니라 “연구/시각화/조건식 보완 대상”으로 봐야 한다. + +## 7. 조건식 / 비용 필터 실험 + +근거 파일: + +- `webui/qlib_backtests/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos_cost25.filter_search.json` +- `webui/qlib_backtests/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos_cost25.rolling_filter_validation.json` +- `webui/qlib_backtests/stom_1s_pred60_2025_full_small_walkforward36x3x50_eval_kronos_cost25.cost_gate.json` + +### 7.1 60초 최적 필터 + +| 항목 | 기본 Top-K | 최적 필터 | +| --- | ---: | ---: | +| 필터 | 없음 | `ret>=0.05`, `cons>=0.5`, `amount q>=0.75` | +| period | 108 | 56 | +| trade 수 | 540 | 87 | +| coverage | 100.00% | 51.85% | +| 평균 gross return | 0.0459% | 0.1590% | +| 평균 net return, 25bp | -0.2041% | -0.0168% | +| direction hit rate | 46.67% | 47.13% | +| 누적수익 | -19.8646% | -1.1639% | + +### 7.2 rolling validation 결과 + +| 항목 | 값 | +| --- | ---: | +| fold 수 | 7 | +| total test trades | 66 | +| avg train net return | 0.0831% | +| avg test net return | -0.2043% | +| avg test baseline net return | -0.1726% | +| test improvement | -0.0317% | +| weighted test direction hit | 39.39% | +| positive test fold rate | 28.57% | +| overfit gap | 0.2874% | + +결론: + +- in-sample 최적 필터는 손실을 크게 줄였다. +- 그러나 rolling test에서 다시 무너졌다. +- 즉, “과거 구간에 맞춘 조건식”이 미래 구간에서도 반복될 정도로 안정적이지 않았다. +- `decision = hold_expand_200k`가 타당하다. + +## 8. Horizon 30/60/120/300초 비교 + +근거 파일: + +- `webui/qlib_backtests/stom_1s_2025_full_small_horizon_comparison.json` +- 대시보드 API: `GET /api/stom/horizon-comparison` + +공통 조건: + +| 항목 | 값 | +| --- | --- | +| 동일 checkpoint | `stom_1s_grid_pred60_2025_full_small` | +| lookback | 300초 | +| test 기간 | 2025-11-07 ~ 2025-12-30 | +| as-of | session당 최대 3개 | +| symbols | as-of당 최대 50개 | +| 비용 | 25bp | + +| horizon | Kronos 방향 적중률 | random 방향 적중률 | random 대비 | Top-K net | 최적 필터 net | rolling net | rolling 방향 | gate | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| 30초 | 39.21% | 38.77% | +0.44%p | -0.2522% | -0.0465% | -0.2398% | 32.08% | 실패 | +| 60초 | 44.79% | 44.93% | -0.15%p | -0.2041% | -0.0168% | -0.2043% | 39.39% | 실패 | +| 120초 | 45.67% | 42.73% | +2.94%p | -0.1735% | -0.0335% | -0.2903% | 49.35% | 실패 | +| 300초 | 49.19% | 46.26% | +2.94%p | -0.1145% | +0.0922% | -0.0052% | 44.29% | 실패 | + +### 8.1 horizon 비교 해석 + +1. 30초는 너무 짧아 노이즈가 크다. +2. 60초는 기존 기본 horizon이지만 random 대비 edge가 없다. +3. 120초는 방향성 edge가 생기지만 rolling net이 나쁘다. +4. 300초가 가장 유망하다. +5. 300초도 아직 gate는 실패했지만, rolling net이 -0.0052%로 손익분기점에 가장 가깝다. + +## 9. 웹 대시보드에 반영된 것 + +위치: + +- `http://127.0.0.1:5070/training` +- 좌측 메뉴: `예측 진단` + +### 9.1 학습 모니터 + +학습 중/완료 상태를 확인하기 위해 다음을 구현했다. + +1. 전체 진행률과 단계별 진행률 +2. tokenizer/predictor 단계 구분 +3. loss, rolling average, 표준편차 +4. learning rate, step, samples/sec +5. GPU 사용률, VRAM, GPU 온도 +6. CPU 사용률과 CPU 온도 표시 영역 +7. 로그 tail +8. 한국 시간 기준 표시 +9. 완료 예상 시각과 경과 시간 + +### 9.2 예측 진단 + +예측 결과를 사용자가 이해하기 쉽게 보기 위해 다음을 구현했다. + +1. model verdict 카드 +2. direction accuracy, MAPE, windows/symbols/sessions KPI +3. 실제 종가 vs Kronos 예측 종가 차트 +4. 예측 등락률 vs 실제 등락률 산점도 +5. Kronos 점수 상위 후보 테이블 +6. 조건식/Top-K 필터별 성과 +7. horizon 30/60/120/300초 비교표와 차트 +8. 백테스트/필터 리포트 산출물 조회 + +## 10. 주요 커밋 흐름 + +| 커밋 | 의미 | +| --- | --- | +| `dafc7c5` | horizon 30/60/120/300초 비교와 대시보드 반영 | +| `88eefb1` | 조건식/비용/rolling gate 결과 문서화 | +| `d43e7ca` | 완료 모델 평가 결과를 예측 진단 대시보드에 연결 | +| `5e7763a` | predictor 완료 상태를 성과 확인 가능 상태로 표시 | +| `2d36fd7` | CPU 상태를 GPU 트렌드와 함께 표시 | +| `84e418a` | 학습 상태 정상 범위와 CPU 기준 연결 | +| `0549ac5` | 학습 진행률 중복 제거와 단계 바 정리 | +| `38c3c21` | 학습 대시보드 차트의 실시간 데이터 누락 개선 | +| `1ed31b5` | tokenizer 검증 정체 후 predictor 학습으로 복구 | +| `cc5a7e1` | 중복 원형 그래프 정리 | +| `748246c` | 전체 진행률을 단계별 구간으로 표시 | +| `290ad12` | 학습 대시보드 한글 깨짐 복구 | + +## 11. 파인튜닝이 잘 안 된 것처럼 보인 이유 + +현재 결과는 “파이프라인 실패”가 아니라 “실전 신호력이 부족”한 상태다. + +### 11.1 학습 목표와 매매 목표 불일치 + +Kronos는 미래 가격 경로 예측을 학습한다. 그러나 실제 목표는 다음이다. + +1. 상승할 종목 선별 +2. 방향 적중 +3. 거래비용 초과 수익 +4. rolling validation에서 반복되는 안정성 + +가격 경로 예측 loss가 낮아지는 것과 실전 매매 수익이 양수가 되는 것은 같은 목표가 아니다. + +### 11.2 1초봉 초단기 데이터의 노이즈 + +30초/60초 예측은 다음의 영향을 크게 받는다. + +- 순간 체결 +- 호가 공백 +- 단기 수급 +- 유동성 차이 +- 거래대금 급변 +- 장초반 이벤트성 움직임 + +OHLCV만으로 이 모든 신호를 안정적으로 잡기는 어렵다. + +### 11.3 비용 25bp의 압박 + +Top-K gross return이 0.05~0.14% 수준이면, 비용 0.25%를 이기기 어렵다. 따라서 예측 방향을 조금 맞혀도 순수익은 음수가 된다. + +### 11.4 60초용 모델로 300초를 평가한 한계 + +300초가 가장 유망했지만, 현재 checkpoint는 기본적으로 60초 dataset/run에서 나온 모델이다. 300초 전용 objective로 학습한 결과는 아직 아니다. + +### 11.5 조건식 과최적화 + +필터 탐색은 in-sample에서 좋아졌지만 rolling validation에서 통과하지 못했다. 이는 “과거에 맞춘 필터”가 미래에도 안정적으로 반복되지 않았다는 의미다. + +## 12. 추가 변수를 넣는 방법에 대한 정리 + +추가 변수를 사용한다고 해서 반드시 모델 구조를 바로 바꿔야 하는 것은 아니다. + +| 방법 | 모델 변경 | 추천도 | 설명 | +| --- | --- | --- | --- | +| 예측 후 필터/점수화에 사용 | 불필요 | 높음 | 가장 안전. Kronos 예측 + 거래대금/변동성/체결강도 조건 | +| OHLCV/amount 전처리에 반영 | 거의 불필요 | 높음 | 정규화, 파생값, 스케일링으로 간접 반영 | +| 별도 classifier/regressor 추가 | 일부 필요 | 중간 | Kronos embedding/score와 추가 feature를 결합 | +| Kronos 입력 차원 확장 | 필요 | 낮음, 장기 | tokenizer/model config/inference까지 수정 필요 | + +현재 추천은 다음 순서다. + +1. 추가 변수는 먼저 **예측 후 조건식/점수화**에 사용한다. +2. 300초 전용 predictor를 따로 학습한다. +3. 성과가 보이면 방향 분류/수익률 회귀 head를 추가한다. +4. 마지막으로 필요할 때만 Kronos 입력 차원 확장을 검토한다. + +## 13. 현재 가장 중요한 결론 + +1. STOM 1초봉 → Kronos fine-tuning → 예측 → 대시보드까지 end-to-end 파이프라인은 구축됐다. +2. 현재 모델은 학습이 완료됐고 정상적으로 예측한다. +3. 그러나 30/60/120초는 비용과 random baseline을 충분히 이기지 못했다. +4. 300초가 가장 유망하다. +5. 300초도 아직 rolling cost gate는 통과하지 못했다. +6. 다음 실험은 “전체 확장 학습”보다 **300초 전용 학습 + 목적함수 개선 + 고확신 필터**가 먼저다. + +## 14. 다음 권장 작업 + +### 14.1 바로 할 작업 + +```text +300초 horizon 전용 predictor를 학습하고, 기존 pred60 checkpoint 기반 300초 평가와 비교한다. +``` + +### 14.2 동시에 개선할 것 + +1. 방향 분류 지표를 학습/검증에 포함 +2. 예상 등락률 회귀 지표 추가 +3. 비용 초과 수익 여부를 별도 평가 지표로 추가 +4. 거래대금, 변동성, 장초반 위치, 체결강도 유사 변수 기반 필터 강화 +5. rolling validation을 gate로 유지 + +### 14.3 성공 기준 + +| 기준 | 목표 | +| --- | --- | +| direction accuracy | 50% 이상 | +| random 대비 edge | +3%p 이상 | +| rolling net return | 0% 이상 | +| positive fold rate | 50% 이상 | +| cost gate | 통과 | +| dashboard | horizon/조건식/실제-예측 비교 표시 | + +## 15. 재현용 주요 명령 + +### 15.1 horizon 평가 + +```powershell +C:\Python\64\Python3119\python.exe finetune\evaluate_stom_1s_checkpoint.py ` + --dataset-path finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --model-path finetune\outputs\stom_1s_grid_pred60_2025_full_small\finetune_predictor\checkpoints\best_model ` + --tokenizer-path finetune\outputs\stom_1s_grid_pred60_2025_full_small\finetune_tokenizer\checkpoints\latest_train_model ` + --output-dir webui\stom_predictions ` + --prefix stom_1s_pred300_2025_full_small_walkforward36x3x50_eval ` + --lookback-window 300 ` + --predict-window 300 ` + --max-symbols 50 ` + --max-asofs 3 ` + --max-sessions 36 ` + --stride 300 ` + --batch-size 8 ` + --top-k 5 ` + --device cuda:0 +``` + +### 15.2 비용 반영 Top-K + +```powershell +C:\Python\64\Python3119\python.exe finetune\qlib_stom_pipeline.py score-backtest ` + --prediction-csv webui\stom_predictions\stom_1s_pred300_2025_full_small_walkforward36x3x50_eval_kronos.csv ` + --output-dir webui\qlib_backtests ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 +``` + +### 15.3 조건식 / rolling validation / cost gate + +```powershell +$pred = 'webui\stom_predictions\stom_1s_pred300_2025_full_small_walkforward36x3x50_eval_kronos.csv' +$prefix = 'stom_1s_pred300_2025_full_small_walkforward36x3x50_eval_kronos_cost25' + +C:\Python\64\Python3119\python.exe finetune\search_stom_1s_filters.py ` + --prediction-csv $pred ` + --output-dir webui\qlib_backtests ` + --prefix $prefix ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 ` + --min-trades 10 ` + --min-periods 3 ` + --min-coverage 0.5 ` + --rolling-validate ` + --rolling-train-periods 30 ` + --rolling-test-periods 10 ` + --rolling-step-periods 10 +``` + +### 15.4 대시보드 검증 + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_dashboard_helpers.py tests\test_training_monitor.py -q +cd webui\v2_src +npm run build +``` + +웹 확인: + +```text +http://127.0.0.1:5070/training +``` + +## 16. 남은 위험 + +1. 현재 결과는 `max-asofs=3`, `max-symbols=50`, `max-sessions=36` 평가다. 완전 exhaustive 평가는 아직 아니다. +2. 300초는 pred60 checkpoint로 평가한 것이므로, 300초 전용 학습 결과와 다를 수 있다. +3. 비용 25bp 가정이 실제 체결 환경과 다르면 결론이 달라질 수 있다. +4. 추가 feature를 모델 입력에 직접 넣는 것은 공식 Kronos 호환성과 pretrained weight 활용성에 영향을 줄 수 있다. +5. 대용량 런타임 산출물은 git에 포함하지 않았으므로 재현 시 로컬 산출물 경로가 필요하다. + diff --git a/docs/stom_kronos_finetuning_correctness_review.md b/docs/stom_kronos_finetuning_correctness_review.md new file mode 100644 index 000000000..f49a9019e --- /dev/null +++ b/docs/stom_kronos_finetuning_correctness_review.md @@ -0,0 +1,289 @@ +# STOM Kronos 파인튜닝 올바름 집중 검토 보고서 + +작성일: 2026-05-07 +검토 대상: STOM 1tick SQLite DB → grouped OHLCV CSV → Kronos predictor fine-tuning → 예측 CSV/웹 대시보드 산출물 + +## 1. 결론 요약 + +| 판단 항목 | 결론 | 근거 | +| --- | --- | --- | +| DB를 학습 가능한 OHLCV 형태로 변환했는가 | **가능 / 대체로 올바름** | `_database\stock_tick_back.db`에서 2,427개 테이블 중 2,425개 호환, 73,650개 eligible session 확인. `price_mode=close_only` 기준으로 현재가/종가를 OHLC에 매핑. | +| 여러 종목/여러 일자 세션을 학습했는가 | **예** | 그룹 키가 `symbol, session`이며, window가 종목/일자 경계를 넘지 않도록 `GroupedKlineDataset`이 구성됨. | +| GPU 파인튜닝이 실제 완료되었는가 | **예** | CUDA `NVIDIA GeForce RTX 4080 SUPER`, PyTorch `2.9.0+cu128`, 최종 all-table bounded run이 `cuda:0`에서 37,500 step 완료 후 checkpoint 저장. | +| 데이터 누수 위험은 통제되었는가 | **대체로 통제됨** | 학습/검증 split은 group 단위이며, 정규화 기준은 config에서 `normalize_using: lookback`으로 설정되어 미래 구간을 정규화 통계에 쓰지 않음. | +| 최종 모델을 실전 매매에 바로 신뢰해도 되는가 | **아직 아님** | all-table bounded 모델 예측의 방향정확도는 0.40으로 낮음. MAPE는 좋아졌지만 방향성/수익성 검증은 별도 walk-forward + 비용 반영 backtest가 필요. | + +**최종 판단:** 파인튜닝 절차 자체는 올바른 방향으로 구현·실행되었습니다. 다만 현재 산출물은 “학습 성공 모델”이지 “실전 종목 추천에 바로 투입 가능한 검증 완료 모델”은 아닙니다. 특히 최종 all-table bounded 모델은 가격 오차(MAPE)는 개선됐지만 방향정확도는 낮아, 점수화/조건식/백테스트 보완 없이는 매매 신호로 단독 사용하면 안 됩니다. + +## 2. 검토한 핵심 근거 + +### 2.1 DB 스캔/학습 가능성 + +확인 파일: `.omx/specs/stom-ohlcv-finetune-research/all_tables_inspect_close_only.json` + +- DB: `_database\stock_tick_back.db` +- DB 크기: 29,727,162,368 bytes +- 전체 테이블: 2,427개 +- 호환 테이블: 2,425개 +- 비호환 테이블: `moneytop`, `stockinfo` +- 최소 필요 row 수: `lookback 300 + predict 60 + 1 = 361` +- eligible session: 73,650개 +- `trainable: true` +- price mode: `close_only` + +스케일 추정 파일: `.omx/specs/stom-ohlcv-finetune-research/all_tables_sample_scale_lookback300_predict60.json` + +- 호환 주식 테이블: 2,425개 +- 전체 그룹: 73,968개 +- eligible 그룹: 73,650개 +- 주식 그룹 전체 row: 122,522,300 +- 이론상 window sample 추정: 95,946,764 + +해석: + +- “여러 종목을 학습했는가?”에 대한 답은 **예**입니다. +- 단, 최종 all-table 학습은 95,946,764개 모든 window를 전부 학습한 것이 아니라, bounded 설정으로 sample 수를 제한한 pilot/scale-up 성격입니다. + +### 2.2 STOM tick DB → OHLCV 변환 로직 + +확인 코드: `finetune_csv/stom_tick_dataset.py` + +중요 구현: + +- `connect_readonly()`는 SQLite를 `mode=ro`로 열고 `PRAGMA query_only=ON`을 설정합니다. +- `infer_stom_column_mapping()`은 STOM 테이블의 timestamp/현재가/종가/거래량/거래대금 계열 컬럼을 Kronos OHLCV로 매핑합니다. +- `price_mode=close_only`에서는 open/high/low/close를 모두 `종가` 또는 `현재가` 기반 close 컬럼으로 통일합니다. +- `read_stom_table_as_kline()`은 09:00:00~09:30:00 구간을 필터링할 수 있습니다. +- `export_stom_tick_db_to_csv()`는 모든 테이블을 순회하면서 `symbol, session` 그룹별로 학습 가능한 row 수를 만족하는 데이터만 CSV에 기록합니다. + +판단: + +- DB 원본을 직접 수정하지 않고 읽기 전용으로 사용하므로 안전합니다. +- STOM tick DB가 “진짜 봉 OHLC”가 아니라 tick 단위 현재가 흐름일 가능성이 커서 `close_only`를 쓴 것은 합리적입니다. +- 다만 `close_only`는 OHLC가 모두 같은 값이므로 Kronos가 원래 기대하는 candlestick shape 정보는 줄어듭니다. 이 모델은 “초단위 close path + volume/amount”에 더 가깝게 학습된 것으로 봐야 합니다. + +## 3. 데이터 누수/분할 정확성 검토 + +확인 코드: `finetune_csv/stom_tick_dataset.py`, `finetune_csv/finetune_base_model.py` + +### 3.1 종목/일자 경계 누수 + +`GroupedKlineDataset`은 다음 방식으로 window를 구성합니다. + +- 필수 column: `symbol`, `session`, `timestamps`, `open`, `high`, `low`, `close`, `volume`, `amount` +- 정렬: `group_columns + timestamps` +- 그룹: `symbol, session` +- window 길이: `lookback_window + predict_window + 1` +- sample index는 각 그룹 내부에서만 생성 + +따라서 한 종목의 어느 날짜 window가 다른 종목 또는 다른 날짜로 이어지는 문제는 코드 구조상 방지됩니다. + +### 3.2 train/validation split + +`GroupedKlineDataset._split_groups()`는 전체 그룹을 `first_timestamp, group_key` 기준으로 정렬한 뒤 group 단위로 train/val/test를 나눕니다. + +장점: + +- row 중간에서 split하지 않으므로 window가 train/val 경계를 넘지 않습니다. +- 종목/세션 경계가 보존됩니다. + +주의점: + +- 같은 종목이 다른 날짜 session으로 train과 validation 양쪽에 등장할 수 있습니다. +- 이는 “매일 거래대금 상위 100위가 바뀌는 universe에 대해 일반화”하려는 목적에는 자연스럽지만, “완전한 미등장 종목 holdout 검증”은 아닙니다. +- 일자 기준 엄격한 walk-forward 검증은 아직 별도 보강이 필요합니다. + +### 3.3 정규화 누수 + +중요 설정: + +- `config_stom_1tick_all.yaml`: `normalize_using: "lookback"` +- `GroupedKlineDataset.get_numpy()`: `lookback` 설정이면 정규화 평균/표준편차를 예측 대상 미래 구간이 아니라 lookback 구간에서만 계산합니다. + +판단: + +- 현재 config 기준으로 정규화 미래 누수는 통제되어 있습니다. +- 단, 누군가 `normalize_using: window`로 바꾸면 미래 구간까지 평균/표준편차 계산에 들어가므로 학습/검증 해석이 왜곡될 수 있습니다. 이 설정은 바꾸지 않는 것이 좋습니다. + +## 4. 파인튜닝 실행 방식 검토 + +확인 코드: `finetune_csv/train_sequential.py`, `finetune_csv/finetune_base_model.py` + +학습 방식: + +- tokenizer: `NeoQuasar/Kronos-Tokenizer-base` 사용, STOM용 tokenizer 재학습은 하지 않음. +- predictor/base model: `NeoQuasar/Kronos-small`에서 시작해 fine-tuning. +- 입력: 정규화된 OHLCV + 시간 feature. +- target: tokenizer가 만든 token sequence를 한 step shift하여 autoregressive next-token loss 계산. +- optimizer: AdamW. +- checkpoint: validation loss가 개선될 때 `best_model` 저장. + +판단: + +- Kronos 구조에 맞는 “시계열 token next-step 예측” 방식으로 학습되고 있습니다. +- 모델이 단순 회귀 label을 직접 맞추는 것이 아니라, Kronos tokenizer/predictor 구조에 맞춰 tokenized future sequence를 학습합니다. +- 현재 설정은 1 epoch, 작은 learning rate 중심의 적응 학습입니다. 큰 폭의 재학습이 아니라 STOM tick 분포에 맞춘 미세 조정입니다. + +## 5. GPU 학습 완료 여부 + +확인 파일: `.omx/specs/stom-full-training-dashboard/env_check_after_cuda.json` + +- PyTorch: `2.9.0+cu128` +- CUDA 사용 가능: `true` +- CUDA version: `12.8` +- GPU: `NVIDIA GeForce RTX 4080 SUPER` +- GPU memory: 15.99 GB + +학습 로그/체크포인트: + +| 모델/run | data path | device | step | train loss | val loss | 시간 | checkpoint | +| --- | --- | --- | ---: | ---: | ---: | ---: | --- | +| `stom_1tick_gpu_pilot_lookback300_pred60` | `finetune_csv/data/stom_1tick_kline.csv` | `cuda:0` | 512 | 2.4667 | 2.4311 | 1.40분 | 있음 | +| `stom_1tick_300_lookback300_pred60` | `finetune_csv/data/stom_1tick_kline_300.csv` | `cuda:0` | 6,250 | 2.3893 | 2.3258 | 9.32분 | 있음 | +| `stom_1tick_1000_lookback300_pred60` | `finetune_csv/data/stom_1tick_kline_1000.csv` | `cuda:0` | 25,000 | 2.3204 | 2.3030 | 35.22분 | 있음 | +| `stom_1tick_all_lookback300_pred60` | `finetune_csv/data/stom_1tick_kline_all_bounded.csv` | `cuda:0` | 37,500 | 2.4891 | 2.3983 | 55.95분 | 있음 | + +체크포인트 파일: + +- 각 run마다 `basemodel/best_model/model.safetensors` 존재. +- 각 model.safetensors 크기: 약 98,980,656 bytes. + +중요 주의: + +- `stom_1tick_all_lookback300_pred60` 로그에는 2026-05-06 22:08의 이전 all CSV 시작 기록과 2026-05-07 06:27의 bounded CSV 완료 기록이 같은 log 파일에 함께 남아 있습니다. +- 완료된 segment는 `stom_1tick_kline_all_bounded.csv`를 사용했고, 37,500 step/55.95분으로 정상 종료되었습니다. +- 따라서 “절전 모드 때문에 학습이 안 됐는가?” 관점에서는, 최종 bounded run은 완료 및 checkpoint 저장 근거가 있습니다. 다만 이전 unbounded 시작 segment는 완료 기록이 없으므로 성공 run으로 보지 않아야 합니다. + +## 6. 예측 성과 검토 + +확인 파일: `webui/stom_predictions/*.metrics.json` + +| 예측 파일 | windows | rows | MAPE | 방향정확도 | 평균 예측 등락률 | 평균 실제 등락률 | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| `pilot_predictions` | 5 | 300 | 0.7366 | 0.20 | 0.0000 | 0.4960 | +| `kronos_gpu_pilot_predictions` | 20 | 1,200 | 0.8289 | 0.50 | -0.2807 | 0.1523 | +| `kronos_300_predictions` | 100 | 6,000 | 0.6687 | 0.58 | -0.1967 | 0.0202 | +| `kronos_1000_predictions` | 200 | 12,000 | 0.5583 | 0.49 | -0.1879 | -0.0481 | +| `kronos_all_predictions` | 300 | 18,000 | 0.2720 | 0.40 | -0.0751 | 0.0094 | + +해석: + +- 가격 오차 관점에서는 all-table bounded 예측의 MAPE가 가장 낮습니다. +- 하지만 방향정확도는 all-table bounded가 0.40으로 낮습니다. +- `kronos_300`은 방향정확도 0.58로 가장 좋아 보이지만 windows 100개 수준이므로 과대평가 가능성이 있습니다. +- 평균 예측 등락률이 전반적으로 음수 쪽으로 치우쳐 있어 보수적/하방 bias가 있습니다. + +결론: + +- “모델이 예측 CSV를 생성하고 실제값/예측값 비교를 할 수 있는가?”는 **예**입니다. +- “예측이 매매에 충분한가?”는 **아직 아니오**입니다. +- 현재는 raw Kronos 예측값을 그대로 매수/매도 조건으로 쓰기보다, 점수화와 필터링을 거친 후보 feature로 활용해야 합니다. + +## 7. 웹 대시보드 검증 관점 + +기존 개발 산출물 기준으로 다음 경로가 확인됩니다. + +- 예측 산출물: `webui/stom_predictions/*.csv` +- metrics: `webui/stom_predictions/*.metrics.json` +- dashboard helper 테스트: `tests/test_stom_dashboard_helpers.py` +- 예측 평가 테스트: `tests/test_stom_prediction_eval.py` + +판단: + +- 학습 후 생성된 예측 CSV/metrics를 웹 UI에서 실제값과 예측값 비교 용도로 사용할 수 있는 구조는 만들어져 있습니다. +- 단, 웹 대시보드 표시가 “모델 품질 검증 완료”를 의미하지는 않습니다. 대시보드는 관찰/검토 도구이고, 실전 투입 판단은 backtest와 live paper-trading 검증이 별도로 필요합니다. + +## 8. 현재 파인튜닝의 가장 중요한 한계 + +1. **최종 all-table 모델은 bounded 학습이다.** + 모든 호환 테이블 universe를 대상으로 했지만, 모든 가능한 95,946,764 window를 전부 학습한 것은 아닙니다. config상 `max_samples: 300000`, `sample_stride: 10`으로 제한된 학습입니다. + +2. **1 epoch만 학습했다.** + 실행 검증과 pilot scale-up에는 적절하지만, 최종 성능 수렴을 단정할 수 없습니다. + +3. **방향정확도가 안정적이지 않다.** + all-table bounded 예측은 MAPE가 낮아도 방향정확도 0.40입니다. 가격 경로가 평균적으로 가까워도 종가매매/단타 의사결정의 승률로 바로 연결되지 않습니다. + +4. **검증 split이 실전 walk-forward와 동일하지 않다.** + group 단위 split은 누수 방지에는 좋지만, “최근 날짜를 완전히 holdout”하는 실전 검증과는 다릅니다. + +5. **`close_only`는 안전하지만 정보량이 제한된다.** + STOM tick DB의 OHLC 의미가 누적 장중 값일 수 있어 `close_only`가 합리적이지만, 결과적으로 봉의 고저/시가 구조는 학습하지 않습니다. + +6. **비용/슬리피지/체결 가능성 검증이 아직 없다.** + 방향정확도와 MAPE만으로 실제 수익성을 판단할 수 없습니다. + +## 9. 올바르게 활용하는 방법 + +현재 Kronos fine-tuned 모델은 단독 매수 신호가 아니라 다음과 같은 feature/score로 쓰는 것이 안전합니다. + +### 9.1 실시간 1분/1초 예측 점수 + +추천 점수 구성 예: + +```text +kronos_return_score = 예상 등락률 z-score +kronos_direction_score = 예측 종가 > 현재가 여부 +kronos_confidence_score = 여러 sample 예측의 분산 역수 +volume_score = 거래량/거래대금 급증 점수 +trend_score = 최근 n초 또는 n분 추세 점수 +risk_penalty = spread, 급락, VI, 과열, 거래정지/관리종목 등 제외 패널티 +final_score = 0.35*kronos_return_score + + 0.20*kronos_direction_score + + 0.20*volume_score + + 0.15*trend_score + - 0.10*risk_penalty +``` + +현재 방향정확도만 보면 Kronos 비중을 너무 크게 두면 위험합니다. 초기에는 Kronos 비중을 20~35% 이하로 두고, 거래대금/체결강도/추세/호가 조건으로 보완하는 것이 좋습니다. + +### 9.2 종가매매 활용 + +일봉용으로도 같은 구조는 가능합니다. 다만 현재 검토한 모델은 09:00~09:30 1tick/1초 흐름에 맞춘 STOM 파인튜닝이므로, 일봉 종가매매에 그대로 쓰면 데이터 분포가 다릅니다. + +종가매매에는 별도 daily OHLCV CSV를 만들고 다음을 해야 합니다. + +- lookback: 예: 60일~240일 +- predict: 예: 1일~5일 +- split: 최근 기간 holdout walk-forward +- metric: 다음날/다음 n일 수익률, hit ratio, MDD, turnover, 비용 차감 수익 +- score: Kronos 예상 등락률 + 수급/거래대금/변동성/시장상태 필터 + +## 10. 권장 다음 단계 + +우선순위 1 — 검증 강화: + +- 최근 날짜 holdout walk-forward 평가 추가. +- persistence baseline과 동일 window 비교. +- 거래 비용/슬리피지 반영 backtest 추가. +- 방향정확도뿐 아니라 top-k 추천 수익률, MDD, turnover, hit ratio 기록. + +우선순위 2 — 재현성 강화: + +- checkpoint마다 config hash, data file hash/size, log segment id를 manifest로 저장. +- 같은 log 파일에 이전 중단 run과 완료 run이 섞이지 않도록 run id별 로그 파일 분리. + +우선순위 3 — 모델 개선: + +- all-table bounded 학습을 2~3 epoch로 늘려 검증 loss와 방향정확도 변화를 확인. +- `max_samples`를 늘리되 memory/time을 기록. +- STOM tick의 시가/고가/저가 컬럼 의미가 “해당 tick 구간 OHLC”인지 “장중 누적 OHLC”인지 확정되면 `db_ohlc` 모드와 비교 실험. + +우선순위 4 — 실전 사용: + +- Kronos 예측을 단독 신호가 아닌 추천 점수의 일부로 편입. +- 임계값 기반 paper trading 로그를 최소 2~4주 수집. +- 실제 주문 전에는 STOM 실시간 DB 지연, 장중 재학습/재예측 주기, 실패 시 fallback 정책을 정해야 합니다. + +## 11. 집중 검토 최종 판정 + +- **파인튜닝 실행 자체:** 정상 완료. +- **GPU 사용:** 정상. +- **DB 전체 universe 활용:** 호환 가능한 모든 주식 테이블 기반 all-table bounded CSV를 사용한 완료 run 확인. +- **여러 종목 학습:** 정상. +- **종목/세션 경계 누수:** 현재 구조상 방지. +- **정규화 미래 누수:** 현재 `lookback` 설정 기준 방지. +- **checkpoint 사용 가능성:** 가능. +- **실전 예측 신뢰도:** 아직 부족. 특히 방향정확도와 수익성 검증이 부족. + +따라서 현재 상태는 **“STOM tick DB로 Kronos를 파인튜닝하는 기술 경로는 성공했고, 모델 산출물도 생성되었으나, 매매 전략으로 채택하려면 검증/점수화/백테스트 단계가 추가로 필요”**로 판단합니다. diff --git a/docs/stom_kronos_official_200k_result_report.md b/docs/stom_kronos_official_200k_result_report.md new file mode 100644 index 000000000..ca33b4324 --- /dev/null +++ b/docs/stom_kronos_official_200k_result_report.md @@ -0,0 +1,187 @@ +# STOM Kronos 공식 200k 학습 성과 보고서 + +작성일: 2026-05-09 +대상 모델: `stom_1s_grid_pred60_official_200k` + +## 1. 결론 요약 + +공식 Kronos 순서인 **STOM tokenizer fine-tuning → STOM predictor fine-tuning → holdout 평가/시각화**는 실제 checkpoint 기준으로 완료됐다. + +그러나 현재 200k pilot 결과는 **실매매 확대 승인 수준이 아니다.** + +핵심 이유: + +1. 방향 정확도는 `0.4188`로 random baseline `0.4084`보다 약간 높지만, 차이가 작다. +2. Top-K 실제 평균 수익은 비용 전 `+0.0511%` 수준인데, 비용/슬리피지 25bp 반영 후 `-0.1989%`로 음수다. +3. rolling validation 8개 fold 모두 비용 반영 후 평균 test net이 음수이며, target 25bp gate가 실패했다. +4. 따라서 1M/5M/full-window 성과 목적 확대는 현재 evidence 기준으로 보류가 맞다. + +## 2. 공식 학습 완료 근거 + +| 단계 | 결과 | 근거 | +| --- | --- | --- | +| tokenizer 20k | 완료 | duration 567.944628초, best val_loss 0.004013419676455669 | +| tokenizer 200k | 완료 | duration 3,211.134006초, best val_loss 0.002904271284851711 | +| predictor 200k | 완료 | duration 4,129.433555초, best val_loss 2.131037336307764 | +| holdout 예측 | 완료 | 184,800 rows, 3,080 windows, 334 symbols | +| 대시보드 그래프 | 완료 | `/api/stom/prediction` status 200, chart JSON 생성 | + +학습 checkpoint: + +```text +Tokenizer: finetune/outputs/stom_1s_grid_pred60_official_200k/finetune_tokenizer/checkpoints/best_model +Predictor: finetune/outputs/stom_1s_grid_pred60_official_200k/finetune_predictor/checkpoints/best_model +``` + +## 3. Holdout 실제값 vs 예측값 성과 + +평가 파일: + +```text +webui/stom_predictions/stom_1s_pred60_official200k_walkforward100x5x50_eval_kronos.csv +webui/stom_predictions/stom_1s_pred60_official200k_walkforward100x5x50_eval_comparison.json +``` + +| 지표 | Official 200k Kronos | Persistence | Random | +| --- | ---: | ---: | ---: | +| rows | 184,800 | 184,800 | 184,800 | +| windows | 3,080 | 3,080 | 3,080 | +| symbols | 334 | 334 | 334 | +| MAE | 173.0052 | 163.4523 | 167.8729 | +| RMSE | 441.4880 | 400.7079 | 402.1609 | +| MAPE | 0.3382% | 0.3184% | 0.3251% | +| 방향 정확도 | 0.4188 | 0.1487 | 0.4084 | +| Top-K hit rate | 0.4146 | 0.1567 | 0.4073 | +| Top-K avg actual return | 0.0518% | 0.0601% | 0.0513% | + +해석: + +- 방향 정확도는 random보다 약간 높지만, MAPE/MAE/RMSE는 persistence보다 나쁘다. +- Top-K avg actual return도 persistence baseline보다 낮다. +- 따라서 “예측 그래프는 생성 가능”하지만 “거래 신호로 바로 사용 가능”하다고 보기 어렵다. + +## 4. 비용 반영 Qlib-style Top-K 결과 + +파일: + +```text +webui/qlib_backtests/stom_1s_pred60_official200k_walkforward100x5x50_eval_kronos.qlib_topk5.json +``` + +| 항목 | 값 | +| --- | ---: | +| periods | 500 | +| trades | 2,470 | +| gross avg return | 0.051069% | +| net avg return, 25bp cost | -0.198931% | +| hit rate | 0.430364 | +| direction hit rate | 0.414575 | +| cumulative return | -63.178640% | +| max drawdown | -63.178640% | +| sharpe per period | -11.982310 | + +결론: 비용 전 수익폭이 너무 작아 25bp 비용/슬리피지 환경에서는 손실 구조다. + +## 5. 조건식/필터 보완 결과 + +파일: + +```text +webui/qlib_backtests/stom_1s_pred60_official200k_walkforward100x5x50_eval_kronos.filter_search.json +``` + +Best filter: + +```text +ret>=-0.1|cons>=0|range<=0.05|amt_q>=0|vol<=none +``` + +| 항목 | baseline top-k | best filter | +| --- | ---: | ---: | +| period_count | 500 | 464 | +| trade_count | 2,470 | 1,320 | +| avg gross return | 0.051799% | 0.063075% | +| avg net return, 25bp | -0.198931% | -0.173905% | +| improvement vs baseline net | - | +0.025026%p | + +해석: + +- 필터는 손실을 조금 줄였지만 net return을 양수로 바꾸지는 못했다. +- “조건식으로 보완 가능성”은 있으나, 현재 모델/표본에서는 충분하지 않다. + +## 6. Rolling validation / cost gate + +파일: + +```text +webui/qlib_backtests/stom_1s_pred60_official200k_walkforward100x5x50_eval_kronos_rolling100x50.rolling_filter_validation.json +webui/qlib_backtests/stom_1s_pred60_official200k_walkforward100x5x50_eval_kronos_cost_gate.cost_gate.json +``` + +Rolling summary: + +| 항목 | 값 | +| --- | ---: | +| fold_count | 8 | +| total_test_trade_count | 212 | +| avg_train_net_return_pct | -0.037471 | +| avg_test_net_return_pct | -0.271940 | +| avg_test_baseline_net_return_pct | -0.201852 | +| avg_test_improvement_net_pct | -0.070088 | +| test_direction_hit_rate_weighted | 0.429245 | +| positive_test_fold_rate | 0.0 | +| overfit_gap_pct | 0.234468 | +| is_profitable_after_cost | false | + +Cost gate: + +```text +decision = hold_expand_200k +expand_training_allowed = false +target_total_cost_bps = 25 +passes_gate = false +``` + +## 7. 이전 pred60 비공식/기존 결과와 비교 + +기존 `stom_1s_pred60_walkforward100x5x50_eval`은 공식 tokenizer fine-tuning을 거치지 않은 결과다. + +| 지표 | 기존 pred60 | 공식 200k | +| --- | ---: | ---: | +| 방향 정확도 | 0.4312 | 0.4188 | +| Top-K hit rate | 0.4328 | 0.4146 | +| MAPE | 0.3334% | 0.3382% | +| MAE | 169.4190 | 173.0052 | + +현재 200k 공식 모델은 기존 결과보다 약하다. 가능한 원인은 다음이다. + +1. 200k는 전체 73,718,875 train windows 중 극히 일부다. +2. `full_sequential` 200k는 “정확한 순차 coverage 검증용” 성격이 강해, random sample보다 날짜/종목 대표성이 낮을 수 있다. +3. tokenizer와 predictor 모두 1 epoch pilot이라 충분히 수렴했다고 보기 어렵다. +4. 1초봉/60초 뒤 예측은 노이즈가 커서 방향성 edge가 작다. +5. 비용 25bp 대비 60초 평균 기대수익 자체가 너무 작다. + +## 8. 판단 + +- 그래프/대시보드 확인 목적: **성공** +- Kronos 공식 순서 준수: **성공** +- 현재 모델로 실전 매매 사용: **비권장** +- 1M/5M/full-window 성과 목적 확대: **보류** +- 단, 사용자가 “수익성 gate와 무관하게 전체 데이터 공식 학습 자체가 목적”이라고 명시하면 8단계에서 장시간 실행 계획으로 전환 가능하다. + +## 9. pred30 진행 판단 + +현재는 pred30 200k/full-window를 즉시 실행하지 않는다. + +판단 근거: + +- pred60 official 200k도 cost gate를 통과하지 못했다. +- pred30은 더 짧은 예측 horizon이므로 25bp 비용을 이기려면 더 강한 순간 방향 edge가 필요하다. +- 새 tokenizer/predictor 세트를 다시 학습해야 하므로, 성과 근거 없이 바로 200k 이상으로 확대하는 것은 시간 대비 효율이 낮다. + +권장 대안: + +1. 대시보드에서 official 200k pred60 그래프를 먼저 확인한다. +2. 실전 목적이면 비용 구조와 조건식/score 개선을 먼저 한다. +3. 연구 비교 목적이면 pred30은 20k tokenizer/predictor quick pilot부터 시작한다. +4. 사용자가 “수익성 gate와 관계없이 pred30 공식 비교도 진행”이라고 명시하면 동일 pipeline으로 pred30을 수행한다. diff --git a/docs/stom_kronos_official_execution_plan.md b/docs/stom_kronos_official_execution_plan.md new file mode 100644 index 000000000..df1a18025 --- /dev/null +++ b/docs/stom_kronos_official_execution_plan.md @@ -0,0 +1,329 @@ +# STOM tick Kronos 공식 준수 실행 계획 + +작성일: 2026-05-09 + +## 1. 반드시 기억할 최종 원칙 + +Kronos 공식 README 기준의 fine-tuning 순서는 다음이다. + +```text +Config 설정 +-> Qlib/STOM 데이터 준비 +-> Tokenizer fine-tuning +-> Predictor fine-tuning +-> Backtesting / evaluation / visualization +``` + +따라서 지금부터의 공식 준수 작업은 `pretrained tokenizer + predictor만 fine-tuning`이 아니라, +**STOM 데이터로 tokenizer를 먼저 fine-tuning하고 그 tokenizer checkpoint로 predictor를 fine-tuning**하는 흐름이다. + +## 2. 1~8 단계 + +| 단계 | 목적 | 현재 상태 | +| --- | --- | --- | +| 1 | tokenizer 학습 경로 안정화 | 완료/커밋 대기 | +| 2 | tokenizer 20k benchmark로 시간 계수 측정 | 예정 | +| 3 | pred60 tokenizer expand_200k 실행 | 예정 | +| 4 | pred60 predictor expand_200k 실행 | 예정 | +| 5 | holdout 실제값 vs 예측값 그래프 생성 | 예정 | +| 6 | 성과 보고서 작성 | 예정 | +| 7 | pred30 진행 여부 판단 | 예정 | +| 8 | 1M/5M/full-window 확대 안내 | 예정 | + +## 3. pred 길이 결정 + +우선순위는 `pred60`이다. + +```text +lookback_window = 300 +predict_window = 60 +``` + +`pred30`은 학습 목표가 다른 별도 모델이므로 pred60 official pilot 결과를 보고 반복 여부를 판단한다. + +## 4. full-window의 정확한 의미 + +기존 dataset 기본 동작은 전체 possible window에서 무작위 샘플링하는 방식이다. “전체 window를 학습했다”고 말하려면 `full_sequential` 방식이 필요하다. + +인정 기준: + +```text +visited_window_count == total_available_windows +unique_visited_window_count == total_available_windows +duplicate_count == 0 +missing_count == 0 +``` + +이번 1단계에서 `sample_random`과 `full_sequential` mode를 코드에서 분리했다. + +## 5. 대형 실행 확대 기준 + +200k official pilot은 공식 절차와 그래프 확인 목적이다. 수익성 승인 단계가 아니다. + +1M/5M/full-window 성과 목적 확대에는 다음 중 하나가 필요하다. + +```text +1. target 25bp cost_sensitivity_gate 통과 +2. 사용자가 수익성 근거 없이도 공식 준수 대형 실행을 하겠다고 명시적으로 override +``` + +## 6. 현재 예상 시간 + +| 범위 | pred60 예상 | +| --- | ---: | +| tokenizer 20k benchmark | 약 7~15분 | +| pred60 tokenizer 200k | 약 1.2~3시간 | +| pred60 predictor 200k | 약 1.5~2시간 | +| pred60 200k 예측/그래프 검증 | 약 40~60분 | +| pred60 official 200k 전체 | 약 3~5시간 | +| pred60 official full-window | 약 40~75시간 | + +시간은 기존 predictor 20k 실측 549초를 기준으로 한 추정이며, tokenizer는 20k benchmark 후 재계산한다. + +## 7. 1단계 검증 근거 + +- `KRONOS_DATASET_SAMPLE_MODE=sample_random|full_sequential` 지원 추가. +- tokenizer/predictor 학습 runner에 `--train-stage tokenizer|predictor|both` 추가. +- `both` 실행 시 tokenizer checkpoint를 predictor 학습에 자동 전달. +- Windows 단일 GPU/단일 프로세스에서도 tokenizer 학습 스크립트 실행 가능하도록 DDP 필수 조건 완화. +- 테스트: `PYTEST_DISABLE_PLUGIN_AUTOLOAD=1; C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_1s_finetune_runner.py -q` → 6 passed. +- 컴파일: `C:\Python\64\Python3119\python.exe -m compileall finetune\config.py finetune\dataset.py finetune\run_stom_1s_finetune.py finetune\train_predictor.py finetune\train_tokenizer.py tests\test_stom_1s_finetune_runner.py` → 성공. +- dry-run: pred60 `--train-stage both --sample-stage budget_20k --dataset-sample-mode full_sequential` → tokenizer/predictor manifest 생성 및 handoff 확인. + +주의: 기본 pytest 플러그인 자동 로딩 상태에서는 Windows PyTorch DLL 초기화 오류가 발생했다. 실제 Python 프로세스의 `import torch`와 플러그인 비활성화 테스트는 정상이다. + +## 8. 2단계 tokenizer 20k benchmark 실측 결과 + +실행 명령: + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage tokenizer ` + --sample-stage budget_20k ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred60_official_tokenizer_20k ` + --dataset-sample-mode full_sequential ` + --batch-size 4 ` + --num-workers 0 ` + --log-interval 100 +``` + +실측 결과: + +| 항목 | 값 | +| --- | ---: | +| train possible windows | 73,718,875 | +| val possible windows | 15,938,107 | +| 실제 train samples | 20,000 | +| 실제 val samples | 4,000 | +| train steps | 5,000 | +| val steps | 1,000 | +| duration_seconds | 567.944628초 | +| 총 경과 | 약 9분 28초 | +| best tokenizer val_loss | 0.004013419676455669 | +| checkpoint | `finetune/outputs/stom_1s_grid_pred60_official_tokenizer_20k/finetune_tokenizer/checkpoints/best_model` | + +이 실측을 단순 선형 환산하면 tokenizer 학습 예상 시간은 다음이다. + +| tokenizer 범위 | 단순 환산 시간 | +| --- | ---: | +| 200k | 약 1.58시간 | +| 1M | 약 7.89시간 | +| 5M | 약 39.44시간 | +| pred60 train full-window 73,718,875 | 약 581.5시간, 약 24.2일 | + +주의: full-window 환산은 train sample 기준의 보수적 단순 환산이며, 전체 validation을 같이 full로 돌리면 더 길어진다. 따라서 8단계 대형 확대는 반드시 목적을 분리해야 한다. + +## 9. 3단계 pred60 tokenizer 200k 실행 결과 + +실행 명령: + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage tokenizer ` + --sample-stage expand_200k ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred60_official_200k ` + --dataset-sample-mode full_sequential ` + --batch-size 4 ` + --num-workers 0 ` + --log-interval 1000 +``` + +실측 결과: + +| 항목 | 값 | +| --- | ---: | +| train possible windows | 73,718,875 | +| val possible windows | 15,938,107 | +| 실제 train samples | 200,000 | +| 실제 val samples | 40,000 | +| train steps | 50,000 | +| val steps | 10,000 | +| duration_seconds | 3,211.134006초 | +| 총 경과 | 약 53분 31초 | +| best tokenizer val_loss | 0.002904271284851711 | +| checkpoint | `finetune/outputs/stom_1s_grid_pred60_official_200k/finetune_tokenizer/checkpoints/best_model` | + +20k 대비 val_loss는 0.004013 → 0.002904로 개선되었다. 다음 단계는 이 tokenizer checkpoint를 `KRONOS_FINETUNED_TOKENIZER_PATH`로 predictor 200k 학습에 전달하는 것이다. + +## 10. 4단계 pred60 predictor 200k 실행 결과 + +실행 명령: + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage predictor ` + --sample-stage expand_200k ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred60_official_200k ` + --dataset-sample-mode full_sequential ` + --finetuned-tokenizer-path finetune\outputs\stom_1s_grid_pred60_official_200k\finetune_tokenizer\checkpoints\best_model ` + --batch-size 4 ` + --num-workers 0 ` + --log-interval 1000 +``` + +실측 결과: + +| 항목 | 값 | +| --- | ---: | +| train samples | 200,000 | +| val samples | 40,000 | +| train steps | 50,000 | +| duration_seconds | 4,129.433555초 | +| 총 경과 | 약 1시간 8분 49초 | +| best predictor val_loss | 2.131037336307764 | +| tokenizer source | `finetune/outputs/stom_1s_grid_pred60_official_200k/finetune_tokenizer/checkpoints/best_model` | +| predictor checkpoint | `finetune/outputs/stom_1s_grid_pred60_official_200k/finetune_predictor/checkpoints/best_model` | + +이 단계로 공식 순서의 핵심인 `STOM tokenizer fine-tuning -> STOM predictor fine-tuning` 흐름은 실제 checkpoint 기준으로 완료되었다. 다음 단계는 이 모델을 holdout/walk-forward 데이터에 적용하여 실제값과 예측값을 그래프 및 성과 지표로 비교하는 것이다. + +## 11. 5단계 holdout 실제값 vs 예측값 시각화 산출물 + +실행 명령: + +```powershell +C:\Python\64\Python3119\python.exe finetune\evaluate_stom_1s_checkpoint.py ` + --dataset-path finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets ` + --model-path finetune\outputs\stom_1s_grid_pred60_official_200k\finetune_predictor\checkpoints\best_model ` + --tokenizer-path finetune\outputs\stom_1s_grid_pred60_official_200k\finetune_tokenizer\checkpoints\best_model ` + --output-dir webui\stom_predictions ` + --prefix stom_1s_pred60_official200k_walkforward100x5x50_eval ` + --lookback-window 300 ` + --predict-window 60 ` + --max-symbols 50 ` + --max-asofs 5 ` + --max-sessions 100 ` + --stride 300 ` + --batch-size 4 ` + --top-k 5 ` + --device cuda:0 +``` + +산출물: + +| 파일 | 목적 | +| --- | --- | +| `webui/stom_predictions/stom_1s_pred60_official200k_walkforward100x5x50_eval_kronos.csv` | Kronos 실제값/예측값 행 단위 비교 | +| `webui/stom_predictions/stom_1s_pred60_official200k_walkforward100x5x50_eval_persistence.csv` | persistence baseline 비교 | +| `webui/stom_predictions/stom_1s_pred60_official200k_walkforward100x5x50_eval_random.csv` | random baseline 비교 | +| `webui/stom_predictions/stom_1s_pred60_official200k_walkforward100x5x50_eval_comparison.json` | 모델별 요약 metric | + +평가 결과: + +| 항목 | Kronos official 200k | +| --- | ---: | +| rows | 184,800 | +| windows | 3,080 | +| symbols | 334 | +| periods/asof | 500 | +| MAE | 173.0052 | +| RMSE | 441.4880 | +| MAPE | 0.3382% | +| 방향 정확도 | 0.4188 | +| Top-K hit rate | 0.4146 | +| avg pred return | -0.0073% | +| avg actual return | 0.0504% | + +웹 대시보드 검증: + +```text +GET /api/stom/prediction?file=stom_1s_pred60_official200k_walkforward100x5x50_eval_kronos.csv&window_id=0 +status=200 +metrics.windows=3080 +chart JSON length=12391 +``` + +즉, `/stom` 대시보드에서 위 CSV를 선택하면 실제 close와 예측 close 그래프를 확인할 수 있다. + +추가로 filter search가 반복 groupby 때문에 10분 이상 지연되어, 동일 의미를 유지하면서 `sort_values(...).groupby(...).head(top_k)` 방식으로 최적화했다. 최적화 후 filter search는 약 109초, rolling validation은 약 255초에 완료됐다. + +## 12. 6단계 성과 보고서 + +상세 보고서는 다음 파일에 작성했다. + +```text +docs/stom_kronos_official_200k_result_report.md +``` + +핵심 결론: + +- 공식 학습 절차와 대시보드 시각화는 성공. +- 방향 정확도 0.4188로 random 0.4084보다 약간 높지만 실전 edge로 보기 어렵다. +- 25bp 비용 반영 Top-K net return은 -0.1989%로 음수. +- 조건식/필터 최적화 후에도 best filter net return은 -0.1739%로 음수. +- rolling cost gate 결과는 `hold_expand_200k`, `expand_training_allowed=false`. + +## 13. 7단계 pred30 진행 여부 판단 + +결론: **지금 즉시 pred30 200k/full-window 학습으로 확대하지 않는다.** + +이유: + +1. pred60 official 200k가 25bp cost gate를 통과하지 못했다. +2. 공식 200k pred60의 방향 정확도 0.4188은 random 0.4084 대비 약한 개선에 그쳤다. +3. Top-K net return은 25bp 비용 반영 후 -0.1989%로 음수다. +4. pred30은 별도 tokenizer/predictor 모델이 필요하므로 최소 2~3시간 이상의 추가 학습/평가 시간이 든다. +5. pred30이 더 짧은 horizon이라 비용 대비 기대수익폭이 더 작아질 가능성이 있다. + +단, pred30을 완전히 버리는 것은 아니다. 다음 조건 중 하나가 있으면 pred30으로 진행한다. + +| 조건 | 권장 실행 | +| --- | --- | +| 사용자가 연구 목적으로 pred30도 비교하겠다고 명시 | pred30 tokenizer 20k → predictor 20k → quick holdout | +| pred60에서 비용을 낮춘 환경, 예: 5bp 이하, 에서 gate 통과 | pred30 200k 비교 가능 | +| 종가/분봉/더 긴 horizon에서 edge가 확인됨 | pred30은 후순위 실험으로 유지 | +| 전체 데이터 공식 학습 자체가 목적이며 수익성 gate를 무시한다고 명시 | pred30도 full-window 장시간 계획에 포함 가능 | + +현재 권장 순서: + +```text +1. pred60 official 200k 결과를 대시보드에서 눈으로 확인 +2. 8단계에서 1M/5M/full-window 확대 여부를 목적별로 결정 +3. 수익성 목적이면 pred30보다 먼저 비용 구조, 거래 horizon, score filter 개선 +4. 연구 목적이면 pred30은 20k benchmark부터 작게 비교 +``` + +## 14. 8단계 안내 문서 + +8단계 확대 안내는 다음 파일에 작성했다. + +```text +docs/stom_kronos_stage8_expansion_guide.md +``` + +핵심: + +- 수익성 목적이면 현재는 확대 보류. +- 연구 목적이면 1M부터 `sample_random`으로 대표성 있게 재시작 권장. +- 전체 window exact 목적이면 `full_sequential + full_window`가 필요하지만 pred60 기준 약 31일 이상 예상. diff --git a/docs/stom_kronos_stage8_expansion_guide.md b/docs/stom_kronos_stage8_expansion_guide.md new file mode 100644 index 000000000..e42df3542 --- /dev/null +++ b/docs/stom_kronos_stage8_expansion_guide.md @@ -0,0 +1,187 @@ +# 8단계 안내: STOM Kronos 1M/5M/full-window 확대 계획 + +작성일: 2026-05-09 +기준 모델: `stom_1s_grid_pred60_official_200k` + +## 1. 현재 상태 + +1~7단계는 완료됐다. + +| 단계 | 상태 | commit | +| --- | --- | --- | +| 1. tokenizer 학습 경로 안정화 | 완료 | `2153f9f` | +| 2. tokenizer 20k benchmark | 완료 | `9ef5207` | +| 3. pred60 tokenizer 200k | 완료 | `66dd204` | +| 4. pred60 predictor 200k | 완료 | `6fc9bf2` | +| 5. holdout 실제값/예측값 그래프 | 완료 | `79c5e6c` | +| 6. 성과 보고서 | 완료 | `60dc759` | +| 7. pred30 판단 | 완료 | `eb3f9ba` | + +핵심 결론: + +```text +공식 학습 절차와 대시보드 시각화는 성공. +하지만 25bp cost gate는 실패. +따라서 수익성 목적의 1M/5M/full-window 확대는 보류. +``` + +## 2. 8단계에서 먼저 선택해야 하는 목적 + +| 목적 | 권장 여부 | 설명 | +| --- | --- | --- | +| 대시보드/연구용 더 큰 공식 모델 | 가능 | 1M부터 실행 가능. 단, 수익성 보장은 없음 | +| 수익성 목적 확대 | 비권장 | 현재 25bp gate 실패. 먼저 비용/조건식/score 개선 필요 | +| 전체 window exact 학습 증명 | 가능하지만 매우 장시간 | pred60 full-window는 약 31일 이상 예상 | +| pred30 공식 비교 | 보류 | pred60 gate 실패 후 20k pilot부터 재검토 | + +## 3. 실측 기반 시간 계산 + +실측: + +| 작업 | train/val samples | 시간 | +| --- | ---: | ---: | +| tokenizer 200k | 200k / 40k | 3,211초, 약 53.5분 | +| predictor 200k | 200k / 40k | 4,129초, 약 68.8분 | +| holdout 예측/그래프 | 3,080 windows | 1,136초, 약 18.9분 | +| filter search | 3,080 windows | 약 109초 | +| rolling validation | 3,080 windows | 약 255초 | + +단순 선형 환산: + +| 확대 범위 | tokenizer | predictor | 학습 합계 | +| --- | ---: | ---: | ---: | +| 1M | 약 4.46시간 | 약 5.74시간 | 약 10.20시간 | +| 5M | 약 22.30시간 | 약 28.68시간 | 약 50.98시간, 약 2.12일 | +| pred60 full-window 73,718,875 | 약 328.78시간 | 약 422.80시간 | 약 751.58시간, 약 31.32일 | + +주의: + +- 위 시간은 현재 3990X + RTX 4080 SUPER + batch_size 4 기준이다. +- full-window는 장시간 중단/절전/오류 리스크가 크므로 checkpoint resume 설계 없이는 권장하지 않는다. +- 1M/5M도 장시간이므로 절전 방지, 로그 저장, 중간 산출물 확인이 필요하다. + +## 4. 1M/5M 실행 시 sample mode 선택 + +### A. 대표성/성과 실험 목적 + +권장: + +```text +KRONOS_DATASET_SAMPLE_MODE=sample_random +``` + +이유: + +- 1M/5M은 전체 73,718,875 window 중 일부만 보는 stage다. +- `full_sequential`로 일부만 돌리면 데이터 앞부분에 편중될 수 있다. +- 성과 실험은 전체 종목/날짜 pool에서 random sampling 하는 것이 더 대표성이 좋다. + +### B. 전체 window exact 증명 목적 + +권장: + +```text +KRONOS_DATASET_SAMPLE_MODE=full_sequential +--sample-stage full_window +``` + +이유: + +- 모든 window를 중복/누락 없이 1회 방문했다는 claim은 sequential mode에서만 가능하다. +- 단, pred60 기준 학습만 약 31일 이상 예상된다. + +## 5. 1M 연구용 공식 확대 명령어 + +### 5.1 tokenizer 1M + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage tokenizer ` + --sample-stage expand_1m ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred60_official_1m ` + --dataset-sample-mode sample_random ` + --batch-size 4 ` + --num-workers 0 ` + --log-interval 5000 +``` + +### 5.2 predictor 1M + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage predictor ` + --sample-stage expand_1m ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred60_official_1m ` + --dataset-sample-mode sample_random ` + --finetuned-tokenizer-path finetune\outputs\stom_1s_grid_pred60_official_1m\finetune_tokenizer\checkpoints\best_model ` + --batch-size 4 ` + --num-workers 0 ` + --log-interval 5000 +``` + +### 5.3 1M 평가/그래프 + +```powershell +C:\Python\64\Python3119\python.exe finetune\evaluate_stom_1s_checkpoint.py ` + --dataset-path finetune\qlib_exports\stom_1s_grid_pred60_full\processed_datasets ` + --model-path finetune\outputs\stom_1s_grid_pred60_official_1m\finetune_predictor\checkpoints\best_model ` + --tokenizer-path finetune\outputs\stom_1s_grid_pred60_official_1m\finetune_tokenizer\checkpoints\best_model ` + --output-dir webui\stom_predictions ` + --prefix stom_1s_pred60_official1m_walkforward100x5x50_eval ` + --lookback-window 300 ` + --predict-window 60 ` + --max-symbols 50 ` + --max-asofs 5 ` + --max-sessions 100 ` + --stride 300 ` + --batch-size 4 ` + --top-k 5 ` + --device cuda:0 +``` + +## 6. 5M/full-window 안내 + +5M은 1M 결과가 다음 조건을 만족할 때만 권장한다. + +```text +1. 25bp cost gate 개선 +2. 또는 비용을 낮춘 5~10bp 환경에서 양수 net return 확인 +3. 또는 사용자가 수익성 목적이 아니라 연구/공식 전체 학습 목적이라고 명시 +``` + +full-window는 다음 조건을 만족할 때만 권장한다. + +```text +1. 최소 31일 이상 워크스테이션을 안정적으로 점유 가능 +2. 절전/재부팅 방지 가능 +3. checkpoint resume 또는 중간 저장 실패 대응 가능 +4. 사용자가 full exact coverage 자체가 목적이라고 명시 +``` + +## 7. 다음 권장 OMX 명령어 + +현재 evidence 기준 추천은 “바로 1M 실행”이 아니라 “대시보드 확인 후 목적 선택”이다. + +```text +$ralph official 200k 결과를 웹 대시보드에서 확인하고, 1M 연구용 확대 또는 cost-gate 개선 중 하나를 선택해 다음 실행 계획을 세워주세요. +``` + +수익성 개선을 먼저 하려면: + +```text +$ralph pred60 official 200k 예측 CSV 기반으로 score/filter/비용 민감도 개선안을 개발하고 25bp cost gate 재검증까지 진행해주세요. +``` + +수익성과 무관하게 1M 연구용 학습을 진행하려면: + +```text +$ralph pred60 official 1M을 sample_random으로 tokenizer부터 predictor, holdout 그래프, cost gate까지 진행해주세요. 각 단계별 commit 해주세요. +``` diff --git a/docs/stom_kronos_stat_dashboard_plan.md b/docs/stom_kronos_stat_dashboard_plan.md new file mode 100644 index 000000000..703a7bdcc --- /dev/null +++ b/docs/stom_kronos_stat_dashboard_plan.md @@ -0,0 +1,80 @@ +# STOM official 200k 종목별/전체 통계 대시보드 계획 + +작성일: 2026-05-10 + +## 추천 방식 기록 + +이번 작업은 이전에 추천한 가장 성숙한 방식으로 진행한다. + +```text +1. deep-interview 성격의 요구사항 정리 +2. autopilot 방식의 계획 -> 구현 -> 검증 -> 코드 리뷰 +3. 단계별 한글 commit +``` + +## 현재까지 완료된 상위 계획 + +| 큰 단계 | 상태 | 설명 | +| --- | --- | --- | +| Kronos 공식 200k 학습 | 완료 | tokenizer 200k + predictor 200k 완료 | +| holdout 예측/그래프 CSV | 완료 | official200k walk-forward CSV 생성 | +| cost gate 보고서 | 완료 | 25bp gate 실패, 확대 보류 | +| 통계 대시보드 고도화 | 진행 중 | 이번 작업 | + +## 이번 작업의 목표 + +기존 `/stom` 대시보드는 개별 window 실제값/예측값, Top-K, Qlib/filter artifact 중심이다. 이번에는 사용자가 바로 모델 품질을 이해할 수 있도록 다음을 추가한다. + +1. 전체 요약 metric 확장 +2. 종목별 MAE / RMSE / MAPE / 방향 정확도 순위 +3. 종목별 평균 예측 등락률과 실제 등락률 +4. 오차 분포 histogram +5. pred_return vs actual_return scatter +6. 종목별 MAPE/방향 정확도 heatmap +7. 전체 데이터 기준 best/worst symbol 테이블 +8. 대시보드 API와 frontend UI 추가 + +## 개발 계획 + +| 단계 | 내용 | 상태 | +| --- | --- | --- | +| A | 요구사항/계획 문서화 | 진행 중 | +| B | backend 통계 계산 함수와 chart JSON 추가 | 예정 | +| C | Flask API `/api/stom/diagnostics` 추가 | 예정 | +| D | `/stom` frontend 통계 섹션 추가 | 예정 | +| E | 단위/API 테스트 추가 | 예정 | +| F | code-review/검증/최종 보고 | 예정 | + +## 완료 기준 + +- 선택한 예측 CSV로 종목별 통계 API가 동작한다. +- dashboard에서 버튼 클릭으로 전체 통계, 표, histogram/scatter/heatmap이 표시된다. +- 테스트가 통과한다. +- 공식 200k CSV 기준 API smoke가 성공한다. + +## 진행 기록 - frontend/API 연결 완료 + +- `/api/stom/diagnostics` backend API 추가 완료. +- `/stom` 화면에 `Model Diagnostics: 전체/종목별 통계` 섹션 추가 완료. +- 표시 항목: + - 전체 rows/windows/symbols/sessions/periods + - MAE/RMSE/MAPE/direction accuracy + - 종목별 summary table + - best/worst symbol table + - 전체 오차 histogram + - pred_return vs actual_return scatter + - symbol metric heatmap +- official 200k CSV smoke: + - symbols = 334 + - symbol_metric_count = 334 + - max_symbols=20 결과 20개 row 반환 + - chart keys = error_distribution, return_scatter, symbol_heatmap + +## 최종 검증 기록 + +- `git diff --check` 통과. +- 관련 테스트 16개 통과, Plotly FutureWarning 2개만 존재. +- compileall 통과. +- `/stom` page status=200, `Model Diagnostics` 포함 확인. +- official 200k diagnostics API status=200. +- official 200k 기준 symbols=334, symbol_metric_count=334, max_symbols=20 반환 확인. diff --git a/docs/stom_kronos_stat_dashboard_review.md b/docs/stom_kronos_stat_dashboard_review.md new file mode 100644 index 000000000..e0c07b90f --- /dev/null +++ b/docs/stom_kronos_stat_dashboard_review.md @@ -0,0 +1,56 @@ +# STOM 통계 대시보드 Code Review + +작성일: 2026-05-10 +범위: `a488b0e..HEAD` 중 이번 통계 대시보드 관련 변경 + +## Verdict + +```text +Recommendation: APPROVE +Architectural status: CLEAR +``` + +## 검토 파일 + +- `docs/stom_kronos_stat_dashboard_plan.md` +- `webui/stom_dashboard.py` +- `webui/app.py` +- `webui/templates/stom_dashboard.html` +- `tests/test_stom_dashboard_helpers.py` + +## 확인한 품질 항목 + +| 항목 | 결과 | +| --- | --- | +| API 경로 안전성 | 기존 `_safe_path_in_dirs` / `load_prediction_frame` 경로 검증 재사용 | +| 큰 CSV 처리 | 서버에서 pandas 집계 후 제한된 symbol rows 반환 | +| Chart payload | Plotly JSON 3종 반환 | +| XSS 표면 | 새 diagnostics table의 symbol 출력은 `escapeHtml` 적용 | +| 테스트 | helper/API/official CSV smoke 통과 | +| 범위 관리 | 새 학습, DB 변경, 외부 연동 없음 | + +## 검증 명령 + +```powershell +git diff --check +$env:PYTEST_DISABLE_PLUGIN_AUTOLOAD='1'; C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_dashboard_helpers.py tests\test_stom_qlib_pipeline.py tests\test_stom_filter_gate.py -q +C:\Python\64\Python3119\python.exe -m compileall webui\app.py webui\stom_dashboard.py tests\test_stom_dashboard_helpers.py finetune\search_stom_1s_filters.py +``` + +결과: + +```text +16 passed, 2 warnings +compileall success +official200k diagnostics smoke status=200 +symbols=334 +symbol_metric_count=334 +summary_rows=20 +chart_keys=error_distribution, return_scatter, symbol_heatmap +``` + +## 남은 주의점 + +- 기존 대시보드 일부 다른 table은 여전히 innerHTML 기반이며, 이번 변경 범위에서는 새 diagnostics 출력만 escape 처리했다. +- Plotly warning은 upstream pandas/plotly FutureWarning이며 기능 실패가 아니다. +- 공식 200k 결과는 cost gate 실패 상태이므로 이 화면은 분석용이지 매매 승인 화면이 아니다. diff --git a/docs/stom_liquidity_recon_2026-05-30.md b/docs/stom_liquidity_recon_2026-05-30.md new file mode 100644 index 000000000..1ccc794fb --- /dev/null +++ b/docs/stom_liquidity_recon_2026-05-30.md @@ -0,0 +1,104 @@ +# 실험 ② — 초당 거래대금 정합 재구성 (용량 정직화, NOT 알파) + +- 작성일: **2026-05-30 KST** / 브랜치: `feature/stom-rl-lab` +- 상위: `docs/stom_data_layer_assessment_2026-05-30.md`(실험 목록), `docs/stom_rl_liquidity_slippage_2026-05-29.md`(Page C 원본) +- 구현: `stom_rl/liquidity_recon.py`(순수함수+DB추출기+CLI) + 테스트 `tests/test_stom_rl_liquidity_recon.py`(11개) / 산출물 `.omx/artifacts/liquidity_recon/summary.json` +- 대상: 시초 갭상승 `ts_imb` — **RULE strategy, NOT reinforcement learning.** 알파 아님, 용량/슬리피지 측정의 정직화일 뿐. 모든 양수치 in-sample / triggered-subset / 라이브 없음 / L2 없음 — 기대 실거래 수익 아님. + +--- + +## 0. 한 줄 결론 + +**Page C의 낙관-편향은 실재했고, 생각보다 컸다 — 용량 헤드룸이 약 45배 과대평가였다.** Page C가 분모로 쓴 진입봉 `초당거래대금`(entry_sec_amount, median 623.5백만원 = 문서값 622 ✓)은 정합 1초 흐름 대비 **median 49×**(mean 72×, p90 125×) 부풀어 있었다(KRX 09:00 단일가 동시호가 누적이 진입봉에 통째로 섞임 — 이미 청산된 동시호가는 개장 후 시장가 주문이 참여할 수 없는 유동성이라 분모로 쓰면 안 됨). 정합 흐름(`초당매수금액+초당매도금액`)으로 다시 계산하면: + +- **median 1초 거래대금: 6.2억 → 0.136억(1,360만원)** (49× 과대) +- **1x 도달 계좌(중앙값): ~62억 → ~1.36억원** (약 **−98%**) +- **1억 계좌 median participation: 1.6% → 73.5%**, feasible(≤1x) **100% → 59%** + +**평결은 "조건부 PASS로 강등".** 슬리피지-조정 기대값은 전 계좌서 양수 유지(1억 +0.58~+0.71%)지만, "1억 계좌 100% feasible·수십억까지 여유"라는 Page C 결론은 **거짓이었다.** 정직한 편안 구간은 **1천만~5천만원**, 1억은 **marginal**, 5억 이상은 **유동성 binding**. 예상대로 결과는 "더 보수적"이지 "새 알파"가 아니다 — 실험 목적(자기기만 제거) 달성. + +--- + +## 1. 편향의 정체 (소스로 확인) + +`gap_up_backtest.py`: 진입 = 세션창 첫 행(≈첫 등락율≥2% 행, gap-up이라 09:00 동시호가 직후), 거기서 `초당거래대금`(entry_sec_amount)을 읽음. KRX 09:00은 **단일가 동시호가**라, 그 누적 체결대금이 진입봉의 `초당거래대금`(= `당일거래대금`의 초당 증분, 백만원)에 통째로 들어간다. 반면 `초당매수금액+초당매도금액`은 **누적 아닌 정합 1초 흐름**(연속거래봉에서 `초당거래대금×1e6`과 비율 ≈1.0, 실데이터 246종목 확인)이라 깨끗하다. + +**측정된 편향 (ts_imb, 진입봉 old vs 정합 clean):** + +| 지표 | 값 | +|---|---:| +| inflation median | **49.1×** | +| inflation mean | 72.1× | +| inflation p90 | 125.2× | +| median old denom (Page C) | 623,500,000원 (= 문서 622백만원 ✓) | +| median clean denom | 13,596,310원 | +| n (ts_imb) | 5,175 (clean 5,173 / 너무 얇아 제외 2) | + +→ 동시호가 누적이 *전형적으로* 연속거래 한 초의 ~49배. 이걸 분모로 쓰면 participation이 49배 작아 보여 용량이 그만큼 과대. + +> 정정 기록: 본 작업 초안에서 한때 median 1.4×·5.0×라는 *과소* 추정을 적었으나, 이는 잘못된 join 스크립트 탓이었다. 결정론적 산출물(`summary.json`)의 권위 수치는 **median 49.1×**이고, median old denom 623.5백만원이 Page C 문서값 622와 일치해 동일 분모 비교임이 확인된다. + +--- + +## 2. 정합 보정 후 용량 (ts_imb N=5,173, full universe) + +정합 denom = 진입 직후 첫 60초 연속거래봉의 `(초당매수금액+초당매도금액)` median(동시호가 행 skip, 양수만). gross 0.98%, base 23bp, slip = coef·√participation (coef 무보정 가정 → sweep). exp = `gross − (base+slip)/100`. + +| 계좌 | 1회 주문(f=10%) | median participation | feasible(≤1x) | strict(≤0.1x) | slip-adj exp (coef 5/10/20bp) | +|---|---:|---:|---:|---:|---:| +| 1,000만 | 100만 | **7.4%** | 98% | 59% | +0.736 / +0.723 / +0.696 | +| 5,000만 | 500만 | **36.8%** | 78% | 17% | +0.720 / +0.689 / +0.629 | +| 1억 | 1,000만 | **73.5%** | 59% | 7% | +0.707 / +0.664 / +0.578 | +| 5억 | 5,000만 | **367.7%** | 17% | 0.3% | +0.654 / +0.558 / +0.366 | +| 10억 | 1억 | **735.5%** | 7% | 0.02% | +0.614 / +0.479 / +0.208 | + +- **median 정합 1초 거래대금 = 1,360만원** (p10 = 272만원 — 얇은 트레이드). +- **1x 도달 계좌(median) = 1.36억원** (Page C ~62억에서 **−98%**). +- **정직한 용량 한도**: 1천만~5천만 편안(feasible 78~98%), **1억 marginal**(median 주문이 한 초 흐름의 73%, feasible 59% — 41%는 멀티-초 분할체결 필요), 5억↑ binding(대부분 트레이드가 한 초 흐름 초과). + +--- + +## 3. Page C 대비 무엇이 바뀌나 (정직한 정정) + +| 항목 | Page C (편향) | 실험 ② (정합) | 변화 | +|---|---:|---:|---| +| median 1초 거래대금 | ~6.2억 | **1,360만** | denom 49× 과대 | +| 1억 계좌 median participation | 1.6% | **73.5%** | ~46배 ↑ | +| 1억 계좌 feasible(≤1x) | 100% | **59%** | **−41%p** | +| 1x 도달 계좌 (median) | ~62억 | **~1.36억** | **−98%** | +| 1억 계좌 slip-adj exp(20bp) | +0.725% | **+0.578%** | −0.15%p (여전히 양수) | +| **유동성 평결** | PASS(수십억 여유) | **조건부 PASS(1천만~5천만 편안, 1억 marginal)** | 강등 | + +**핵심: 기대값은 전 계좌 양수로 살아남지만, Page C의 "용량 헤드룸"은 거의 신화였다.** "수십억 계좌까지 확장 가능"(§2.1)은 **철회**. 정직한 상한은 median 기준 ~1.36억, p10(얇은 트레이드) 기준 ~2,720만. participation 교차검증: Page C 1.6% × 46 ≈ 73.5% ✓ (inflation과 정합). + +--- + +## 4. 정직성 캐비엇 + +1. **slippage 모델이 participation≫1에서 과소평가**: √-impact는 한 초 흐름 내 가정. 5억·10억은 median participation 3.7×·7.4×라 실제론 호가를 더 깊게 긁어 슬리피지가 표보다 큼 → 그 행의 +0.37%·+0.21%는 **낙관적 상한**. (1억 이하는 participation<1이라 유효.) +2. `초당매수+초당매도`를 정합 흐름으로 본 가정은 연속거래봉서 `초당거래대금×1e6`과 ≈1.0 일치(교차검증). 동시호가 매칭분은 어느 1초 컬럼에도 안 잡히므로 개장 *직후* 실제 가용 유동성은 정합치보다 약간 더 클 수 있음(=보수적 방향). +3. clean proxy는 양수 초만 median → "활성 초" 유동성. 죽은 초(거래 0) 다수 → 실제 분당 누적 가용량은 더 크나, 단일 시장가 주문의 즉시 체결 한도로는 본 측정이 맞다. +4. 여전히 **triggered-subset DB** 아침 구간 — 시장 전체 일반화 미입증. 큐포지션·부분체결 데이터 없음. +5. **알파 아님.** gross 0.98%는 룰의 드리프트(타 문서 확정), 본 실험은 그 위 비용/용량 측정. + +--- + +## 5. 재현 + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.liquidity_recon # full universe 재구성+용량 +py -3.11 -m pytest tests/test_stom_rl_liquidity_recon.py -q # 11 passed +``` +산출: `.omx/artifacts/liquidity_recon/summary.json`. 핵심(2026-05-30): inflation median 49.1×, median clean 1,360만원, 1억 계좌 participation 73.5%/feasible 59%, 전 계좌 slip-adj exp 양수. + +--- + +## 6. 실험 트랙 갱신 (데이터 레이어 평가 §4) + +| # | 실험 | 상태 | +|---|---|---| +| **② 초당흐름 재구성 (용량 정직화)** | ✅ **완료 — 편향 median 49× 실재, 용량 −98% 정정, 평결 조건부 PASS로 강등(1억 marginal)** | +| ③ SL예측 선행 분류기 | ⬜ 다음(싼 게이트) | +| ① skip-gate (유일한 미검정 알파-인접) | ⬜ ③ 통과 시 | +| ④ 상태조건 청산 | ⬜ ③ 통과 시 | diff --git a/docs/stom_midpoint_direction_check_2026-06-05.md b/docs/stom_midpoint_direction_check_2026-06-05.md new file mode 100644 index 000000000..c8cdcb53e --- /dev/null +++ b/docs/stom_midpoint_direction_check_2026-06-05.md @@ -0,0 +1,139 @@ +# STOM/Kronos Midpoint Direction Check — 2026-06-05 + +## Purpose + +This document is a midpoint direction check for the opening 30m STOM/Kronos trading research track. It consolidates current progress, evidence quality, blockers, expected path to 100%, and the next recommended LazyCodex/OMO command sequence. + +This is a research and dashboard evidence document. It is not a live-trading approval, not broker readiness, not a profit guarantee, and not a claim that reinforcement learning is currently successful. + +## Current Direction + +The correct direction remains: + +1. Keep `ts_imb` as the main RULE baseline. +2. Treat the opening 30m rule/meta-label filter as the nearest practical candidate track. +3. Use participant/supply-demand fields only as proxy evidence. +4. Use orderbook/호가, 체결강도, overheat, and upper-wick features as hypotheses that must pass controls and ablations. +5. Do not promote PPO/DQN/RL until the simpler RULE/meta-label filter beats baselines and negative controls. +6. Keep the dashboard as a read-only evidence viewer, not as proof of profitability. + +## Latest Evidence Snapshot + +| Field | Value | +|---|---:| +| Latest realdata run | `opening_30m_rule_filter_realdata_oos_2026_06_05` | +| Verdict | `NO-GO_CONTROL` | +| Split hash | `37664423068ddeca` | +| Cost assumption | `23bp` | +| OOS net return | `+3.576084196458389%` | +| Validation net return | `-1.6100862564692298%` | +| OOS TAKE count | `2` | +| Minimum OOS TAKE count | `3` | +| Max drawdown | `1.2345662100456654%` | +| Sample scope | bounded real tick DB sample, not full-universe validation | + +## Why The Current Result Is Not Yet Good Enough + +| Blocker | Interpretation | Required Response | +|---|---|---| +| `insufficient_oos_take_trades` | OOS sample has only 2 TAKE trades vs minimum 3 | Wider preregistered sample required | +| `failed_baseline:buy_and_hold` | Filter matched buy-and-hold, so no incremental edge | Must beat buy-and-hold after 23bp | +| `failed_baseline:ts_imb_rule` | Filter matched `ts_imb RULE baseline` | Must beat the base RULE baseline | +| `failed_controls` | Shuffled labels/time shuffle did not fail cleanly | Need stronger negative-control evidence | +| `failed_ablations` | Orderbook/overheat/proxy feature contribution is unstable | Need feature attribution on larger sample | + +The positive OOS return is not enough. The filter must beat baseline, cost, control, and ablation gates before it can be considered a serious candidate. + +## Current Page / Area Progress + +| Page/Area | Progress | Status | +|---|---:|---| +| Official Dashboard Shell | 70% | Integrated, but status/doc consolidation remains | +| Trading/RL Evidence Tab | 84% | Rule-filter evidence, controls, ablations visible | +| Participant Proxy Card | 82% | RULE/RL label blocker fixed; proxy validity unproven | +| Rule Filter Evidence Tables | 85% | Controls, ablations, proxy availability, orderbook persistence rows available | +| Orderbook/호가 Evidence | 60% | Display exists; alpha contribution not proven | +| Overheat/윗꼬리 Feature | 55% | Included in ablation set; contribution not proven | +| Docs/Research Page | 70% | Result docs exist; more roadmap/status linking needed | +| System Health/Data Readiness | 58% | Read-only DB preflight exists; UI readiness status partial | +| RL Model Performance | 20% | PPO/DQN/RL remains research-only | +| Rule/meta-label Filter | 60% | Realdata OOS complete, but `NO-GO_CONTROL` | +| Live/Broker/Order Execution | 0% | Intentionally out of scope | + +## Expected Path To 100% + +| Stage | Goal | Current | Estimated Time | +|---:|---|---:|---:| +| 1 | Dashboard evidence consolidation | 70–85% | 0.5–1 day | +| 2 | Current `NO-GO_CONTROL` blocker analysis | 100% | completed | +| 3 | Wider realdata OOS preregistration | 0% | 1–2 hours | +| 4 | Wider bounded realdata OOS execution | 0% | 2–6 hours | +| 5 | Baseline/control/ablation revalidation | 0% | 2–4 hours | +| 6 | Feature improvement: orderbook, 체결강도, overheat, upper-wick, proxy | 30% | 1–3 days | +| 7 | Rule/meta-label filter iteration | 30% | 2–5 days | +| 8 | PPO/DQN/RL reconsideration | 20% | 2–5 days | +| 9 | Dashboard history/performance management | 65% | 1–2 days | +| 10 | Final research report and operating decision | 50% | 0.5–1 day | + +Practical estimate: + +| Target | Estimate | +|---|---:| +| Minimum research-complete loop | 4–7 days | +| More credible OOS validation | 1–2 weeks | +| RL/PPO/DQN revalidation included | 2–3 weeks | +| Pre-live research quality | 3–6+ weeks | + +## Can We Run All Remaining Steps At Once? + +No. Running everything at once is technically possible in an automation sense, but it is not valid research practice here. + +The reason is dependency order: + +1. The wider OOS experiment must be preregistered before execution. +2. OOS results must not be used to tune thresholds or features. +3. PPO/DQN should not be expanded until the simpler rule/meta-label filter proves baseline/control/ablation stability. +4. Dashboard enhancements should follow evidence shape, not invent success narratives ahead of validation. + +Safe parallelization is possible only inside a stage: + +| Can Parallelize? | Work | +|---|---| +| Yes | Dashboard display polish and documentation linking | +| Yes | Artifact readers / evidence table QA | +| Yes | Post-run analysis of controls and ablations | +| No | Preregistration → OOS execution → interpretation | +| No | Rule-filter validation → PPO/DQN expansion | +| No | Failed control diagnosis → live-readiness work | + +## Recommended Command Sequence + +### Next command — highest priority + +```text +$ulw-plan Create a preregistered wider bounded realdata OOS rule-filter experiment plan based on the blocker analysis, without tuning on split 37664423068ddeca. +``` + +### Then execute the generated plan + +```text +$start-work .omo/plans/.md +``` + +### After wider OOS result exists + +If the wider run is still `NO-GO_*`: + +```text +$ulw-plan Analyze wider OOS failure causes and decide whether to simplify features, revise proxy definitions, or stop the RL expansion path. +``` + +If the wider run passes baseline/control/ablation gates: + +```text +$ulw-plan Plan PPO/DQN opening 30m RL revalidation using the passed rule/meta-label evidence as baseline, with no OOS tuning and 23bp cost. +``` + +## Decision + +Continue, but continue sequentially and evidence-first. The project direction is still coherent. The dashboard and evidence system are ahead of the model. The model track must now earn promotion through a wider preregistered OOS experiment, not through dashboard visuals or RL complexity. diff --git a/docs/stom_opening_30m_rl_dashboard_handoff_2026-06-11.md b/docs/stom_opening_30m_rl_dashboard_handoff_2026-06-11.md new file mode 100644 index 000000000..a08231a08 --- /dev/null +++ b/docs/stom_opening_30m_rl_dashboard_handoff_2026-06-11.md @@ -0,0 +1,228 @@ +# STOM 시초 30분 RL/호가/대시보드 핸드오프 — 2026-06-11 + +## 0. 목적 + +이 문서는 2026-06-01 이후 진행된 STOM 시초 30분 tick/orderbook/orderbook-imbalance 기반 연구와 공식 대시보드 리모델링 작업을 다음 작업자가 바로 이어받기 위한 핸드오프 문서다. + +핵심 목표는 다음이다. + +```text +시초 30분 tick/orderbook 기반 연구 검증 시스템을 구축하고, +RULE baseline(ts_imb), PPO/DQN/orderbook RL 후보, participant proxy, +호가 imbalance, 과열/윗꼬리 feature를 OOS, negative control, +feature ablation으로 검증한 뒤, +공식 대시보드에서 누적 수익곡선, 시간별 거래, 성과 이력, +실패 원인, GO/NO-GO 판정을 확인할 수 있게 만든다. +``` + +범위는 **연구 검증 시스템**이다. 실거래, broker 연동, 수익 보장, live-ready 주장은 제외한다. + +--- + +## 1. 현재 결론 요약 + +| 항목 | 현재 판단 | +|---|---| +| 시스템 개발 방향 | 맞다. 대시보드는 연구 이력/성과/실패를 노출하는 evidence viewer로 가야 한다. | +| 대시보드 리모델링 | 상당 부분 진행됨. 공식 대시보드 방향으로 통합 중이며 RL/Rule evidence를 보여주는 기반이 있다. | +| 강화학습 연구 | 가능하지만 아직 수익 모델은 아니다. PPO/DQN/orderbook RL은 research-only 후보로 유지해야 한다. | +| 가장 중요한 기준선 | `ts_imb` 시초 30분 RULE baseline. 절대 RL로 부르면 안 된다. | +| 최신 실험 verdict | `NO-GO_CONTROL` | +| 비용 가정 | `23bp` round trip | +| 실거래 준비도 | 0%. 현재는 local backtest/research/dashboard evidence 전용이다. | + +--- + +## 2. 반드시 지켜야 할 연구/표현 규칙 + +| 규칙 | 이유 | +|---|---| +| `ts_imb`는 RULE baseline이다. RL이 아니다. | 수익곡선이 rule에서 나온 경우 RL 성과로 오해하면 안 된다. | +| `NO-GO_CONTROL`을 숨기지 않는다. | 실패를 숨기면 다음 실험이 과최적화된다. | +| 비용은 기본 `23bp`로 명시한다. | 초단타/시초 전략에서는 비용이 성과를 크게 바꾼다. | +| dashboard는 read-only evidence viewer다. | 실거래/주문/브로커 기능을 넣으면 연구 검증 경계가 무너진다. | +| leading-zero 종목코드를 보존한다. | `000250` 같은 코드를 int로 바꾸면 데이터가 깨진다. | +| OOS, negative control, no-trade/RULE baseline 비교 없이 alpha를 주장하지 않는다. | 과최적화/우연 성과를 걸러내기 위함이다. | +| 호가/orderbook RL은 research-only다. | 현재 성과 검증이 충분하지 않다. | + +--- + +## 3. 지금까지 구축된 주요 축 + +### 3.1 공식 대시보드 리모델링 + +| 구성 | 상태 | 메모 | +|---|---|---| +| Flask backend | 진행됨 | `webui/app.py`, `webui/rl_dashboard*.py` 계열 | +| Svelte dashboard | 진행됨 | `webui/v2_src/**`; 내부 경로는 v2지만 사용자-facing 표현은 공식 대시보드 방향 | +| RL/Trading 탭 | 진행됨 | `webui/v2_src/src/tabs/RLTradingTab.svelte`, `rlTrading/` components | +| Rule/RL 라벨 분리 | 개선됨 | `opening_30m_rule_filter`는 RL experiment가 아니라 RULE/meta-label evidence로 표시해야 함 | +| table/API loader | 진행됨 | `rule_filter_controls`, `rule_filter_ablations`, `rule_filter_proxy_availability`, `rule_filter_orderbook_persistence` 확인됨 | +| dist build | 생성됨 | `webui/static/v2/dist/**`는 generated/optional commit group으로 분리 | + +### 3.2 시초 30분 RULE/RL 연구 + +| 연구 | 파일/영역 | 현재 의미 | +|---|---|---| +| RULE baseline | `ts_imb` 계열, rule-filter artifacts | 가장 중요한 비교 기준선 | +| Rule-filter | `stom_rl/opening_30m_rule_filter_*.py` | OOS, control, ablation, gate, lifecycle 관리 | +| PPO/DQN 후보 | `stom_rl/opening_30m_rl_*`, SB3/orderbook files | research-only 후보. baseline을 이겨야 승격 가능 | +| Orderbook RL | `stom_rl/orderbook_rl_env.py`, `orderbook_sb3_*` | 호가/imbalance 상태공간 실험 기반 | +| Participant proxy | `participant_pressure_*`, market participant studies | 외국인/기관/프로그램 직접 식별 대신 proxy feature 연구 | +| 과열/윗꼬리 feature | rule-filter/participant proxy와 결합 대상 | 거래대금 폭증 후 지속성/고점 형성 가설 검증 필요 | + +--- + +## 4. 최신 검증/성과 상태 + +가장 최근 커밋 위생/검증 작업에서 확인된 핵심 증거는 다음 위치에 있다. + +| 증거 | 경로 | +|---|---| +| 최종 상태 요약 | `.omo/evidence/commit-hygiene-jun1/final-status.md` | +| 커밋 그룹 문서 | `.omo/evidence/commit-hygiene-jun1/commit-groups.md` | +| staging inventory | `.omo/evidence/commit-hygiene-jun1/staging-inventory.md` | +| docs encoding report | `.omo/evidence/commit-hygiene-jun1/docs-encoding-report.md` | +| LOC report | `.omo/evidence/commit-hygiene-jun1/loc-report.md` | +| dashboard loader/API evidence | `.omo/evidence/commit-hygiene-jun1/f3-dashboard-loader.md` | + +검증 결과 요약: + +| 검증 | 결과 | +|---|---| +| Rule-filter policy/CLI/dashboard tests | 17 passed | +| Dashboard API/source/route/dist tests | 25 passed | +| Orderbook env/SB3/persistence tests | 16 passed | +| Dashboard table regression tests | 18 passed | +| Svelte build/check | 0 errors, 4 warnings | +| Dashboard loader/API tables | 4/4 tables 200 OK | +| staged generated scan | staged files 0개 | +| `git diff --cached --check` | PASS, no staged files | + +중요: 위 검증은 연구/대시보드 시스템 검증이다. **수익 가능 모델 검증 통과가 아니다.** 최신 trading verdict는 여전히 `NO-GO_CONTROL`이다. + +--- + +## 5. 현재 dirty worktree 상태와 커밋 전략 + +현재 worktree에는 대량의 source/docs/tests/generated 변경이 섞여 있다. 무조건 bulk commit하면 안 된다. + +권장 commit group은 다음 순서다. + +| 순서 | 그룹 | 설명 | +|---:|---|---| +| 1 | guardrails/knowledge | `AGENTS.md` 계열 규칙 문서 | +| 2 | dashboard source/tests | 공식 대시보드와 evidence viewer 코드/테스트 | +| 3 | STOM research source/tests | opening 30m RL/rule/orderbook/participant proxy 연구 코드와 테스트 | +| 4 | research docs | readable docs만 포함. mojibake 문서는 제외 | +| 5 | optional dist | `webui/static/v2/dist/**`만 별도. deployment 필요 시에만 | + +현재 이 핸드오프 커밋은 **문서 단독 커밋**으로 남긴다. 기존 대량 변경은 의도적으로 stage하지 않는다. + +--- + +## 6. 알려진 위험/미완료 항목 + +| 항목 | 상태 | 다음 조치 | +|---|---|---| +| 실제 수익 모델 | 미완료 | baseline 대비 OOS/negative control/ablation 통과 필요 | +| `NO-GO_CONTROL` | 유지 | 숨기지 말고 실패 원인 분석 대상으로 관리 | +| 대형 Python 파일 | 8개 이상 250 pure LOC 초과 | split 또는 legacy-large rationale 필요 | +| 깨진 한글 문서 | 3개 hold-out | 복구 전 commit 제외 | +| generated/session/cache | 많음 | `.omo`, `.omc`, `.codegraph`, `webui/rl_runs`, `webui/stom_predictions`는 기본 제외 | +| dist assets | 생성물 | source commit과 섞지 말고 optional dist commit으로 분리 | +| 대시보드 시각화 | 개선 필요 | 누적 수익곡선, 시간별 거래, 실패 원인, feature ablation 비교 강화 | +| RL 성과 | 낮음/불확실 | PPO/DQN/orderbook RL은 RULE baseline을 이길 때까지 research-only | + +--- + +## 7. 다음 작업자가 바로 볼 파일 + +| 목적 | 파일 | +|---|---| +| 현재 방향성 | `AGENTS.md`, `docs/AGENTS.md`, `stom_rl/AGENTS.md`, `webui/AGENTS.md` | +| 대시보드 backend | `webui/rl_dashboard.py`, `webui/rl_dashboard_opening_tables.py`, `webui/app.py` | +| 대시보드 frontend | `webui/v2_src/src/tabs/RLTradingTab.svelte`, `webui/v2_src/src/tabs/rlTrading/*` | +| Rule-filter CLI | `stom_rl/opening_30m_rule_filter_cli.py` | +| Rule-filter policy/gate | `stom_rl/opening_30m_rule_filter_policy.py`, `stom_rl/opening_30m_rule_filter_gate.py` | +| Orderbook RL | `stom_rl/orderbook_rl_env.py`, `stom_rl/orderbook_sb3_adapter.py`, `stom_rl/orderbook_sb3_smoke.py` | +| Participant proxy | `stom_rl/participant_pressure_contract.py`, `stom_rl/participant_pressure_features.py`, `stom_rl/market_participant_studies.py` | +| 핵심 tests | `tests/test_stom_rl_opening_rule_filter_*.py`, `tests/test_stom_rl_dashboard_*.py`, `tests/test_stom_rl_orderbook_*.py` | + +--- + +## 8. 다음 권장 실행 순서 + +### Step 1. worktree 정리/commit 실행 계획 확정 + +먼저 `.omo/evidence/commit-hygiene-jun1/commit-groups.md`를 기준으로 실제 commit을 나눈다. + +추천 명령: + +```text +$ulw-plan commit-groups.md 기준으로 generated/session/cache 제외, mojibake docs 제외, 대형 Python 파일 정책을 반영하여 안전한 실제 commit 실행 계획 수립 +``` + +### Step 2. 대시보드 연구 UI 완성 + +누적 수익곡선, 시간별 거래, feature ablation, negative control, 실패 원인을 한 화면에서 비교할 수 있게 만든다. + +추천 명령: + +```text +$ulw-loop 공식 대시보드 RL/Trading 탭을 연구 검증 화면으로 완성. 누적 수익곡선, 시간별 거래, OOS split, negative control, feature ablation, rule/RL 구분, 실패 원인, GO/NO-GO 판정을 확인할 수 있게 개선 +``` + +### Step 3. 모델 연구 루프 지속 + +RULE baseline을 먼저 고정하고 RL은 baseline을 이길 때만 승격한다. + +추천 명령: + +```text +$ulw-loop 시초 30분 tick/orderbook/orderbook-imbalance 기반 STOM 강화학습 연구를 계속 진행. RULE baseline(ts_imb), PPO/DQN/orderbook RL 후보, participant proxy, 호가 imbalance, 과열/윗꼬리 feature를 OOS, negative control, feature ablation으로 검증하고 GO/NO-GO 판정을 누적 기록 +``` + +--- + +## 9. 바로 실행할 검증 명령 + +```powershell +py -3.11 -m pytest tests/test_stom_rl_opening_rule_filter_policy.py tests/test_stom_rl_opening_rule_filter_cli.py tests/test_stom_rl_opening_rule_filter_dashboard.py -q +py -3.11 -m pytest tests/test_stom_rl_dashboard_api.py tests/test_stom_rl_dashboard_tab.py tests/test_v2_route.py tests/test_v2_dist_marker.py -q +py -3.11 -m pytest tests/test_stom_rl_orderbook_env.py tests/test_stom_rl_orderbook_sb3.py tests/test_stom_rl_orderbook_persistence.py -q +cd webui/v2_src +npm run build +npm run check --if-present +cd ../.. +git diff --check +git diff --cached --check +``` + +--- + +## 10. 최종 목표 정의 + +최종 목표는 다음 세 가지를 동시에 만족하는 것이다. + +| 목표 | 판정 기준 | +|---|---| +| 연구 시스템 | 실험 이력, feature, baseline, control, OOS, 실패 원인을 재현 가능하게 기록한다. | +| 대시보드 | 누적 수익곡선/시간별 거래/성과 이력/feature ablation/GO-NO-GO를 read-only로 확인한다. | +| 모델 후보 | RULE baseline, no-trade, negative control보다 OOS에서 우월해야 하며 비용 `23bp`와 drawdown gate를 통과해야 한다. | + +그 전까지 모든 PPO/DQN/orderbook RL 결과는 다음으로만 표현한다. + +```text +RL experiment / research-only / not live-ready / not a profit model +``` + +--- + +## 11. 다음 세션 시작 문구 + +다음 세션에서는 아래 문구로 시작하면 된다. + +```text +docs/stom_opening_30m_rl_dashboard_handoff_2026-06-11.md와 .omo/evidence/commit-hygiene-jun1/commit-groups.md를 먼저 읽고, generated/session/cache를 제외한 안전한 commit group 실행 또는 공식 대시보드 RL/Trading 연구 검증 화면 완성을 이어서 진행해주세요. ts_imb는 RULE baseline이고 최신 verdict는 NO-GO_CONTROL, cost는 23bp입니다. 실거래/수익보장/broker 연동은 제외합니다. +``` diff --git a/docs/stom_opening_30m_rl_feature_revalidation_2026-06-04.md b/docs/stom_opening_30m_rl_feature_revalidation_2026-06-04.md new file mode 100644 index 000000000..0956ba24c --- /dev/null +++ b/docs/stom_opening_30m_rl_feature_revalidation_2026-06-04.md @@ -0,0 +1,112 @@ +# Opening 30M RL Feature Revalidation — preregistered dashboard/feature update (2026-06-04) + +## Status + +This track is an `RL EXPERIMENT`, not live-ready, not broker-ready, and not a profit model. +The `ts_imb RULE baseline` remains a RULE baseline and must not be relabeled as RL. +The default cost assumption is `23bp` round trip. + +Current actual model verdict before the new bounded revalidation run remains `NO-GO_BASELINE` from the existing 2026-06-04 OOS candidate validation. The feature revalidation run below is preregistered and must publish its actual verdict after Task12; do not promote a model before that evidence exists. + +## Hypothesis under test + +Add causal opening context features to the opening 30m DQN/PPO candidate path: + +| Feature group | Examples | Missing policy | +|---|---|---| +| `price_volume` | returns, volume, time fraction | required base observation | +| `participant_pressure` | participant proxy pressure, transaction value surge, signed amount persistence | proxy evidence only | +| `orderbook_imbalance` | bid/ask depth imbalance, microprice pressure, spread penalty | fail closed or mark unavailable | +| `orderbook_persistence` | bid depth persistence, OFI, signed flow persistence | fail closed or mark unavailable | +| `overheat_upper_wick` | overheat penalty, upper-wick ratio, pullback reacceleration | penalty/evidence only | +| `optional_investor_flow` | foreign/institution/program net buy if present | missing/None; never zero-filled as truth | + +Participant variables are `proxy evidence`; they do not identify actual foreign, institution, retail, or big-money actors. + +## Canonical ablations + +Required IDs: + +- `full_context` +- `no_participant_pressure` +- `no_orderbook_imbalance` +- `no_orderbook_persistence` +- `no_overheat_upper_wick` +- `minimal_price_volume` +- `shuffled_participant_context` +- `ts_imb_rule_baseline` + +Legacy aliases such as `full`, `no_participant`, `no_orderbook`, and `no_overheat` are accepted only as compatibility input and are normalized before dashboard/gate interpretation. + +## Revalidation command + +```powershell +py -3.11 -m stom_rl.opening_30m_rl_realdata --db _database/stock_tick_back.db --run-id opening_30m_rl_feature_revalidation_smoke --output-dir webui/rl_runs/opening_30m_rl_feature_revalidation --max-tables 10 --max-sessions-per-table 2 --time-start 090000 --time-end 093000 --candidate-algos dqn,ppo --create-split --tiny-train +``` + +Expected artifact root: + +```text +webui/rl_runs/opening_30m_rl_feature_revalidation/opening_30m_rl_feature_revalidation_smoke +``` + +## Dashboard tables to inspect + +| Table alias | Purpose | +|---|---| +| `candidate_lifecycle` | DQN/PPO candidate rows and status | +| `candidate_splits` | frozen train/validation/OOS sessions and split hash | +| `candidate_controls` | no-trade, buy-and-hold, `ts_imb RULE baseline`, random/shuffle controls | +| `candidate_ablations` | canonical feature-ablation rows | +| `candidate_equity_curve` | cumulative equity curve evidence, not a profit claim | +| `candidate_time_buckets` | time-bucket trade result rows | +| `candidate_failure_reasons` | explicit `NO-GO` blockers | +| `proxy_availability` | participant proxy availability and missing proxy columns | +| `orderbook_persistence` | orderbook imbalance/persistence/overheat components | +| `context_feature_sample` | RL observation context feature sample | + +## Promotion rule + +A candidate can only be promoted if it beats no-trade, buy-and-hold, and the `ts_imb RULE baseline` after `23bp`, passes negative controls, and remains stable under feature ablation. Otherwise the dashboard must show `NO-GO`, `NO-GO_BASELINE`, `NO-GO_CONTROL`, `NO-GO_ABLATION`, or `INCONCLUSIVE` visibly. + +## Current interpretation + +The dashboard and feature path are now designed to falsify the RL candidate more clearly. This is not evidence that DQN/PPO is profitable. + +## Actual bounded run result + +The preregistered bounded real-data smoke run completed and the actual verdict is still `NO-GO_BASELINE`. + +| Field | Value | +|---|---| +| Run | `opening_30m_rl_feature_revalidation_smoke` | +| Artifact root | `webui/rl_runs/opening_30m_rl_feature_revalidation/opening_30m_rl_feature_revalidation_smoke` | +| Split hash | `cb46cac3fd20651f` | +| Candidate count | `2` | +| Cost | `23bp` round trip | +| Candidate verdict | `NO-GO_BASELINE` | +| Baseline label | `ts_imb RULE baseline` | + +Dashboard API evidence parsed these table row counts: + +| Table alias | Rows | +|---|---:| +| `candidate_lifecycle` | 2 | +| `candidate_splits` | 18 | +| `candidate_controls` | 6 | +| `candidate_ablations` | 7 | +| `candidate_equity_curve` | 2 | +| `candidate_time_buckets` | 2 | +| `candidate_failure_reasons` | 2 | +| `proxy_availability` | 10 | +| `orderbook_persistence` | 10 | +| `context_feature_sample` | 8 | + +Interpretation: the new context features, ablations, and dashboard tables are now visible and test-covered, but the model did not beat the required baseline gates in this bounded OOS smoke. It remains an `RL EXPERIMENT`, not live-ready and not a profit model. + +Known verification notes: + +- Targeted pytest passed for participant proxy, orderbook persistence, market participant studies, RL context, dashboard APIs, candidate training, ablation, gate, and real-data OOS CLI (`40 passed` after the context-ablation fix). +- The final run artifacts now prove `no_participant_pressure` zeroes appended context features and `shuffled_participant_context` records shuffled participant context metadata. +- `npm --prefix webui/v2_src run build` passed with 0 errors and 4 pre-existing Svelte/CSS warnings in `DocsTab.svelte` and `ForecastWorkbenchTab.svelte`. +- `cmd /c "git diff --check"` passed after normalizing generated dashboard HTML line endings; only Git CRLF conversion warnings remain. diff --git a/docs/stom_opening_30m_rl_oos_candidate_validation_2026-06-04.md b/docs/stom_opening_30m_rl_oos_candidate_validation_2026-06-04.md new file mode 100644 index 000000000..c56363264 --- /dev/null +++ b/docs/stom_opening_30m_rl_oos_candidate_validation_2026-06-04.md @@ -0,0 +1,86 @@ +# STOM Opening 30M RL OOS Candidate Validation - 2026-06-04 + +## Verdict + +`NO-GO_BASELINE` / candidate rejected by OOS baseline gate. + +This is a bounded real tick/orderbook OOS smoke. It trained tiny DQN/PPO candidates and feature-ablation candidates, but the selected candidate failed no-trade and buy-and-hold after the 23bp cost assumption; the OOS-only `ts_imb RULE baseline` was lower than the candidate, but that is insufficient for promotion. Feature ablation also failed because masked variants outperformed the full feature set, indicating instability rather than a robust signal. It is not profitable and not live-ready. + +## Command + +```powershell +py -3.11 -m stom_rl.opening_30m_rl_realdata --db _database/stock_tick_back.db --run-id opening_30m_rl_oos_candidate_smoke --output-dir webui/rl_runs/opening_30m_rl_oos_validation --max-tables 10 --max-sessions-per-table 2 --time-start 090000 --time-end 093000 --candidate-algos dqn,ppo --create-split --tiny-train +``` + +## Artifacts + +- Run directory: `webui/rl_runs/opening_30m_rl_oos_validation/opening_30m_rl_oos_candidate_smoke` +- Summary: `webui/rl_runs/opening_30m_rl_oos_validation/opening_30m_rl_oos_candidate_smoke/opening_30m_rl_workflow_summary.json` +- Candidate lifecycle: `webui/rl_runs/opening_30m_rl_oos_validation/opening_30m_rl_oos_candidate_smoke/opening_candidate_lifecycle.json` +- Split manifest: `webui/rl_runs/opening_30m_rl_oos_validation/opening_30m_rl_oos_candidate_smoke/opening_oos_split_manifest.json` +- Dataset artifact: `webui/rl_runs/opening_30m_rl_oos_validation/opening_30m_rl_oos_candidate_smoke/opening_oos_dataset_artifact.json` +- Evidence log: `.omo/evidence/oos-rl-task-10-smoke.txt` + +## Sample Bounds + +- DB: `_database/stock_tick_back.db` +- Access: read-only bounded smoke +- `max_tables`: 10 +- `max_sessions_per_table`: 2 +- Window: `090000` to `093000` +- Cost: `23bp` +- Split hash: `cb46cac3fd20651f` +- Dataset rows: `18` sampled symbol/sessions + +## Candidate Results + +| Candidate | Status | Model file | Eval log | Validation net % | OOS net % | OOS trades | +|---|---:|---:|---:|---:|---:|---:| +| `dqn_default_seed100` | `trained` | `True` | `True` | `-1.4638` | `-1.7512` | `8` | +| `ppo_default_seed100` | `trained` | `True` | `True` | `-1.4638` | `-2.4550` | `8` | + +## Baseline Gate + +| Baseline | Candidate OOS net % | Baseline net % | Passed | +|---|---:|---:|---:| +| `no_trade` | `-1.7512` | `0.0000` | `False` | +| `buy_and_hold` | `-1.7512` | `7.6032` | `False` | +| `ts_imb_rule` | `-1.7512` | `-2.5109` | `True` | + +## Controls and Ablations + +| Control | Net % | Source | Passed | +|---|---:|---|---:| +| `no_trade` | `0.0000` | `baseline_same_split` | `True` | +| `buy_and_hold` | `7.6032` | `baseline_same_split` | `True` | +| `ts_imb_rule` | `-2.5109` | `baseline_same_split` | `True` | +| `random_policy` | `-0.4899` | `policy_eval_oos` | `True` | +| `label_shuffle` | `-0.4899` | `label_shuffle_eval_oos` | `True` | +| `time_session_shuffle` | `-13.6313` | `time_session_shuffle_eval_oos` | `True` | + +| Ablation | OOS net % | Comparison | Passed | +|---|---:|---|---:| +| `full` | `-2.4550` | `compared_to_full` | `True` | +| `no_participant` | `-2.1999` | `not_applicable_feature_absent` | `True` | +| `no_orderbook` | `-1.7512` | `compared_to_full` | `False` | +| `no_overheat` | `-0.2348` | `compared_to_full` | `False` | +| `minimal_price_volume` | `-3.0613` | `not_applicable_feature_absent` | `True` | + +- Negative controls artifact: `passed`; baseline controls use `baseline_same_split`, and random/label-shuffle/time-session-shuffle controls are evaluated on the frozen OOS split with eval logs. +- Feature ablation artifact: `INCONCLUSIVE` / failed; `no_orderbook` and `no_overheat` outperformed the full feature set, so feature dependence is not stable. `no_participant` is explicitly marked not applicable because participant proxy columns are absent. +- Promotion gate: `NO-GO_BASELINE` with blockers `['failed_ablations', 'failed_baseline:no_trade', 'failed_baseline:buy_and_hold']`. + +## Guardrails + +- `RL EXPERIMENT` +- `not live-ready` +- `ts_imb RULE baseline` +- `23bp` +- no profitability claim +- no broker integration +- no live order path +- failed evidence remains visible as `NO-GO` or `INCONCLUSIVE` + +## Next Decision + +Do not promote the model. The next valid step is to improve hypotheses/features and rerun with the same OOS discipline; no `GO_CANDIDATE` label is allowed until PPO/DQN beats no-trade, buy-and-hold, and the `ts_imb RULE baseline` after 23bp with acceptable MDD and stable feature-ablation evidence. diff --git a/docs/stom_opening_30m_rl_realdata_validation_2026-06-04.md b/docs/stom_opening_30m_rl_realdata_validation_2026-06-04.md new file mode 100644 index 000000000..a8b5079bb --- /dev/null +++ b/docs/stom_opening_30m_rl_realdata_validation_2026-06-04.md @@ -0,0 +1,80 @@ +# STOM Opening 30M RL Realdata Validation - 2026-06-04 + +## Verdict + +`INCONCLUSIVE` + +This is an `RL EXPERIMENT`, not live-ready, not broker-ready, and not a profit model. +The run used the `ts_imb RULE baseline` guardrail and 23bp cost assumption. + +## Exact command + +```powershell +py -3.11 -m stom_rl.opening_30m_rl_realdata --db _database/stock_tick_back.db --run-id opening_30m_rl_realdata_smoke --output-dir webui/rl_runs/opening_30m_rl_realdata_smoke --max-tables 5 --max-sessions-per-table 1 --time-start 090000 --time-end 093000 +``` + +Missing DB negative check: + +```powershell +py -3.11 -m stom_rl.opening_30m_rl_realdata --db _database/missing.db --run-id missing_db --output-dir .omo/evidence/missing_db --max-tables 1 +``` + +## Sample bounds + +| Field | Value | +|---|---:| +| max_tables | 5 | +| max_sessions_per_table | 1 | +| max_rows_per_session | 1800 | +| min_rows_per_session | 4 | +| time_start | `090000` | +| time_end | `093000` | +| cost_bps | 23.0 | + +## Sampled tables/sessions + +| Symbol | Session | Rows | +|---|---|---:| +| `000020` | `20221212` | 446 | +| `000040` | `20230906` | 1525 | +| `000050` | `20220531` | 1309 | +| `000060` | `20221108` | 625 | +| `000070` | `20250602` | 1296 | + +Leading-zero symbols were preserved as strings. + +## Gate status + +| Gate | Status | +|---|---| +| OOS split | missing | +| negative/shuffle controls | missing | +| baseline superiority after 23bp | not proven | +| participant-pressure ablation | failed / missing | +| orderbook-persistence ablation | failed / missing | +| overheat-penalty ablation | failed / missing | +| model_status | `no_model_trained` | +| training_status | `available_not_requested` | + +Blocking reasons: + +- `missing_oos_split` +- `missing_negative_controls` +- `failed_baseline:no_trade` +- `failed_baseline:buy_and_hold` +- `failed_baseline:ts_imb_rule` +- `failed_ablation:orderbook_persistence` +- `failed_ablation:overheat_penalty` +- `failed_ablation:participant_pressure` + +## Dashboard evidence + +- Run summary: `webui/rl_runs/opening_30m_rl_realdata_smoke/opening_30m_rl_workflow_summary.json` +- Adapter summary: `webui/rl_runs/opening_30m_rl_realdata_smoke/realdata_adapter/realdata_adapter_summary.json` +- Validation gate: `webui/rl_runs/opening_30m_rl_realdata_smoke/realdata_validation_gate.json` +- Dashboard helper evidence: `.omo/evidence/realdata-task-8-real-run-dashboard.txt` + +## Next decision + +Do not tune on this smoke result. The next safe step is Task 10 final verification, +then a separate preregistered OOS/control/ablation run if the user wants a larger real-data validation. diff --git a/docs/stom_opening_30m_rl_workflow_2026-06-03.md b/docs/stom_opening_30m_rl_workflow_2026-06-03.md new file mode 100644 index 000000000..025dbb404 --- /dev/null +++ b/docs/stom_opening_30m_rl_workflow_2026-06-03.md @@ -0,0 +1,150 @@ +# STOM Opening 30M RL Workflow preregistration / handoff — 2026-06-03 + +## Verdict boundary + +This document preregisters the opening-30-minute RL workflow as an **RL EXPERIMENT** only. +It is research evidence, **not live-ready**, not broker-ready, and not a usable trading model. + +The `ts_imb RULE baseline` remains the mainline opening gap-up rule baseline. The RL track may only be compared against it; it must not relabel the rule curve as reinforcement learning. + +Current default cost is **23bp** round trip with marketable-fill interpretation where applicable. + +## Hypothesis + +Opening continuation may be better explained by: + +```text +participant proxy pressure ++ orderbook persistence ++ overheat / upper-wick exhaustion ++ cost and slippage +``` + +The model must treat participant data as **proxy evidence** only. It must not claim that it identified actual foreign, institution, retail, or big-money actors. + +## Data bounds + +| Item | Bound | +|---|---| +| Window | 09:00:00–09:30:00 only | +| Decision data | causal rows at or before the decision second | +| Symbols | preserve leading-zero codes such as `000250` | +| Investor-class flow | optional only; if unavailable or delayed, mark missing / not causal | +| Dashboard | read-only evidence viewer | + +The 09:00 auction print must not be treated as a continuous-trading tick unless the source data explicitly supports that interpretation. + +## Split protocol + +- Build deterministic opening episode manifests before training. +- Use chronological train/eval/OOS split metadata. +- Refuse split overlap. +- Do not tune on OOS after seeing outcomes. +- Any GO_CANDIDATE requires OOS evidence plus negative control and feature-ablation checks. + +## Model family + +First model family: + +```text +Stable-Baselines3 DQN +action space: hold / market_buy / market_exit +fixed-entry / exit-first smoke path +``` + +If the local Windows SB3/Torch import is unavailable, the training stage must record `skipped_sb3_unavailable` rather than pretending a model was produced. + +## Required controls + +The workflow is blocked unless all relevant controls are recorded: + +1. `negative control` / shuffled participant context. +2. Feature ablation: full context vs no participant-pressure. +3. Feature ablation: full context vs no orderbook-persistence. +4. Overheat/upper-wick penalty ablation. +5. No-trade, buy-and-hold, and `ts_imb RULE baseline` comparisons. +6. 23bp cost gate. +7. OOS split metadata. + +Failure of controls or cost gate keeps the verdict at **NO-GO**. + +## GO / NO-GO criteria + +The workflow can only become GO_CANDIDATE if all are true: + +1. RL mean return beats no-trade, buy-and-hold, and `ts_imb RULE baseline` after 23bp cost. +2. Negative controls remain NO-GO. +3. Feature ablations prove participant/orderbook context adds incremental value. +4. OOS split exists and has no overlap with train/eval. +5. Drawdown/trade-count gates are sane. +6. Dashboard still labels the result **RL EXPERIMENT**, **NO-GO** when failed, and **not live-ready**. + +Otherwise the result is **NO-GO** or INCONCLUSIVE. + +## Dashboard interpretation + +The dashboard must show: + +- `OPENING 30M RL WORKFLOW` +- `PARTICIPANT PROXY EVIDENCE` +- `ORDERBOOK PERSISTENCE` +- proxy availability and missing proxy columns +- feature ablation status +- baseline/cost/control failures +- `NO-GO`, `not live-ready`, `23bp`, and `ts_imb RULE baseline` + +Visual curves are evidence summaries only. They are not profitability proof. + +## Prior NO-GO guardrails preserved + +- skip-gate result remains **NO-GO** unless a new preregistered hypothesis and evidence overturn it. +- state-exit result remains **NO-GO** unless a new preregistered hypothesis and evidence overturn it. +- Earlier PPO/plain RL candidates remain research-only and not live-ready. + +## Exact commands + +### Workflow dry-run + +```powershell +py -3.11 -m stom_rl.opening_30m_rl_workflow --run-id opening_30m_rl_workflow --output-dir webui/rl_runs/opening_30m_rl_workflow --no-write +``` + +### Tiny fixture smoke + +```powershell +py -3.11 -m pytest tests/test_stom_rl_opening_workflow_runner.py tests/test_stom_rl_opening_training.py tests/test_stom_rl_opening_artifacts.py -q +``` + +### End-to-end fixture smoke + +```powershell +py -3.11 -m pytest tests/test_stom_rl_opening_workflow_e2e.py -q +``` + +### Dashboard API test + +```powershell +py -3.11 -m pytest tests/test_stom_rl_opening_dashboard_api.py tests/test_stom_rl_opening_progress.py tests/test_stom_rl_participant_dashboard.py -q +``` + +### Frontend build + +```powershell +powershell -NoProfile -Command 'Push-Location webui/v2_src; npm run build; $code=$LASTEXITCODE; Pop-Location; exit $code' +``` + +### Current focused regression + +```powershell +py -3.11 -m pytest tests/test_stom_rl_dashboard_api.py tests/test_stom_rl_dashboard_tab.py tests/test_stom_rl_participant_dashboard.py -q +``` + +### Opening workflow regression + +```powershell +py -3.11 -m pytest tests/test_stom_rl_dashboard_api.py tests/test_stom_rl_dashboard_tab.py tests/test_stom_rl_orderbook_env.py tests/test_stom_rl_orderbook_sb3.py tests/test_stom_rl_opening_workflow_contract.py tests/test_stom_rl_opening_workflow_runner.py tests/test_stom_rl_opening_workflow_e2e.py -q +``` + +## Handoff + +Next work should run the end-to-end fixture smoke, then final verification. Do not claim a profitable or usable model from the current workflow. The current deliverable is a falsifiable research workflow and dashboard evidence surface. diff --git a/docs/stom_opening_30m_rule_filter_prereg_2026-06-04.md b/docs/stom_opening_30m_rule_filter_prereg_2026-06-04.md new file mode 100644 index 000000000..da595a467 --- /dev/null +++ b/docs/stom_opening_30m_rule_filter_prereg_2026-06-04.md @@ -0,0 +1,75 @@ +# STOM Opening 30m Rule Filter Preregistration - 2026-06-04 + +## Status + +This branch is a `RULE` / meta-label risk-control experiment, not RL. +It is not live-ready, not broker-ready, and not a profit model. +The base policy is `ts_imb RULE baseline`. +Participant and supply-demand variables are proxy evidence only; they do not identify actual foreign, institution, retail, or big-money actors. +The default round-trip cost is `23bp`. + +## Why this branch exists + +The latest bounded opening 30m DQN/PPO feature revalidation remains `NO-GO_BASELINE` at `23bp` with split hash `cb46cac3fd20651f`. +Full OOS RL expansion remains deferred until a bounded branch beats no-trade, buy-and-hold, and the `ts_imb RULE baseline` with controls and ablations. + +## Hypothesis + +A deterministic TAKE/SKIP filter around the `ts_imb RULE baseline` can reduce low-quality opening entries using: + +- participant proxy pressure +- orderbook imbalance and orderbook persistence +- overheat / upper-wick risk +- time-bucket behavior + +The filter may return `TAKE` or `SKIP` only. Position sizing, exit management, live trading, broker execution, and RL policy training are out of scope. + +## Labels + +- `TAKE`: execute the base `ts_imb RULE baseline` decision in the bounded research backtest. +- `SKIP`: do not take the base decision. +- `skipped_opportunity_net_return_pct`: what the base rule would have earned if skipped; this prevents hiding skipped winners. + +## Controls + +Required controls: + +- no-trade +- buy-and-hold same opening horizon +- base `ts_imb RULE baseline` +- shuffled labels +- time/session shuffle +- randomized features within split boundaries + +## Feature ablations + +Required ablations: + +- no participant proxy pressure +- no orderbook imbalance +- no orderbook persistence +- no overheat/upper-wick +- no time bucket +- context-only +- tick-only +- shuffled participant context + +## Decision labels + +- `GO_RULE_FILTER` +- `NO-GO_BASELINE` +- `NO-GO_CONTROL` +- `NO-GO_ABLATION` +- `INCONCLUSIVE` + +## Dashboard fields + +The dashboard must expose read-only `rule_filter_*` tables, including lifecycle, splits, controls, ablations, equity curve, time buckets, failure reasons, opportunity cost, proxy availability, orderbook persistence, and context sample. + +## Guardrails + +- Do not call this RL. +- Do not tune on OOS. +- Do not claim profitability. +- Do not claim actual participant identity. +- Do not add broker/live-order paths. diff --git a/docs/stom_opening_30m_rule_filter_realdata_oos_2026-06-05.md b/docs/stom_opening_30m_rule_filter_realdata_oos_2026-06-05.md new file mode 100644 index 000000000..f8e732c15 --- /dev/null +++ b/docs/stom_opening_30m_rule_filter_realdata_oos_2026-06-05.md @@ -0,0 +1,133 @@ +# STOM Opening 30m Rule Filter Realdata OOS Result — 2026-06-05 — NO-GO_CONTROL + +## Guardrails + +This is a `RULE` / meta-label filter result, not RL. +It is not live-ready, not broker-ready, and not a profit model. +The base policy remains the `ts_imb RULE baseline`. +Participant fields are proxy evidence only, not actual participant identity. +The cost assumption is `23bp` round trip. +This is a bounded real tick DB sample, not full-universe validation. + +## Exact command + +```powershell +py -3.11 -m stom_rl.opening_30m_rule_filter_cli --db _database/stock_tick_back.db --output-dir webui/rl_runs --run-id opening_30m_rule_filter_realdata_oos_2026_06_05 --create-split --max-tables 3 --max-sessions-per-table 5 --max-rows-per-session 1800 --min-rows-per-session 120 --time-start 090000 --time-end 093000 --cost-bps 23 --min-oos-take-trades 3 +``` + +The command was executed inside a 900-second first-pass ceiling. It completed without timeout in `3.219` seconds. + +## Actual bounded realdata result + +| Field | Value | +|---|---:| +| Run | `opening_30m_rule_filter_realdata_oos_2026_06_05` | +| Verdict | `NO-GO_CONTROL` | +| split hash | `37664423068ddeca` | +| Cost | `23bp` | +| Adapter frame count | `11` | +| Bounded tables | `3` | +| Max sessions per table | `5` | +| OOS net return pct | `3.576084196458389` | +| OOS TAKE count | `2` | +| Minimum OOS TAKE count | `3` | +| Validation net return pct | `-1.6100862564692298` | +| Max drawdown pct | `1.2345662100456654` | +| Skipped opportunity cost pct | `0.0` | + +## DB bounds and read-only evidence + +| Field | Value | +|---|---:| +| DB path | `_database/stock_tick_back.db` | +| DB access | SQLite `mode=ro`, `query_only=true` | +| Time window | `090000` to `093000` | +| max_tables | `3` | +| max_sessions_per_table | `5` | +| max_rows_per_session | `1800` | +| min_rows_per_session | `120` | + +Sampled symbols preserved leading zero codes: `000020`, `000040`, `000050`. + +## Sampled sessions + +| Symbol | Eligible sessions / row counts | +|---|---| +| `000020` | `20221212` / 446 | +| `000040` | `20230906` / 1525; `20240221` / 1292; `20240222` / 1529; `20250723` / 1340; `20250731` / 1660 | +| `000050` | `20220531` / 1309; `20250512` / 1770; `20251215` / 1646; `20260204` / 1463; `20260205` / 1545 | + +## Baseline gate + +| Baseline | Filter OOS net % | Baseline net % | Passed | +|---|---:|---:|---:| +| `no_trade` | `3.576084196458389` | `0.0` | `true` | +| `buy_and_hold` | `3.576084196458389` | `3.576084196458389` | `false` | +| `ts_imb_rule` | `3.576084196458389` | `3.576084196458389` | `false` | + +Interpretation: the filter beat no-trade in this bounded sample, but it did not beat buy-and-hold or the `ts_imb RULE baseline`. That blocks promotion. + +## Controls + +| Control | Control net % | Passed | +|---|---:|---:| +| `no_trade` | `0.0` | `true` | +| `buy_and_hold` | `3.576084196458389` | `false` | +| `ts_imb_rule` | `3.576084196458389` | `false` | +| `shuffled_labels` | `4.810650406504054` | `false` | +| `time_session_shuffle` | `3.576084196458389` | `false` | +| `randomized_features` | `0.0` | `true` | + +Negative/control evidence is not clean: shuffled labels outperformed the filter, and time-session shuffle matched the filter. The result remains `NO-GO_CONTROL`. + +## Feature ablations + +| Ablation | Ablated net % | Delta vs full OOS % | Passed | +|---|---:|---:|---:| +| `no_participant_pressure` | `0.0` | `3.576084196458389` | `true` | +| `no_orderbook_imbalance` | `3.576084196458389` | `0.0` | `false` | +| `no_orderbook_persistence` | `0.0` | `3.576084196458389` | `true` | +| `no_overheat_upper_wick` | `3.576084196458389` | `0.0` | `false` | +| `no_time_bucket` | `0.0` | `3.576084196458389` | `true` | +| `context_only` | `3.576084196458389` | `0.0` | `false` | +| `tick_only` | `0.0` | `3.576084196458389` | `true` | +| `shuffled_participant_context` | `3.576084196458389` | `0.0` | `false` | + +Interpretation: participant pressure and orderbook persistence matter in this sample, but orderbook imbalance, overheat/upper-wick, context-only, and shuffled participant context do not prove stable incremental value. This blocks promotion. + +## Blocking reasons + +- `insufficient_oos_take_trades` +- `failed_baseline:buy_and_hold` +- `failed_baseline:ts_imb_rule` +- `failed_controls` +- `failed_ablations` + +## Dashboard evidence + +The dashboard API can discover this run as `opening_30m_rule_filter` and returns non-RL strategy context: + +| Table | Rows | +|---|---:| +| `rule_filter_controls` | `6` | +| `rule_filter_ablations` | `8` | +| `rule_filter_proxy_availability` | `5` | +| `rule_filter_orderbook_persistence` | `10` | + +Frontend source now uses the run strategy label for participant proxy evidence. For this run the label is `RULE FILTER EVIDENCE`, not an RL label. + +## Artifact source + +- `webui/rl_runs/opening_30m_rule_filter_realdata_oos_2026_06_05/opening_rule_filter_summary.json` +- `webui/rl_runs/opening_30m_rule_filter_realdata_oos_2026_06_05/opening_rule_filter_lifecycle.json` +- `webui/rl_runs/opening_30m_rule_filter_realdata_oos_2026_06_05/opening_rule_filter_controls.json` +- `webui/rl_runs/opening_30m_rule_filter_realdata_oos_2026_06_05/opening_rule_filter_ablations.json` +- `webui/rl_runs/opening_30m_rule_filter_realdata_oos_2026_06_05/opening_rule_filter_gate.json` +- `webui/rl_runs/opening_30m_rule_filter_realdata_oos_2026_06_05/opening_rule_filter_split_manifest.json` +- `webui/rl_runs/opening_30m_rule_filter_realdata_oos_2026_06_05/realdata_adapter/realdata_adapter_summary.json` + +## Next decision + +Do not scale RL from this result. The bounded realdata rule-filter produced a positive OOS net in this small sample, but it failed the promotion gate because it did not beat buy-and-hold or the `ts_imb RULE baseline`, failed controls, failed feature ablations, and had only 2 OOS TAKE trades against the required 3. + +The next safe step is blocker analysis, not PPO/DQN expansion. \ No newline at end of file diff --git a/docs/stom_opening_30m_rule_filter_result_2026-06-04.md b/docs/stom_opening_30m_rule_filter_result_2026-06-04.md new file mode 100644 index 000000000..7aee716c6 --- /dev/null +++ b/docs/stom_opening_30m_rule_filter_result_2026-06-04.md @@ -0,0 +1,49 @@ +# STOM Opening 30m Rule Filter Result - 2026-06-04 + +## Guardrails + +This is a `RULE` / meta-label filter result, not RL. +It is not live-ready, not broker-ready, and not a profit model. +The base policy remains `ts_imb RULE baseline`. +Participant fields are proxy evidence only, not actual participant identity. +The cost assumption is `23bp` round trip. + +## Actual bounded smoke result + +| Field | Value | +|---|---| +| Run | `opening_30m_rule_filter_smoke` | +| Verdict | `NO-GO_CONTROL` | +| Split hash | `225356e7f771784c` | +| Cost | `23.0bp` | +| OOS net return pct | `-3.7771389861260922` | +| OOS TAKE count | `3` | +| Skipped opportunity cost pct | `0.0` | + +## Dashboard table counts + +| Table | Rows | +|---|---:| +| `rule_filter_controls` | 6 | +| `rule_filter_ablations` | 8 | +| `rule_filter_equity_curve` | 2 | +| `rule_filter_time_buckets` | 1 | +| `rule_filter_opportunity_cost` | 2 | + + +## Controls and ablations + +- Control rows: `6` +- Ablation rows: `8` +- Blocking reasons: `['failed_baseline:no_trade', 'failed_baseline:buy_and_hold', 'failed_baseline:ts_imb_rule', 'failed_controls', 'failed_ablations']` + +## Interpretation + +The bounded smoke artifact is now dashboard-visible and falsifiable. If the verdict is not `GO_RULE_FILTER`, full OOS RL expansion remains deferred. If future work attempts RL expansion, it must first update this result with a bounded gate that beats no-trade, buy-and-hold, and the `ts_imb RULE baseline` after `23bp`, with controls and feature ablations passing. + +## Artifact source + +- `webui/rl_runs/opening_30m_rule_filter_smoke/opening_rule_filter_lifecycle.json` +- `webui/rl_runs/opening_30m_rule_filter_smoke/opening_rule_filter_gate.json` +- `webui/rl_runs/opening_30m_rule_filter_smoke/opening_rule_filter_controls.json` +- `webui/rl_runs/opening_30m_rule_filter_smoke/opening_rule_filter_ablations.json` diff --git a/docs/stom_opening_30m_rule_filter_simplified_prereg_2026-06-05.md b/docs/stom_opening_30m_rule_filter_simplified_prereg_2026-06-05.md new file mode 100644 index 000000000..4cfed46b8 --- /dev/null +++ b/docs/stom_opening_30m_rule_filter_simplified_prereg_2026-06-05.md @@ -0,0 +1,60 @@ +# STOM Opening 30m Simplified RULE/meta-label Preregistration — 2026-06-05 + +## Purpose + +This preregisters a future simplified opening 30m `RULE`/meta-label validation. It is not an executed experiment. It is not RL, not live-ready, not broker-ready, and not a profit model. + +## Prior diagnostic evidence + +| Field | Value | +|---|---:| +| Prior bounded split | `37664423068ddeca` | +| Prior wide split | `bc4384540145ce12` | +| Prior wide verdict | `NO-GO_CONTROL` | +| Prior decision | `STOP_RL_EXPANSION + SIMPLIFY_FEATURES + PROXY_AUDIT_REQUIRED` | +| Cost assumption | `23bp` | + +Both prior splits are diagnostic-only. They must not be used to tune thresholds, features, labels, actions, proxy definitions, or policy rules. + +## Simplified feature set + +The future simplified run must use: + +```text +--feature-set minimal_ts_imb +``` + +`minimal_ts_imb` uses the base `ts_imb RULE baseline` action path and excludes unproven context features from the decision score: + +- participant pressure +- `proxy_*` +- orderbook imbalance +- orderbook persistence +- overheat +- upper-wick / wick +- broad context-only path + +## Future command shape + +```powershell +py -3.11 -m stom_rl.opening_30m_rule_filter_cli --db _database/stock_tick_back.db --output-dir webui/rl_runs --run-id opening_30m_rule_filter_simplified_oos_2026_06_05 --create-split --feature-set minimal_ts_imb --max-tables 10 --max-sessions-per-table 5 --max-rows-per-session 1800 --min-rows-per-session 120 --time-start 090000 --time-end 093000 --cost-bps 23 --min-oos-take-trades 10 +``` + +## Gates + +`GO_RULE_FILTER` is allowed only if all pass: + +- OOS TAKE count >= `10`. +- OOS net return beats no-trade, independent buy-and-hold, and `ts_imb RULE baseline` after `23bp`. +- Validation net return is non-negative. +- Max drawdown <= `5.0%`. +- Negative controls pass. +- No OOS tuning or post-OOS rerun with changed bounds. + +## Baseline semantics + +Future result docs must distinguish artifact controls from the independent opening baseline comparator. Current artifact equality between `buy_and_hold` and `ts_imb_rule` must not be reported as independent outperformance. + +## RL gate + +PPO/DQN and opening 30m RL expansion remain blocked until a future simplified RULE/meta-label run passes the preregistered gates. diff --git a/docs/stom_opening_30m_rule_filter_simplified_result_2026-06-05.md b/docs/stom_opening_30m_rule_filter_simplified_result_2026-06-05.md new file mode 100644 index 000000000..8b33620f7 --- /dev/null +++ b/docs/stom_opening_30m_rule_filter_simplified_result_2026-06-05.md @@ -0,0 +1,127 @@ +# STOM Opening 30m Rule Filter Simplified Result - 2026-06-05 + +## 결론 + +- Verdict: `NO-GO_CONTROL` +- Manual decision: `NO-GO_CONTROL` +- Run id: `opening_30m_rule_filter_simplified_oos_2026_06_05` +- Feature set: `minimal_ts_imb` +- Split hash: `bc4384540145ce12` +- Cost: `23bp` + +이 실행은 강화학습 성공 결과가 아니다. `minimal_ts_imb`는 `ts_imb RULE baseline` 경로를 단순화해 검증한 RULE/meta-label evidence run이다. 결과는 OOS 손실과 control 실패 때문에 `NO-GO_CONTROL`이다. + +## Exact command + +```powershell +py -3.11 -m stom_rl.opening_30m_rule_filter_cli --db _database/stock_tick_back.db --output-dir webui/rl_runs --run-id opening_30m_rule_filter_simplified_oos_2026_06_05 --create-split --feature-set minimal_ts_imb --max-tables 10 --max-sessions-per-table 5 --max-rows-per-session 1800 --min-rows-per-session 120 --time-start 090000 --time-end 093000 --cost-bps 23 --min-oos-take-trades 10 +``` + +## Bounds + +- max_tables: 10 +- max_sessions_per_table: 5 +- max_rows_per_session: 1800 +- min_rows_per_session: 120 +- time_start: 090000 +- time_end: 093000 +- DB read-only evidence: sqlite `mode=ro`, `query_only=true` +- Frame count: 39 + +## Metrics + +| Metric | Value | +|---|---:| +| validation net return | 6.034562888623697% | +| OOS net return | -0.5448716160851159% | +| OOS TAKE count | 6 / 10 | +| max drawdown | 8.133029466690743% | +| max allowed drawdown | 5.0% | +| skipped opportunity cost | 0.0% | + +## Blocking reasons + +- `insufficient_oos_take_trades` +- `failed_risk:max_drawdown` +- `failed_baseline:no_trade` +- `failed_baseline:buy_and_hold` +- `failed_baseline:ts_imb_rule` +- `failed_controls` +- `failed_ablations` + +## Baseline comparison + +| Baseline | Filter OOS | Baseline OOS | Passed | +|---|---:|---:|---| +| no_trade | -0.5448716160851159% | 0.0% | False | +| buy_and_hold | -0.5448716160851159% | -0.5448716160851159% | False | +| ts_imb_rule | -0.5448716160851159% | -0.5448716160851159% | False | + + +## Baseline semantics + +- Artifact `buy_and_hold` equality: True +- Artifact `ts_imb_rule` equality: True +- Guardrail: `Do not report artifact baseline equality as independent outperformance.` + +해석: 이번 `minimal_ts_imb` 결과는 artifact `buy_and_hold`/`ts_imb_rule`와 구조적으로 같은 수익률을 보였다. 이것은 독립 baseline을 이긴 증거가 아니다. + +## Controls + +| Control | Control return | Passed | +|---|---:|---| +| no_trade | 0.0% | False | +| buy_and_hold | -0.5448716160851159% | False | +| ts_imb_rule | -0.5448716160851159% | False | +| shuffled_labels | -3.3356245549756265% | True | +| time_session_shuffle | -0.5448716160851159% | False | +| randomized_features | -0.5448716160851159% | False | + +## Ablations + +| Ablation | Return | Delta vs full | Passed | +|---|---:|---:|---| +| no_participant_pressure | -0.5448716160851159% | 0.0% | False | +| no_orderbook_imbalance | -0.5448716160851159% | 0.0% | False | +| no_orderbook_persistence | -0.5448716160851159% | 0.0% | False | +| no_overheat_upper_wick | -0.5448716160851159% | 0.0% | False | +| no_time_bucket | -0.5448716160851159% | 0.0% | False | +| context_only | -0.5448716160851159% | 0.0% | False | +| tick_only | -0.5448716160851159% | 0.0% | False | +| shuffled_participant_context | -0.5448716160851159% | 0.0% | False | + + +## Dashboard visibility + +| Table alias | Rows | Status | +|---|---:|---| +| rule_filter_controls | 6 | OK | +| rule_filter_ablations | 8 | OK | +| rule_filter_proxy_availability | 5 | OK | +| rule_filter_orderbook_persistence | 10 | OK | + + +Dashboard role is read-only evidence viewing. It is not a profitability proof and not an order/broker surface. + +## Prior split policy + +Prior diagnostic split hashes `37664423068ddeca` and `bc4384540145ce12` were not used for tuning. The emitted split for this run is frozen after first emission: `bc4384540145ce12`. + +## Guardrails + +- `ts_imb` remains a RULE baseline, not RL. +- PPO/DQN remains blocked. +- No live trading readiness claim. +- No broker readiness claim. +- No guaranteed profit claim. +- Participant/proxy fields are proxy evidence only, not actual participant identity. + +## Next decision + +Recommended next command: + +```text +$ulw-plan baseline-only termination and data expansion audit for opening 30m rule-filter track +``` + +Reason: current simplified OOS run reproduced the same `NO-GO_CONTROL`. The next useful step is not PPO/DQN; it is either stop this track or audit independent baselines/sample expansion before any model expansion. diff --git a/docs/stom_opening_30m_rule_filter_wide_failure_decision_2026-06-05.md b/docs/stom_opening_30m_rule_filter_wide_failure_decision_2026-06-05.md new file mode 100644 index 000000000..b3ee799da --- /dev/null +++ b/docs/stom_opening_30m_rule_filter_wide_failure_decision_2026-06-05.md @@ -0,0 +1,60 @@ +# STOM Opening 30m Rule Filter Wide Failure Decision ? 2026-06-05 + +## Decision + +```text +STOP_RL_EXPANSION + SIMPLIFY_FEATURES + PROXY_AUDIT_REQUIRED +``` + +This decision is based on the wider bounded realdata OOS run: + +| Field | Value | +|---|---:| +| Run | `opening_30m_rule_filter_realdata_oos_wide_2026_06_05` | +| Verdict | `NO-GO_CONTROL` | +| Split hash | `bc4384540145ce12` | +| Cost | `23.0bp` | +| Frame count | `39` | +| Validation net return pct | `6.034562888623697` | +| OOS net return pct | `-0.5448716160851159` | +| OOS TAKE count | `6` | +| Minimum OOS TAKE count | `10` | +| Max drawdown pct | `8.133029466690743` | +| Max allowed drawdown pct | `5.0` | + +## Guardrails + +This is `RULE` / meta-label research evidence, not RL success. `ts_imb` remains the `ts_imb RULE baseline`, never RL. Participant/proxy fields are proxy evidence only, not actual participant identity. The dashboard is a read-only evidence viewer. This is not live-ready, not broker-ready, and not a profit model. + +## Blocking reasons + +- `insufficient_oos_take_trades` +- `failed_risk:max_drawdown` +- `failed_baseline:no_trade` +- `failed_baseline:buy_and_hold` +- `failed_baseline:ts_imb_rule` +- `failed_controls` +- `failed_ablations` + +## Failure interpretation + +The wider run is a stronger negative result than the prior bounded run. Validation was positive at `6.034562888623697%`, but OOS was negative at `-0.5448716160851159%`. OOS TAKE count was `6`, below the preregistered minimum `10`. Drawdown exceeded the gate. The filter failed no-trade, buy-and-hold, and `ts_imb RULE baseline` checks. + +All eight ablations matched the full OOS return and failed. This is interpreted as attribution collapse / threshold-insensitivity / insufficient OOS action diversity, not as proof that any feature works. + +## Decision matrix + +| Decision | Meaning | Next action | +|---|---|---| +| `STOP_RL_EXPANSION` | Current evidence blocks PPO/DQN/opening 30m RL expansion. | Keep RL work paused until future RULE/meta-label gates pass. | +| `SIMPLIFY_FEATURES` | Current feature stack is not attributable. | Plan a minimal preregistered RULE/meta-label experiment. | +| `PROXY_AUDIT_REQUIRED` | Participant/proxy context is not proven useful. | Audit proxy definitions and shuffled proxy behavior before adding features. | + +## Next recommended command + +```text +$ulw-plan Create a simplified RULE/meta-label experiment plan that removes unproven context features, audits buy_and_hold vs ts_imb baseline semantics, and keeps PPO/DQN blocked until gates pass. +``` + + +Guardrail marker: cost assumption is 23bp. diff --git a/docs/stom_opening_30m_rule_filter_wide_prereg_2026-06-05.md b/docs/stom_opening_30m_rule_filter_wide_prereg_2026-06-05.md new file mode 100644 index 000000000..8b7bd3b48 --- /dev/null +++ b/docs/stom_opening_30m_rule_filter_wide_prereg_2026-06-05.md @@ -0,0 +1,67 @@ +# STOM Opening 30m Rule Filter Wide OOS Preregistration ? 2026-06-05 + +## Purpose + +Run a wider bounded realdata OOS validation for the opening 30m `RULE`/meta-label filter. This is research evidence only. It is not a live-trading approval, not broker readiness, not a profit guarantee, and not a reinforcement-learning success claim. + +## Prior diagnostic evidence + +| Field | Value | +|---|---:| +| Prior run | `opening_30m_rule_filter_realdata_oos_2026_06_05` | +| Prior verdict | `NO-GO_CONTROL` | +| Frozen diagnostic split | `37664423068ddeca` | +| Prior cost | `23bp` | +| Prior OOS TAKE count | `2` | +| Prior minimum OOS TAKE count | `3` | + +The prior split `37664423068ddeca` is diagnostic-only and must not be used to tune thresholds, features, labels, actions, or policy rules. + +## Primary run + +| Field | Value | +|---|---:| +| Selected run id | `opening_30m_rule_filter_realdata_oos_wide_2026_06_05` | +| max_tables | `10` | +| max_sessions_per_table | `5` | +| max_rows_per_session | `1800` | +| min_rows_per_session | `120` | +| time window | `090000-093000` | +| cost | `23bp` | +| min_oos_take_trades | `10` | +| timeout ceiling | `1800s` | + +```powershell +py -3.11 -m stom_rl.opening_30m_rule_filter_cli --db _database/stock_tick_back.db --output-dir webui/rl_runs --run-id opening_30m_rule_filter_realdata_oos_wide_2026_06_05 --create-split --max-tables 10 --max-sessions-per-table 5 --max-rows-per-session 1800 --min-rows-per-session 120 --time-start 090000 --time-end 093000 --cost-bps 23 --min-oos-take-trades 10 +``` + +## Fallback policy + +Fallback is allowed only before OOS interpretation and only if primary preflight/runtime/artifact production fails. A bad primary verdict is not a fallback trigger. + +Fallback run id: `opening_30m_rule_filter_realdata_oos_wide_fallback_2026_06_05` + +```powershell +py -3.11 -m stom_rl.opening_30m_rule_filter_cli --db _database/stock_tick_back.db --output-dir webui/rl_runs --run-id opening_30m_rule_filter_realdata_oos_wide_fallback_2026_06_05 --create-split --max-tables 5 --max-sessions-per-table 5 --max-rows-per-session 1800 --min-rows-per-session 120 --time-start 090000 --time-end 093000 --cost-bps 23 --min-oos-take-trades 10 +``` + +Fallback uses `--max-tables 5` and must be labeled separately. + +## Promotion criteria + +`GO_RULE_FILTER` is allowed only if all are true: + +- OOS TAKE count >= `10`. +- Filter beats `no_trade`, `buy_and_hold`, and `ts_imb RULE baseline` after `23bp`. +- Validation net return is non-negative. +- Negative controls pass: `shuffled_labels`, `time_session_shuffle`, `randomized_features`. +- Ablations pass or remain inconclusive with no promotion. +- Max drawdown remains within the existing gate limit. +- No OOS tuning or post-OOS rerun with changed bounds. + +## Guardrails + +- `ts_imb` is a RULE baseline, never RL. +- Participant/supply-demand fields are proxy evidence only, not actual participant identity. +- Dashboard is read-only evidence viewer. +- PPO/DQN remains blocked unless this RULE/meta-label track passes baseline/control/ablation gates. diff --git a/docs/stom_opening_30m_rule_filter_wide_result_2026-06-05.md b/docs/stom_opening_30m_rule_filter_wide_result_2026-06-05.md new file mode 100644 index 000000000..4214502c3 --- /dev/null +++ b/docs/stom_opening_30m_rule_filter_wide_result_2026-06-05.md @@ -0,0 +1,121 @@ +# STOM Opening 30m Rule Filter Wide Realdata OOS Result ? 2026-06-05 ? NO-GO_CONTROL + +## Guardrails + +This is a `RULE` / meta-label filter result, not RL. It is not live-ready, not broker-ready, and not a profit model. The base policy remains the `ts_imb RULE baseline`. Participant fields are proxy evidence only, not actual participant identity. The cost assumption is `23bp` round trip. This is a wider bounded real tick DB sample, not full-universe validation. + +## Preregistration + +Preregistration document was written before execution: + +```text +docs/stom_opening_30m_rule_filter_wide_prereg_2026-06-05.md +``` + +Prior diagnostic split `37664423068ddeca` was not used for tuning. + +## Exact command + +```powershell +py -3.11 -m stom_rl.opening_30m_rule_filter_cli --db _database/stock_tick_back.db --output-dir webui/rl_runs --run-id opening_30m_rule_filter_realdata_oos_wide_2026_06_05 --create-split --max-tables 10 --max-sessions-per-table 5 --max-rows-per-session 1800 --min-rows-per-session 120 --time-start 090000 --time-end 093000 --cost-bps 23 --min-oos-take-trades 10 +``` + +The command was executed inside a 1800-second ceiling. It completed without timeout in `5.313` seconds. + +## Actual wider bounded realdata result + +| Field | Value | +|---|---:| +| Run | `opening_30m_rule_filter_realdata_oos_wide_2026_06_05` | +| Verdict | `NO-GO_CONTROL` | +| Split hash | `bc4384540145ce12` | +| Cost | `23.0bp` | +| Adapter frame count | `39` | +| Bounded tables | `10` | +| Max sessions per table | `5` | +| OOS net return pct | `-0.5448716160851159` | +| OOS TAKE count | `6` | +| Minimum OOS TAKE count | `10` | +| Validation net return pct | `6.034562888623697` | +| Max drawdown pct | `8.133029466690743` | +| Max allowed drawdown pct | `5.0` | +| Skipped opportunity cost pct | `0.0` | + +## DB bounds and read-only evidence + +| Field | Value | +|---|---:| +| DB path | `_database/stock_tick_back.db` | +| DB access | SQLite `mode=ro`, `query_only=True` | +| Time window | `090000` to `093000` | +| max_tables | `10` | +| max_sessions_per_table | `5` | +| max_rows_per_session | `1800` | +| min_rows_per_session | `120` | + +## Split ranges + +| Split | Start | End | Sessions | +|---|---:|---:|---:| +| `train` | `20220325` | `20231114` | `23` | +| `validation` | `20231117` | `20241227` | `8` | +| `oos` | `20250512` | `20260205` | `8` | + +## Blocking reasons + +- `insufficient_oos_take_trades` +- `failed_risk:max_drawdown` +- `failed_baseline:no_trade` +- `failed_baseline:buy_and_hold` +- `failed_baseline:ts_imb_rule` +- `failed_controls` +- `failed_ablations` + +## Baseline gate + +| Baseline | Filter OOS net % | Baseline net % | Passed | +|---|---:|---:|---:| +| `no_trade` | `-0.5448716160851159` | `0.0` | `False` | +| `buy_and_hold` | `-0.5448716160851159` | `-0.5448716160851159` | `False` | +| `ts_imb_rule` | `-0.5448716160851159` | `-0.5448716160851159` | `False` | + +Benchmark note: in this artifact, `buy_and_hold` and `ts_imb_rule` are equal to the filter result. This is reported as equality/failure, not as independent outperformance. + +## Controls + +| Control | Control net % | Passed | +|---|---:|---:| +| `no_trade` | `0.0` | `False` | +| `buy_and_hold` | `-0.5448716160851159` | `False` | +| `ts_imb_rule` | `-0.5448716160851159` | `False` | +| `shuffled_labels` | `-3.3356245549756265` | `True` | +| `time_session_shuffle` | `-0.5448716160851159` | `False` | +| `randomized_features` | `0.0` | `False` | + +## Ablations + +| Feature set | Full % | Ablated % | Delta % | Passed | +|---|---:|---:|---:|---:| +| `no_participant_pressure` | `-0.5448716160851159` | `-0.5448716160851159` | `0.0` | `False` | +| `no_orderbook_imbalance` | `-0.5448716160851159` | `-0.5448716160851159` | `0.0` | `False` | +| `no_orderbook_persistence` | `-0.5448716160851159` | `-0.5448716160851159` | `0.0` | `False` | +| `no_overheat_upper_wick` | `-0.5448716160851159` | `-0.5448716160851159` | `0.0` | `False` | +| `no_time_bucket` | `-0.5448716160851159` | `-0.5448716160851159` | `0.0` | `False` | +| `context_only` | `-0.5448716160851159` | `-0.5448716160851159` | `0.0` | `False` | +| `tick_only` | `-0.5448716160851159` | `-0.5448716160851159` | `0.0` | `False` | +| `shuffled_participant_context` | `-0.5448716160851159` | `-0.5448716160851159` | `0.0` | `False` | + +## Dashboard availability + +| Table | Rows | Source | +|---|---:|---| +| `rule_filter_controls` | `6` | `opening_rule_filter_lifecycle.json` | +| `rule_filter_ablations` | `8` | `opening_rule_filter_lifecycle.json` | +| `rule_filter_proxy_availability` | `5` | `opening_rule_filter_lifecycle.json` | +| `rule_filter_orderbook_persistence` | `10` | `opening_rule_filter_lifecycle.json` | + +## Interpretation + +The wider bounded realdata experiment is a stronger negative result than the prior tiny sample. It increased the frame count and generated dashboard-visible evidence, but the model/filter remains `NO-GO_CONTROL` because OOS return is negative, OOS TAKE count is below the preregistered minimum, drawdown exceeds the gate, baselines are not beaten, and controls/ablations fail. + +Do not proceed to PPO/DQN expansion from this result. The next work should analyze this wider failure and decide whether to simplify features, revise proxy definitions, or stop the RL expansion path until the RULE/meta-label track improves. diff --git a/docs/stom_ppo_full_eval_2026-05-31.md b/docs/stom_ppo_full_eval_2026-05-31.md new file mode 100644 index 000000000..829207fd7 --- /dev/null +++ b/docs/stom_ppo_full_eval_2026-05-31.md @@ -0,0 +1,101 @@ +# PPO 100k 전체 test split(2,730 episode) 정직 재평가 + +- 작성일: **2026-05-31 KST** / 브랜치: `feature/stom-rl-lab` +- 대상: 저장된 SB3 PPO 100k 모델(`webui/rl_runs/stom_1s_2025_sb3_ppo_100k/ppo_model.zip`) — **RULE strategy lab, the model is on trial. NOT a profit claim.** +- 구현: `.omx/ppo_full_eval.py`(production `_evaluate_model`/`_summarize_model` 재사용, checkpoint-resumable) / 산출물 `.omx/artifacts/ppo_full_eval/summary.json` +- 동기: 리더보드의 PPO 100k "buy-and-hold 통과(+1.03%)"가 **20~100 episode 소표본** 평가였음. 전체 2,730으로 정직하게 재평가. + +--- + +## 0. 한 줄 결론 + +**소표본 환상 확정.** PPO 100k의 평균 수익은 표본을 100→2,730 episode로 늘리자 **+1.016% → +0.165%/episode로 84% 붕괴**했고, **buy-and-hold(+0.513%)에 진다(beats_bh=False), cost gate 미통과, episode 중앙값은 −0.276%(음수)**. 즉 트레이드 과반이 손실이고 평균은 소수 대박이 겨우 양수로 끌어올린 것. **이 PPO 모델은 검증된 수익 모델이 아니다.** + +> 검증: 동일 드라이버가 첫 100 episode에서 기존 eval100(+1.0161952713615299)을 **소수점까지 재현** → 드라이버는 정확하고, 차이는 순전히 표본 크기. + +--- + +## 1. 표본이 커질수록 무너지는 곡선 (결정론적) + +| 누적 episode | avg net %/ep | episode median % | +|---:|---:|---:| +| 100 | **+1.016** | +0.681 | +| 200 | +0.596 | +0.412 | +| 300 | +0.426 | +0.300 | +| 500 | +0.310 | +0.187 | +| 700 | +0.121 | −0.035 | +| 1,000 | +0.127 | −0.204 | +| 1,500 | +0.172 | −0.267 | +| 2,000 | ~+0.17 | ~−0.26 | +| **2,730 (전체)** | **+0.165** | **−0.276** | + +→ 100 episode의 +1.0%는 운 좋은 초기 구간일 뿐, 표본이 커지자 **+0.165%로 수렴**. median은 +0.68%에서 **−0.28%로 추락** — 분포가 "대부분 소액 손실 + 소수 대박"임을 드러냄. + +--- + +## 2. 전체 2,730 episode 실측 (확정) + +| 지표 | 값 | +|---|---:| +| episode_count | 2,730 | +| **avg_episode_net_return_pct** | **+0.1652%** | +| median_episode_net_return_pct | **−0.2763%** | +| avg_trade_net_return_pct | +0.1029% | +| hit_rate (수익 트레이드 비율) | **31.4%** | +| trades_per_episode | 1.556 (총 4,249 트레이드) | +| max_drawdown_pct | **−74.32%** | +| passes_cost_gate | **False** | +| beats_buy_and_hold | **False** | +| beats_no_trade | True (간신히) | +| cost_bps | 25 | + +--- + +## 3. baseline 비교 (전체 test split, 25bp, 동일 조건) + +| 정책 | avg net %/ep | beats BH | cost gate | 평가 | +|---|---:|---|---|---| +| **PPO 100k (full, 본 문서)** | **+0.165** | ❌ | ❌ | watch | +| buy_and_hold | +0.513 | — | ❌ | baseline (최강) | +| contextual_bandit (full) | +0.125 | ❌ | ❌ | watch | +| no_trade | 0.000 | ❌ | — | baseline | +| momentum/mean_reversion/random | 음수 | ❌ | ❌ | baseline | + +→ PPO 100k(+0.165%)는 contextual bandit(+0.125%)과 **사실상 동급**이고, **buy-and-hold(+0.513%)의 약 1/3**. 즉 학습된 RL 모델이 "아무것도 안 하고 들고 있기"보다 못함. (리더보드의 dqn_50k +1.61%·ppo_100k +1.03% 등 "buy-and-hold 통과" 행은 전부 N=5~100 소표본이라 동일한 환상으로 추정 — 정직한 평가는 전체 표본 필요.) + +--- + +## 4. 핵심 함의 + +1. **"모델이 있다 ≠ 수익이 난다."** PPO/DQN zip은 디스크에 실재하고 로드·실행되지만, 전체 표본에선 buy-and-hold도 못 이긴다. 리더보드의 표면적 우위는 소표본 아티팩트. +2. **median 음수(−0.276%)·hit 31%**: 전형적 모멘텀 추격 실패 — 대부분 손절, 가끔 큰 승. MDD −74%는 배포 불가 수준. +3. **다른 게이트들과 정합**: shuffle(선택 무알파)·P1b(타이밍 NO-GO)·SL게이트(방향 아닌 리스크만 예측)에 이어, **학습된 정책의 실수익도 baseline 미달**. 일관된 결론 — 이 데이터에서 RL은 방향성 수익을 못 낸다. + +--- + +## 5. 정직성 캐비엇 + +1. 본 평가는 `mark_to_market` 환경의 buy/hold/sell 단일종목 정책 재생. 25bp 비용 반영, 슬리피지 0(낙관). 실거래는 더 나쁠 수 있음. +2. compounded_return +901%는 **비복리 가정의 산술적 누적**이지 실현 수익이 아님 — avg_episode +0.165%가 정직한 1회 기대. +3. triggered-subset DB, L2 없음, 라이브 포워드 없음. **수익 보장 아님.** +4. 드라이버는 production 함수(`_evaluate_model`/`_summarize_model`) 그대로 재사용 + 첫 청크가 eval100 재현 → 수치 신뢰 가능. RULE not RL. + +--- + +## 6. 재현 + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 .omx/ppo_full_eval.py 0 # checkpoint-resumable, summary.json 갱신 +``` +산출: `.omx/artifacts/ppo_full_eval/summary.json`. 핵심(2026-05-31): N=2,730, avg +0.1652%, median −0.2763%, hit 31.4%, MDD −74.3%, passes_cost_gate False, beats_buy_and_hold False. + +--- + +## 7. 트랙 갱신 + +| 항목 | 상태 | +|---|---| +| **PPO 100k 전체 재평가** | ✅ **완료 — +0.165%/ep, buy-and-hold 미달, cost gate 미통과 (소표본 +1.0% 환상 확정)** | +| 학습된 RL 모델 일반 결론 | ❌ 전체 표본서 baseline 미달 — 검증된 수익 모델 없음 | +| RULE 전략 | ✅ 별개 트랙(타 문서); 본 문서는 RL 모델 정직 평가 | diff --git a/docs/stom_predictor_auto_handoff_override.md b/docs/stom_predictor_auto_handoff_override.md new file mode 100644 index 000000000..89294a144 --- /dev/null +++ b/docs/stom_predictor_auto_handoff_override.md @@ -0,0 +1,74 @@ +# STOM predictor 자동 고효율 handoff 적용 기록 + +작성일: 2026-05-14 08:45 KST +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 목적 + +사용자가 계속 직접 감시하지 않아도, 현재 실행 중인 `--train-stage both` 부모 프로세스가 tokenizer 완료 후 `train_predictor.py`를 실행하는 시점에 predictor 설정을 고효율 값으로 바꿀 수 있게 한다. + +## 핵심 문제 + +현재 살아 있는 부모 프로세스는 이미 다음 설정으로 실행 중이다. + +```text +--batch-size 4 +--num-workers 0 +--train-stage both +``` + +따라서 이전에 추가한 `--predictor-batch-size`, `--predictor-num-workers` CLI 옵션은 미래의 새 실행에는 유효하지만, 이미 실행 중인 부모 프로세스에는 소급 적용되지 않는다. + +## 이번 해결 방식 + +`train_predictor.py`가 시작될 때 run 디렉터리의 handoff 파일을 읽어 predictor 전용 설정을 덮어쓰게 했다. + +기본 파일 위치: + +```text +finetune/outputs/stom_1s_grid_pred60_2025_full_small/predictor_handoff_overrides.json +``` + +현재 배치한 운영 파일 내용의 핵심값: + +```json +{ + "enabled": true, + "batch_size": 16, + "num_workers": 2 +} +``` + +이 파일은 `finetune/outputs/` 아래에 있어 git ignore 대상이다. 모델 산출물이나 checkpoint를 수정하지 않으며, predictor 프로세스 시작 시점에만 읽힌다. + +## 적용 범위 + +- 현재 tokenizer 단계에는 영향 없음 +- tokenizer가 끝난 뒤 새로 시작되는 `train_predictor.py`에만 영향 있음 +- `batch_size=4`, `num_workers=0` 대신 `batch_size=16`, `num_workers=2` 적용 예정 +- 설정 적용 시 stdout에 다음 형태의 로그가 출력됨 + +```text +Predictor handoff overrides applied: {'batch_size': 16, 'num_workers': 2} from ...predictor_handoff_overrides.json +``` + +## 안전 장치 + +- JSON `enabled=false`이면 적용하지 않음 +- 허용 key는 `batch_size`, `num_workers`만 사용 +- `batch_size <= 0` 또는 `num_workers < 0`은 오류 처리 +- UTF-8 BOM이 있는 Windows PowerShell 생성 JSON도 읽을 수 있게 `utf-8-sig`로 로드 +- unknown key는 무시하고 metadata로만 기록 + +## 남은 감시 포인트 + +1. tokenizer 100% 도달 +2. validation 완료 +3. tokenizer checkpoint 생성 +4. predictor 시작 로그에서 handoff 적용 메시지 확인 +5. predictor의 GPU 사용률, VRAM, samples/sec 확인 +6. OOM/RAM/DataLoader 문제가 있으면 `batch_size=8` 또는 `num_workers=0`으로 낮춰 재시도 + +## 중요 결론 + +이제 사용자가 계속 직접 감시하지 않아도, 현재 부모 프로세스가 predictor를 실행하는 순간 디스크의 handoff 파일을 통해 고효율 설정이 적용되도록 준비했다. 단, 실제 적용 여부는 predictor 시작 로그와 `/api/training/status`에서 반드시 확인해야 한다. diff --git a/docs/stom_predictor_max_efficiency_handoff.md b/docs/stom_predictor_max_efficiency_handoff.md new file mode 100644 index 000000000..0c748c215 --- /dev/null +++ b/docs/stom_predictor_max_efficiency_handoff.md @@ -0,0 +1,131 @@ +# STOM 2025 predictor 최대 효율 전환 준비 + +작성일: 2026-05-13 KST + +## 목표 + +현재 실행 중인 `stom_1s_grid_pred60_2025_full_small` tokenizer 학습은 중단하지 않는다. tokenizer checkpoint가 생성된 뒤 predictor 단계는 가능한 한 GPU를 더 잘 쓰는 설정으로 실행할 수 있게 준비한다. + +## 핵심 제약 + +- 현재 실행 중인 부모 프로세스는 이미 다음 인자로 시작됐다. + - `--train-stage both` + - `--batch-size 4` + - `--num-workers 0` +- 이 프로세스는 메모리에 로드된 기존 Python 코드/인자를 사용하므로, 이번 코드 변경이 현재 살아 있는 부모 프로세스의 자동 predictor 실행에는 소급 적용되지 않는다. +- 따라서 현재 run에서 predictor를 고효율로 실행하려면 tokenizer checkpoint 확인 후 predictor-only를 별도로 시작하는 제어 전환이 필요하다. + +## 이번 준비 변경 + +`finetune/run_stom_1s_finetune.py`에 stage별 효율 옵션을 추가했다. + +- 공통 기존 옵션 + - `--batch-size` + - `--num-workers` +- 신규 stage별 옵션 + - `--tokenizer-batch-size` + - `--predictor-batch-size` + - `--tokenizer-num-workers` + - `--predictor-num-workers` + +미래의 `--train-stage both` 실행에서는 tokenizer는 안정 설정, predictor는 고효율 설정처럼 분리할 수 있다. + +예시: + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage both ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --run-name stom_1s_grid_pred60_2025_full_small ` + --dataset-sample-mode full_sequential ` + --batch-size 4 ` + --num-workers 0 ` + --predictor-batch-size 16 ` + --predictor-num-workers 2 ` + --n-train-iter 18806883 ` + --n-val-iter 3925397 ` + --log-interval 1000 +``` + +## 현재 진행 중인 run의 안전 전환 절차 + +1. tokenizer 100% 도달을 기다린다. +2. 아래 checkpoint가 실제 생성됐는지 확인한다. + +```text +finetune\outputs\stom_1s_grid_pred60_2025_full_small\finetune_tokenizer\checkpoints\best_model +``` + +3. checkpoint 확인 전에는 PC 재부팅, 학습 프로세스 종료, 절전 진입을 피한다. +4. checkpoint 확인 후 자동 predictor가 기존 `batch_size=4`, `num_workers=0`으로 시작되면 즉시 상태를 기록하고 predictor-only 고효율 run으로 전환한다. +5. 전환 시 원본 tokenizer checkpoint는 그대로 사용한다. + +## predictor 고효율 벤치마크 후보 + +먼저 짧은 budget으로 2~4개 후보를 비교한다. 핵심 지표는 `samples/sec`, GPU 사용률, VRAM, RAM, validation loss다. + +| 후보 | batch size | workers | 목적 | +|---|---:|---:|---| +| 안전 | 8 | 0 | 현재와 가장 유사하지만 batch 증가 | +| 1차 추천 | 16 | 0 | Windows worker 복제 리스크 없이 GPU 사용 증가 | +| 2차 추천 | 16 | 2 | 데이터 공급 병목 완화 | +| 공격적 | 24 또는 32 | 2 | VRAM 여유 활용, 안정성 확인 필요 | + +## predictor-only 벤치마크 명령 예시 + +```powershell +$tok = "D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\finetune_tokenizer\checkpoints\best_model" + +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage predictor ` + --sample-stage budget_20k ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --run-name stom_1s_grid_pred60_2025_predictor_bench_bs16_w2 ` + --dataset-sample-mode full_sequential ` + --batch-size 16 ` + --num-workers 2 ` + --finetuned-tokenizer-path $tok ` + --log-interval 1000 +``` + +## predictor 본학습 추천 시작 명령 + +벤치마크에서 문제가 없으면 아래 설정을 1차 본학습 후보로 사용한다. + +```powershell +$tok = "D:\Chanil_Park\Project\Programming\Kronos\finetune\outputs\stom_1s_grid_pred60_2025_full_small\finetune_tokenizer\checkpoints\best_model" + +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage predictor ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --run-name stom_1s_grid_pred60_2025_full_small_predictor_opt_bs16_w2 ` + --dataset-sample-mode full_sequential ` + --batch-size 16 ` + --num-workers 2 ` + --n-train-iter 18806883 ` + --n-val-iter 3925397 ` + --finetuned-tokenizer-path $tok ` + --log-interval 1000 +``` + +## 예상 완료 시간 영향 + +현재 측정 기준에서 predictor를 기존 설정 그대로 돌리면 전체 완료는 대략 2026-05-18~19 KST다. predictor 전에 최적화하면 대략 다음 범위를 기대한다. + +| predictor 개선 | 전체 완료 예상 | +|---:|---| +| 1.4x | 2026-05-17 밤 ~ 2026-05-18 | +| 1.8x | 2026-05-17 새벽~오전 | +| 2.2x | 2026-05-16 밤 ~ 2026-05-17 | + +## 중단 조건 + +- tokenizer checkpoint가 없으면 predictor 최적화 전환을 실행하지 않는다. +- `batch_size=16, workers=2`에서 OOM, RAM 급증, DataLoader 정지, samples/sec 저하가 보이면 `workers=0` 또는 `batch_size=8`로 낮춘다. +- validation loss가 기존 설정 대비 명확히 악화되면 속도보다 안정 설정을 우선한다. diff --git a/docs/stom_qlib_execution_stage_report.md b/docs/stom_qlib_execution_stage_report.md new file mode 100644 index 000000000..65dfa1455 --- /dev/null +++ b/docs/stom_qlib_execution_stage_report.md @@ -0,0 +1,170 @@ +# STOM Qlib 실제 실행 단계 점검 보고서 + +작성일: 2026-05-07 + +## 핵심 결론 + +- `pyqlib 0.9.7` 설치 후 `qlib-env-check`가 성공했다. +- pip wheel에는 `scripts/dump_bin.py`가 포함되지 않아 Microsoft Qlib source를 `.omx/external/qlib`에 clone해 사용했다. +- Qlib upstream `dump_bin.py`의 현재 인자는 `--csv_path`가 아니라 `--data_path`이므로 파이프라인 코드를 수정했다. +- STOM DB 읽기 전용 파일럿 export는 1초봉/1분봉 모두 성공했다. +- `dump_bin --execute`는 1초봉/1분봉 모두 성공했다. +- pyqlib provider smoke는 1분봉에서 성공했고, 1초봉은 pyqlib `Freq` parser가 지원하지 않는 제약을 확인했다. + +## 실행 환경 점검 + +```powershell +python finetune\qlib_stom_pipeline.py qlib-env-check +``` + +요약: + +```json +{ + "qlib_installed": true, + "qlib_version": "0.9.7", + "dump_bin_script_found": true, + "dump_bin_script": "D:\\Chanil_Park\\Project\\Programming\\Kronos\\.omx\\external\\qlib\\scripts\\dump_bin.py" +} +``` + +추가 검증: + +```powershell +python -m pip check +``` + +결과: + +```text +No broken requirements found. +``` + +## 1초봉 파일럿 + +명령: + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_stage_pyqlib_pilot ` + --max-tables 2 ` + --lookback-window 30 ` + --predict-window 5 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --freq 1s ` + --max-groups 4 ` + --train-ratio 0.5 ` + --val-ratio 0.25 ` + --test-ratio 0.25 +``` + +결과: + +| 항목 | 값 | +| --- | --- | +| selected tables | 2 | +| exported groups | 4 | +| exported rows | 4,792 | +| train rows | 1,971 | +| val rows | 1,292 | +| test rows | 1,529 | +| 주요 warning | `종가 column not found; using 현재가 as close.` | + +`dump_bin --execute --freq 1s` 결과는 성공이다. 다만 provider smoke: + +```powershell +python finetune\qlib_stom_pipeline.py provider-smoke ` + --provider-uri finetune\qlib_exports\stom_1s_stage_pyqlib_pilot\qlib_bin ` + --region cn ` + --freq 1s +``` + +결과는 의도적으로 실패 처리된다. + +```text +pyqlib provider does not support second-level freq such as '1s'. +``` + +판단: 1초봉은 Qlib provider보다 Kronos `processed_datasets/*.pkl` fine-tuning 경로를 우선 사용한다. + +## 1분봉 파일럿 + +명령: + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1min_stage_pyqlib_pilot ` + --max-tables 2 ` + --lookback-window 20 ` + --predict-window 5 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --freq 1min ` + --max-groups 4 ` + --train-ratio 0.5 ` + --val-ratio 0.25 ` + --test-ratio 0.25 +``` + +결과: + +| 항목 | 값 | +| --- | --- | +| selected tables | 2 | +| exported groups | 4 | +| exported rows | 112 | +| train rows | 57 | +| val rows | 28 | +| test rows | 27 | +| 주요 warning | `종가 column not found; using 현재가 as close.` | + +실제 변환: + +```powershell +python finetune\qlib_stom_pipeline.py dump-bin ` + --export-report finetune\qlib_exports\stom_1min_stage_pyqlib_pilot\stom_qlib_export_report.json ` + --qlib-dir finetune\qlib_exports\stom_1min_stage_pyqlib_pilot\qlib_bin ` + --dump-bin-script .omx\external\qlib\scripts\dump_bin.py ` + --freq 1min ` + --execute +``` + +결과: `returncode: 0`, `status: ok`. + +Provider smoke: + +```powershell +python finetune\qlib_stom_pipeline.py provider-smoke ` + --provider-uri finetune\qlib_exports\stom_1min_stage_pyqlib_pilot\qlib_bin ` + --region cn ` + --freq 1min +``` + +결과: + +```json +{ + "mode": "qlib_provider_smoke", + "freq": "1min", + "calendar_count": 112, + "calendar_sample": [ + "2022-12-12 09:00:00", + "2022-12-12 09:01:00", + "2022-12-12 09:02:00", + "2022-12-12 09:03:00", + "2022-12-12 09:04:00" + ] +} +``` + +## 다음 개발 판단 + +1. 1분봉 Qlib provider 경로는 실제 사용 가능 상태로 본다. +2. 1초봉 Qlib `.bin`은 생성 가능하지만 pyqlib provider 로딩은 공식 freq 제약 때문에 보류한다. +3. 1초봉 Kronos 학습은 `processed_datasets/train_data.pkl`, `val_data.pkl`, `test_data.pkl`을 직접 사용하는 기존 `finetune/train_predictor.py` 경로가 맞다. +4. 다음 단계는 1분봉 Qlib provider에서 feature load/backtest를 안정화하거나, 1초봉 Kronos 학습 자동 실행/재개/로그 대시보드 연결로 진행한다. diff --git a/docs/stom_qlib_kronos_pipeline.md b/docs/stom_qlib_kronos_pipeline.md new file mode 100644 index 000000000..7b05e3bfa --- /dev/null +++ b/docs/stom_qlib_kronos_pipeline.md @@ -0,0 +1,637 @@ +# STOM Qlib Kronos 파이프라인 개발/사용 문서 + +작성일: 2026-05-07 + +## 1. 목적 + +기존 STOM 파인튜닝은 `finetune_csv`의 grouped CSV 경로로 진행되었다. 이 문서는 STOM `stock_tick_back.db`를 Qlib 연구 흐름에 연결하기 위한 새 pilot-first 파이프라인을 설명한다. + +목표 흐름: + +```text +STOM SQLite DB +→ Qlib dump-ready CSV +→ Kronos QlibDataset pickle split +→ Kronos predictor fine-tuning +→ Kronos prediction CSV +→ Qlib-style Top-K score backtest +→ 웹 대시보드 시각화 +``` + +참고: + +- Qlib 공식 문서는 CSV를 Qlib `.bin` format으로 변환할 때 `scripts/dump_bin.py dump_all`을 사용하며, `date_field_name`, `symbol_field_name`, `include_fields`를 지정할 수 있다고 설명한다. +- Qlib의 기본 목적은 데이터 계층, feature retrieval, dataset, backtest/risk analysis를 표준화하는 것이다. +- 현재 구현은 pyqlib을 강제 dependency로 추가하지 않고, Qlib dump-ready CSV와 Kronos `QlibDataset` pickle split을 먼저 생성한다. pyqlib 설치 후에는 생성된 command로 `.bin` 변환을 이어갈 수 있다. + +## 2. 새로 추가된 구성 + +| 파일 | 역할 | +| --- | --- | +| `finetune/qlib_stom_pipeline.py` | STOM→Qlib export, Qlib-style Top-K score backtest CLI | +| `tests/test_stom_qlib_pipeline.py` | synthetic STOM DB export/backtest 검증 | +| `webui/stom_dashboard.py` | Qlib backtest artifact list/load/chart helper | +| `webui/app.py` | `/api/stom/qlib-backtests` API | +| `webui/templates/stom_dashboard.html` | Qlib Top-K equity curve와 metrics 표시 | +| `.gitignore` | 대용량 Qlib export/backtest 산출물 제외 | + +## 3. STOM DB → Qlib pilot export + +### 3.1 1초 pilot + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_pilot ` + --max-tables 50 ` + --lookback-window 300 ` + --predict-window 60 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --freq 1s ` + --max-groups 500 ` + --train-ratio 0.70 ` + --val-ratio 0.15 ` + --test-ratio 0.15 +``` + +### 3.1.1 1초 grid + 실제 초 단위 horizon + +30초 후/60초 후 예측은 단순히 `30 row 후`, `60 row 후`가 아니라 실제 시간 기준이어야 한다. STOM tick row가 중간에 비어 있을 수 있으므로 1초봉 전체 파인튜닝용 export는 다음 옵션을 사용한다. + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1s_grid_pred30_full ` + --max-tables 0 ` + --lookback-window 300 ` + --horizon-seconds 30 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --freq 1s ` + --regularize-1s ` + --split-by session ` + --train-ratio 0.70 ` + --val-ratio 0.15 ` + --test-ratio 0.15 +``` + +핵심 옵션: + +- `--regularize-1s`: 누락된 초를 1초 grid로 보정한다. 가격은 직전가 forward-fill, 거래량/거래대금은 0으로 채운다. +- `--horizon-seconds`: 1초 grid 기준 실제 N초 후 target을 명시한다. +- `--split-by session`: 같은 거래일이 train/val/test에 섞이지 않도록 날짜/session 기준으로 분리한다. + +### 3.2 1분 pilot + +1분봉은 Qlib daily/minute 연구 흐름에 더 자연스럽다. 단, `lookback + predict + 1` row를 만족해야 하므로 window를 1분 단위에 맞게 줄여야 한다. + +```powershell +python finetune\qlib_stom_pipeline.py export ` + --db _database\stock_tick_back.db ` + --output-dir finetune\qlib_exports\stom_1min_pilot ` + --max-tables 50 ` + --lookback-window 20 ` + --predict-window 5 ` + --price-mode close_only ` + --time-start 090000 ` + --time-end 093000 ` + --freq 1min ` + --max-groups 500 +``` + +## 4. Export 산출물 + +예: `finetune\qlib_exports\stom_1s_pilot` + +```text +qlib_csv/ + KR000001_20260102.csv + ... +processed_datasets/ + train_data.pkl + val_data.pkl + test_data.pkl +meta/ + calendar_1s.txt + instruments_all.txt + qlib_dump_bin_command.txt +stom_qlib_export_report.json +``` + +### 4.1 Qlib dump-ready CSV + +각 CSV는 다음 컬럼을 가진다. + +```text +symbol,date,open,high,low,close,volume,amount,money,factor +``` + +이 구조는 Qlib `dump_bin.py`에 연결하기 위한 전단계다. + +### 4.2 Kronos QlibDataset pickle + +`finetune/dataset.py`의 `QlibDataset`은 다음 pickle을 읽는다. + +```text +train_data.pkl +val_data.pkl +test_data.pkl +``` + +각 pickle은 다음 형태다. + +```python +{ + "KR000001_20260102": DataFrame( + index=datetime, + columns=["open", "high", "low", "close", "vol", "amt"] + ) +} +``` + +주의: key를 `symbol + session`으로 만든 이유는 기존 `QlibDataset`이 symbol 단위로 연속 window를 만들기 때문이다. session을 분리하지 않으면 하루 끝 window가 다음날로 넘어가는 누수가 생길 수 있다. + +## 5. pyqlib `.bin` 변환 + +export 후 다음 파일에 command가 저장된다. + +```text +meta\qlib_dump_bin_command.txt +``` + +현재 Qlib upstream `scripts/dump_bin.py` 기준 인자는 `--csv_path`가 아니라 `--data_path`이다. 예: + +```powershell +python scripts/dump_bin.py dump_all ` + --data_path finetune/qlib_exports/stom_1min_pilot/qlib_csv ` + --qlib_dir finetune/qlib_exports/stom_1min_pilot/qlib_bin ` + --date_field_name date ` + --symbol_field_name symbol ` + --include_fields open,high,low,close,volume,amount,money,factor ` + --freq 1min +``` + +pyqlib은 repo 필수 dependency가 아니라 실행 환경 dependency이다. 현재 워크스테이션에서는 `python -m pip install pyqlib`로 `pyqlib 0.9.7` 설치를 완료했고, pip 패키지에는 `dump_bin.py`가 포함되지 않아 Microsoft Qlib source를 `.omx/external/qlib`에 clone하여 `scripts/dump_bin.py`를 사용했다. `.omx`는 실행 보조 산출물이며 commit 대상이 아니다. + +### 5.1 Qlib 환경 점검 + +먼저 현재 Python 환경에 pyqlib과 `dump_bin.py`가 준비되어 있는지 확인한다. + +```powershell +python finetune\qlib_stom_pipeline.py qlib-env-check +``` + +현재 워크스테이션 점검 결과: + +```json +{ + "qlib_installed": true, + "qlib_version": "0.9.7", + "dump_bin_script_found": true, + "dump_bin_script": "D:\\Chanil_Park\\Project\\Programming\\Kronos\\.omx\\external\\qlib\\scripts\\dump_bin.py" +} +``` + +### 5.2 dump_bin 실행 + +export report에서 Qlib 변환 command를 재생성한다. `--execute`를 빼면 dry-run만 수행한다. + +```powershell +python finetune\qlib_stom_pipeline.py dump-bin ` + --export-report finetune\qlib_exports\stom_1min_pilot\stom_qlib_export_report.json ` + --qlib-dir finetune\qlib_exports\stom_1min_pilot\qlib_bin ` + --dump-bin-script .omx\external\qlib\scripts\dump_bin.py ` + --freq 1min ` + --execute +``` + +2026-05-07 파일럿 검증 결과: + +| 구분 | 결과 | +| --- | --- | +| 1초봉 export | 성공: 4개 instrument/session, 4,792 rows | +| 1초봉 dump_bin | 성공: `--freq 1s` bin 생성 | +| 1초봉 pyqlib provider smoke | 실패/제약 확인: pyqlib `D.calendar(freq="1s")`는 초봉 freq를 지원하지 않음 | +| 1분봉 export | 성공: 4개 instrument/session, 112 rows | +| 1분봉 dump_bin | 성공: `--freq 1min` bin 생성 | +| 1분봉 pyqlib provider smoke | 성공: calendar 112개 로드 | + +따라서 **Qlib provider/전략 연구는 1분봉 이상을 우선 사용**하고, **1초봉은 Kronos fine-tuning용 `processed_datasets/*.pkl` 경로를 우선 사용**한다. + +### 5.3 Qlib provider smoke test + +`.bin` 변환이 완료되면 provider를 초기화하고 calendar를 읽어본다. + +```powershell +python finetune\qlib_stom_pipeline.py provider-smoke ` + --provider-uri finetune\qlib_exports\stom_1min_pilot\qlib_bin ` + --region cn ` + --freq 1min +``` + +성공 예: + +```json +{ + "mode": "qlib_provider_smoke", + "freq": "1min", + "calendar_count": 112, + "calendar_sample": [ + "2022-12-12 09:00:00", + "2022-12-12 09:01:00" + ] +} +``` + +주의: `dump_bin.py`는 `--freq 1s` 변환 자체는 수행하지만, pyqlib provider의 `Freq` parser는 `1s`를 공식 지원하지 않는다. `provider-smoke --freq 1s`는 의도적으로 명확한 오류를 반환하게 했다. + +## 6. Kronos predictor 학습 연결 + +export된 pickle을 기존 `finetune/dataset.py`가 읽게 하려면 환경변수를 지정한다. + +```powershell +$env:KRONOS_DATASET_PATH="D:\Chanil_Park\Project\Programming\Kronos\finetune\qlib_exports\stom_1s_pilot\processed_datasets" +$env:KRONOS_LOOKBACK_WINDOW="300" +$env:KRONOS_PREDICT_WINDOW="60" +$env:KRONOS_EPOCHS="1" +$env:KRONOS_BATCH_SIZE="8" +$env:KRONOS_N_TRAIN_ITER="2000" +$env:KRONOS_N_VAL_ITER="400" +$env:KRONOS_USE_COMET="0" +$env:KRONOS_PRETRAINED_TOKENIZER_PATH="NeoQuasar/Kronos-Tokenizer-base" +$env:KRONOS_PRETRAINED_PREDICTOR_PATH="NeoQuasar/Kronos-small" +``` + +그 다음 기존 Qlib predictor fine-tune entry를 사용한다. + +```powershell +python finetune\train_predictor.py +``` + +주의: + +- 이 script는 DDP/CUDA 전제를 포함한다. +- 실전 장시간 학습 전에는 작은 pilot pickle로 loader smoke test를 먼저 수행한다. +- 현재 `finetune_csv/train_sequential.py` 경로가 이미 검증되어 있으므로, Qlib 경로는 pilot부터 단계적으로 확대한다. + +## 7. Kronos prediction CSV → Qlib-style Top-K backtest + +기존 예측 CSV를 score로 사용한다. + +```powershell +python finetune\qlib_stom_pipeline.py score-backtest ` + --prediction-csv webui\stom_predictions\kronos_all_predictions.csv ` + --output-dir webui\qlib_backtests ` + --top-k 10 ` + --cost-bps 15 ` + --slippage-bps 10 +``` + +산출물: + +```text +webui/qlib_backtests/*.json +webui/qlib_backtests/*.curve.csv +webui/qlib_backtests/*.trades.csv +``` + +실제 실행 예시 결과: + +```text +period_count: 300 +trade_count: 300 +avg_gross_return_pct: 0.0094 +avg_net_return_pct: -0.2406 +hit_rate: 0.4567 +direction_hit_rate: 0.4 +cumulative_return_pct: -51.77 +max_drawdown_pct: -52.11 +``` + +중요 해석: + +- 기존 `kronos_all_predictions.csv`는 각 window의 `asof_timestamp`가 대부분 달라 진짜 cross-sectional Top-K가 아니다. +- 그래서 평균 선택 수가 Top-K보다 작으면 artifact에 warning을 남긴다. +- 진짜 Qlib Top-K를 하려면 같은 `asof_timestamp`에서 여러 종목 score를 생성하는 prediction export가 필요하다. + +## 8. 웹 대시보드 + +실행: + +```powershell +python webui\run.py +``` + +접속: + +```text +http://localhost:7070/stom +``` + +추가된 기능: + +- Qlib Top-K backtest artifact 선택 +- period/trade/top-k metrics +- gross/net return +- cumulative return / MDD +- hit / direction / Sharpe +- equity curve +- top trades table + +API: + +```text +GET /api/stom/qlib-backtests +GET /api/stom/qlib-backtests?file= +``` + +## 9. 전체 DB로 확대하는 순서 + +1. `--max-tables 50`, `--max-groups 500` pilot +2. 변환 report 확인 +3. `QlibDataset` loader smoke test +4. predictor 1 epoch smoke 학습 +5. prediction CSV 생성 +6. score-backtest와 dashboard 확인 +7. `--max-tables 300` +8. `--max-tables 1000` +9. 제한 해제 또는 날짜 구간별 batch export + +## 10. 아직 남은 중요한 과제 + +1. 동일 asof timestamp의 cross-sectional prediction 생성기 +2. Qlib `TopkDropoutStrategy` 직접 연결 +3. 1초 데이터는 pyqlib provider 공식 freq 제약 때문에 Kronos pickle 경로와 분리 운영 +4. 비용/슬리피지 모델 정교화 +5. 최근 날짜 완전 holdout 기준 학습/평가 +6. 전체 DB 장시간 export/train의 재개/로그/대시보드 상태 표시 + +## 11. 최종 판단 + +이번 구현은 Qlib 적용의 실제 실행 게이트까지 통과했다. + +```text +완료: STOM DB → Qlib dump-ready CSV/pickle split → dump_bin 실제 변환 → 1분봉 pyqlib provider smoke → Qlib-style score backtest → dashboard +제약: 1초봉 dump_bin 변환은 가능하지만 pyqlib provider freq는 1s를 지원하지 않아 Kronos pickle 학습 경로를 우선 사용 +미완료: Qlib TopkDropoutStrategy 실연동, cross-sectional prediction 생성, 전체 DB 장시간 학습 +``` + +따라서 이제는 “Qlib을 써야 하나?”를 논의하는 단계에서 벗어나, 1분봉 이상 STOM 데이터를 Qlib 연구 체계로 넣어 검증할 수 있는 기반이 생겼다. + +## 12. 2026-05-08 1초봉 전체 export 완료 + +1초봉은 pyqlib provider의 `1s` freq 제약 때문에 `.bin` provider 학습 경로가 아니라 Kronos `QlibDataset` pickle 경로를 우선한다. 이번 단계에서 전체 `stock_tick_back.db`를 대상으로 30초/60초 horizon 학습용 pickle을 생성했다. + +- 30초 output: `finetune/qlib_exports/stom_1s_grid_pred30_full/processed_datasets` +- 60초 output: `finetune/qlib_exports/stom_1s_grid_pred60_full/processed_datasets` +- 각 horizon: 73,900 groups, 131,470,857 rows +- 성공 stock table: 2,425 +- 제외 table: `moneytop`, `stockinfo` (OHLCV stock table 아님) +- split: session 기준, train/val/test 거래일 overlap 0 + +따라서 이후 단계는 `dump_bin/provider-smoke`가 아니라 `KRONOS_DATASET_PATH`를 위 `processed_datasets`로 지정한 `finetune/train_predictor.py` 실행이다. + +## 13. 2026-05-08 1초봉 budgeted 파인튜닝 완료 + +`finetune/run_stom_1s_finetune.py` launcher로 pred30/pred60 전체 QlibDataset pickle을 실제 Kronos predictor 학습 루프에 연결했다. + +핵심 실행 결과: + +- pred30 possible train window: 75,277,195 +- pred60 possible train window: 73,718,875 +- 각 horizon budgeted run: train 20,000 sample, val 4,000 sample, batch 4, 5,000 train step +- pred30 best val loss: 2.1549 +- pred60 best val loss: 2.1302 +- checkpoint 저장 완료: + - `finetune/outputs/stom_1s_grid_pred30_full_budget/finetune_predictor/checkpoints/best_model` + - `finetune/outputs/stom_1s_grid_pred60_full_budget/finetune_predictor/checkpoints/best_model` + +이 결과는 “STOM 1초봉 전체 데이터로 Kronos 파인튜닝 실행이 가능한가?”에 대한 실행 증거다. 단, 아직 “매매 정확도 개선” 증거는 아니므로 다음 단계에서 checkpoint prediction CSV와 holdout actual을 비교해야 한다. + +## 14. 2026-05-08 checkpoint 예측 CSV와 baseline 비교 + +`finetune/evaluate_stom_1s_checkpoint.py`로 budgeted checkpoint의 holdout prediction CSV를 생성했다. CSV는 `webui/stom_predictions`에 저장되며 기존 `/stom` 대시보드에서 실제값/예측값 차트로 확인할 수 있다. + +생성된 주요 파일: + +- `stom_1s_pred30_budget_holdout_eval_kronos.csv` +- `stom_1s_pred30_budget_holdout_eval_persistence.csv` +- `stom_1s_pred30_budget_holdout_eval_random.csv` +- `stom_1s_pred60_budget_holdout_eval_kronos.csv` +- `stom_1s_pred60_budget_holdout_eval_persistence.csv` +- `stom_1s_pred60_budget_holdout_eval_random.csv` + +제한 샘플 평가 결과: + +- 30초 Kronos direction accuracy: 0.3704 +- 60초 Kronos direction accuracy: 0.4444 +- 60초는 0.40을 넘었지만, Qlib-style Top-K net return은 음수다. + +따라서 다음 단계는 단순 정확도 확인이 아니라 walk-forward 표본 확대, 필터 조건식 보완, Top-K net return 개선 여부 검증이다. + +## 15. 2026-05-08 pred60 walk-forward와 조건식 필터 검증 + +`finetune/evaluate_stom_1s_checkpoint.py`는 checkpoint 예측 CSV에 예측 시점 feature를 추가로 기록한다. 이 feature는 미래 실제값을 쓰지 않으므로 조건식 또는 종가/단기 추천 점수화에 사용할 수 있다. + +추가된 주요 feature: + +- `pred_path_consistency`: 예측 경로가 기준가 대비 상승/하락 방향을 얼마나 일관되게 유지했는지 +- `pred_range_pct`: 예측 경로의 가격 폭 +- `history_volatility_pct`: lookback 구간 변동성 +- `history_mean_amount`: lookback 구간 평균 거래대금 + +조건식 탐색은 `finetune/search_stom_1s_filters.py`로 수행한다. + +```powershell +python finetune\search_stom_1s_filters.py ` + --prediction-csv webui\stom_predictions\stom_1s_pred60_walkforward30x3_eval_kronos.csv ` + --output-dir webui\qlib_backtests ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 ` + --min-trades 30 ` + --min-periods 30 ` + --min-coverage 0.5 +``` + +현재 best robust 조건은 다음과 같다. + +```text +pred_return_window >= 0.05 +history_volatility_pct <= 0.2 +``` + +더 좁힌 opportunistic 조건은 다음과 같다. + +```text +pred_return_window >= 0.05 +pred_range_pct <= 0.1 +history_volatility_pct <= 0.2 +``` + +하지만 25bp 비용 가정에서는 opportunistic 조건도 평균 net -0.0089%로 아직 양수 전환하지 못했다. 그러므로 이 조건식은 실전 매수 승인 규칙이 아니라 다음 확대 검증의 후보 규칙으로만 취급한다. + +대시보드는 Qlib Top-K JSON과 filter-search JSON을 같은 폴더에서 보게 되므로, `metrics`가 있는 Qlib Top-K artifact만 backtest 목록에 노출하도록 안전 처리했다. filter-search JSON은 현재 문서와 파일 artifact로 확인하며, 다음 단계에서 별도 패널로 승격할 수 있다. + +## 16. 2026-05-08 rolling filter validation과 대시보드 패널 + +조건식 과최적화를 확인하기 위해 `finetune/search_stom_1s_filters.py`에 rolling validation 모드를 추가했다. + +```powershell +python finetune\search_stom_1s_filters.py ` + --prediction-csv webui\stom_predictions\stom_1s_pred60_walkforward30x3_eval_kronos.csv ` + --output-dir webui\qlib_backtests ` + --prefix stom_1s_pred60_walkforward30x3_eval_kronos_rolling30x30 ` + --top-k 5 ` + --cost-bps 15 ` + --slippage-bps 10 ` + --min-trades 10 ` + --min-periods 10 ` + --min-coverage 0.25 ` + --rolling-validate ` + --rolling-train-periods 30 ` + --rolling-test-periods 30 ` + --rolling-step-periods 30 +``` + +이번 결과: + +- avg train net: +0.0519% +- avg test net: -0.0351% +- avg test baseline net: -0.2438% +- baseline 대비 test 개선폭: +0.2087%p +- weighted test direction hit: 0.4615 +- positive test fold rate: 0.5 + +해석: + +```text +조건식은 baseline 대비 손실을 줄이는 효과가 rolling test에서도 유지된다. +그러나 25bp 비용 후 평균 test net이 아직 음수이므로 실전 승인 조건은 아니다. +``` + +대시보드 추가: + +- `GET /api/stom/filter-reports` +- filter-search JSON 목록/상세 표시 +- rolling validation JSON 목록/상세 표시 +- fold별 train net, test net, baseline net, 개선폭 표시 + +다음 대형 평가 규모는 `max_sessions 100`, `max_asofs 5`, `max_symbols 50`이며 예상 최대 window는 25,000개, horizon 60초 기준 mode당 row는 1,500,000개다. GPU 추론 시간이 길 수 있어 별도 장시간 실행 단계로 진행한다. + +## 17. 2026-05-09 staged full-training launcher + +전체 STOM tick dataset은 이미 학습 루프에 연결되어 있지만, 현재 실제 fine-tuning은 20,000 train sample budget으로 수행되었다. 전량 학습을 단계적으로 확대하기 위해 `finetune/run_stom_1s_finetune.py`에 `--sample-stage` 옵션을 추가했다. + +지원 stage: + +| stage | train samples | val samples | +| --- | ---: | ---: | +| `budget_20k` | 20,000 | 4,000 | +| `expand_200k` | 200,000 | 40,000 | +| `expand_1m` | 1,000,000 | 100,000 | +| `expand_5m` | 5,000,000 | 250,000 | +| `full_window` | horizon별 전체 possible train | horizon별 전체 possible val | + +예시: + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --sample-stage expand_200k ` + --output-root finetune\outputs ` + --dry-run +``` + +주의: `full_window`는 pred60 기준 73,718,875 train samples이므로 즉시 실행 대상이 아니다. 먼저 대형 walk-forward와 rolling validation에서 비용 후 성과가 개선되는지 확인한 뒤 `expand_200k`부터 실행한다. + +## 18. 2026-05-09 pred60 대형 walk-forward 게이트 결과 + +상세 보고서: `docs/stom_1s_large_walkforward_gate_report.md` + +이번 단계에서는 `expand_200k` 실제 학습으로 넘어가기 전에, 기존 pred60 `budget_20k` checkpoint를 더 큰 holdout walk-forward 표본으로 검증했다. + +핵심 수치: + +| 항목 | 값 | +| --- | ---: | +| selected windows | 3,080 | +| rebalance periods | 500 | +| rows per mode | 184,800 | +| Kronos direction accuracy | 0.4312 | +| random direction accuracy | 0.4084 | +| persistence direction accuracy | 0.1487 | +| Qlib Top-K avg net return | -0.1953% | +| best robust filter avg net return | -0.1266% | +| rolling avg test net return | -0.1766% | +| rolling positive test fold rate | 0.25 | + +판단: + +```text +expand_200k 실제 학습은 이번 단계에서 실행하지 않는다. +방향성 신호는 random보다 높지만, 비용 후 수익성과 rolling 안정성이 아직 기준 미달이다. +``` + +따라서 다음 단계는 학습량 확대가 아니라 score/filter 구조 개선, 비용 민감도 분석, pred30/pred60 ensemble 후보 검증이다. rolling 평균 test net이 0 이상으로 올라오고 여러 fold에서 반복 개선이 확인될 때만 `--sample-stage expand_200k` 학습으로 넘어간다. + +현재 판단: + +```text +Page 1 DB 구조 분석 [█████] 100% +Page 2 STOM tick OHLCV/QlibDataset 구축 [█████] 100% +Page 3 bounded/pilot 학습 검증 [████░] 70% +Page 4 1초봉 전체 학습 루프 연결 [█████] 100% +Page 5 30초/60초 20k 파인튜닝 [█████] 100% +Page 6 대형 walk-forward/rolling 검증 [█████] 95% +Page 7 웹 대시보드/검증 산출물 확인 [████░] 82% +Page 8 staged full-training 계획 [████░] 88% +Page 9 expand/full-window 실제 확대 학습 [░░░░░] 0% +전체 진행률 [█████░] 91% +``` + +주의: 여기서 전체 진행률은 “파이프라인 구축과 검증 체계” 기준이다. STOM tick의 모든 possible window를 실제로 끝까지 학습한 것은 아니며, 확대 학습은 게이트 미충족으로 보류한다. + +## 19. 2026-05-09 cost sensitivity gate 자동화 + +상세 보고서: `docs/stom_1s_cost_gate_analysis_report.md` + +`finetune/search_stom_1s_filters.py`에 `--gate-analysis` 모드를 추가했다. 이 모드는 기존 filter-search와 rolling-validation JSON을 재사용해 비용 민감도와 확대 학습 승인 여부를 계산한다. + +생성 artifact: + +```text +*.cost_gate.json +*.cost_gate_rolling_sensitivity.csv +``` + +대시보드 artifact 유형도 다음처럼 확장했다. + +```text +filter_search +rolling_filter_validation +cost_sensitivity_gate +``` + +현재 대형 pred60 결과에서는 5bp 비용 가정만 통과하고, target 25bp 기준은 실패했다. + +| total cost | rolling avg test net | positive fold rate | gate | +| ---: | ---: | ---: | --- | +| 5bp | +0.0234% | 0.500 | PASS | +| 10bp | -0.0266% | 0.375 | FAIL | +| 15bp | -0.0766% | 0.375 | FAIL | +| 25bp | -0.1766% | 0.250 | FAIL | + +따라서 pipeline의 안전 순서는 다음과 같다. + +```text +prediction CSV +-> Qlib Top-K/filter-search +-> rolling validation +-> cost_sensitivity_gate +-> target 25bp gate 통과 시에만 expand_200k fine-tuning +``` diff --git a/docs/stom_rl_1min_signal_verdict_2026-05-27.md b/docs/stom_rl_1min_signal_verdict_2026-05-27.md new file mode 100644 index 000000000..c3fa9f8c3 --- /dev/null +++ b/docs/stom_rl_1min_signal_verdict_2026-05-27.md @@ -0,0 +1,292 @@ +# STOM Portfolio — 1-Minute-Horizon Tradeable-Selection-Alpha Verdict (NON-ADVISORY) + +Plan: `.omx/plans/ralplan-stom-rl-1min-signal-2026-05-27.md` (Stages 3–5) +Pre-registration (LOCKED, M=1): `docs/stom_rl_1min_stage1_prereg_2026-05-27.md` +Repo: `Kronos` · Branch: `feature/stom-rl-lab` · Baseline: `20fb0fe` · Python `py -3.11` (3.11.9) · Windows +Evidence artifacts (NOT committed — `.omx/` is gitignored): `.omx/artifacts/stom_rl_1min_signal/` +Date: 2026-05-27 + +--- + +## 0. The decisive question + the answer up front + +> Does tradeable stock-selection alpha appear at the **1-minute** horizon (with the four new +> causal trend features), NON-ADVISORY (`n_folds = 5 ≥ 5`), measured by the cheap +> `supervised_ranker` vs baselines after **25 bps** cost + the **mandatory shuffle test**? +> This directly contrasts the PROVEN-null 1-second result. + +### NON-ADVISORY VERDICT: **NO.** There is no tradeable stock-selection edge at the 1-minute horizon either. + +Widening the decision horizon 60× (1s → 1min) and adding the trend/momentum feature class the +1s set lacked (이동평균 / 변동성 / 거래대금각도 / 등락율각도) **did not change the conclusion.** +On the same co-dated universe (111 symbols, 2022-08-30, 09:00–09:30), with the same harness and the +same 25 bps cost, the cheap `supervised_ranker`: + +- **Beats `equal_weight` on only 2 of 5 disjoint folds** — FAILS the pre-registered strict-majority + threshold of 3/5. +- **Loses to `no_trade` on the mean** (ranker mean cost-adjusted return **−0.1023%** vs `no_trade` + **0.0000%**) — every active policy bleeds money after cost. +- **Does NOT survive the shuffle test — and fails it in the most damning way possible:** the SHUFFLED + signal scores a **3/5** majority over `equal_weight` while the REAL signal scores only **2/5**. The + real selection signal is **no better than (indeed slightly worse than) destroyed-signal noise**, so + there is no real edge for the shuffle to "fail to reproduce." + +This is the expected, valid completion (plan §0, Principle 4): a documented NON-ADVISORY negative, +reported with real per-fold numbers, not massaged. **Deep-RL (MaskablePPO, `sb3-contrib`) is NOT +justified** — the cheap falsifier finds nothing at 1-min, so the expensive path stays DEFERRED. The +honest next direction is a **daily / multi-day horizon or external signal**, NOT more intraday RL. + +--- + +## 1. Universe / session + per-fold candidate counts + +| Choice | Value | Rationale | +|---|---|---| +| Session | **2022-08-30** (`20220830`) | The most-co-dated session established by the 1s phase (112 tables → **111** real 6-digit stock tables). Reused verbatim for a clean **1s↔1min controlled contrast** on the identical harness/universe/cost (bounded co-dated probe, NO full-DB scan). | +| Window | **09:00:00 – 09:30:00** | The full recorded window for these symbols (open + first 30 min). | +| Symbols | **111 co-dated** (67 produced ≥1 candidate) | Bounded read of one session × 111 tables × 30-min window. NOT a full-universe scan. | +| Rule | **`buy_demand_pressure`** | The most permissive rule — gives the ranker the largest, fairest candidate pool to find selection signal in. | +| Freq | **`1min`** | Stage-2 net-new RL resampler (per-column SUM/LAST semantics, `floor("min")`, `close=last`); resampled BEFORE the feature builder. | +| Cost | **`cost_bps = 25`** (explicit) | Reused from the 1s phase for a clean decision-rate contrast. No zero-cost victory laps. | + +### 1-min candidate generation (Stage 3) + +`py -3.11 -m stom_rl.candidate_gen --freq 1min` → +**296 candidates · 292 fillable · 4 unfillable (last-bar T+1=NaN) · 31 distinct 1-min timestamps.** +T+1 fill contract preserved (grid-agnostic `condition_screener.py:274` `shift(-1)` = next 1-min bar). + +### Per-fold candidate counts (`n_folds = 5`, expanding-window disjoint holdout) + +| Fold | TRAIN candidates | TEST candidates | TEST window (disjoint, strictly later) | +|---|---|---|---| +| 0 | 118 | 67 | 09:06:00 – 09:10:00 | +| 1 | 185 | 38 | 09:11:00 – 09:15:00 | +| 2 | 223 | 24 | 09:16:00 – 09:20:00 | +| 3 | 247 | 20 | 09:21:00 – 09:25:00 | +| 4 | 267 | 29 | 09:26:00 – 09:30:00 | + +(31 distinct 1-min timestamps → 6 disjoint time segments → 5 evaluated folds. V-COVERAGE cleared: +31 ≥ 6.) + +--- + +## 2. Gate ordering (R7) — feature-integrity gates BEFORE the alpha verdict + +The plan makes feature non-degeneracy a hard precondition for the alpha verdict (a constant/zero +feature shuffles to itself and would pass the shuffle test vacuously, manufacturing a false null). + +### V-NONDEGEN (ran FIRST, on the actual 1-min candidate set) — **PASS** + +Every gate-named 1-min feature column has strictly non-zero variance and ≥2 distinct values, so the +silent-zero false-null hazard (pre-mortem #4) is ruled out. Critically, the **four new trend features +are genuinely non-degenerate at 1-min**: + +| 1-min feature (`feature_*`) | variance (ddof=0) | distinct values | non-degenerate? | +|---|---|---|---| +| `trade_strength` | 9,706.74 | 271 | yes | +| `bid_ask_imbalance` | 0.008552 | 289 | yes | +| `change_rate` | 14.3024 | 176 | yes | +| `moving_average_n` (이동평균) | 2,798,169,941.18 | 266 | yes | +| `volatility_n` (변동성) | 0.7232 | 250 | yes | +| `amount_slope_n` (거래대금각도) | 77,422.78 | 262 | yes | +| `change_rate_slope_n` (등락율각도) | 0.4170 | 255 | yes | + +### V-COVERAGE — **PASS** (31 distinct 1-min timestamps ≥ 6; 67 symbols produced candidates) + +Because V-NONDEGEN + V-COVERAGE PASS, the alpha verdict below counts: the features are real, so the +shuffle/majority gates have teeth. + +--- + +## 3. Pre-registration (LOCKED at Stage 1 — copied verbatim, M=1, NO post-hoc tuning) + +| Item | LOCKED value | +|---|---| +| freq | **1min** | +| Trailing window `N` | **10** one-min bars | +| 변동성 ddof | **0** (population std) | +| `cost_bps` | **25.0** | +| `M` (config count) | **1** | +| Primary policy | **`supervised_ranker`** (`trained_ppo` DEFERRED) | +| `top_k` / `max_positions` | **10 / 10** | +| `n_folds` / majority | **5** / **⌈6/2⌉ = 3 of 5** | +| Holding / fill | **1-bar step**; fill at **T+1** (next 1-min bar) | +| Turnover gate | ranker mean turnover ≤ `equal_weight` × **1.25** | +| `amount` aggregation | **SUM** (`초당거래대금`, per-second — Stage-1 §2) | +| Shuffle seed | **0** (paired real-vs-shuffle) | + +`M = 1` ⇒ no Bonferroni/BH correction owed (single pre-registered config; no config search). +`n_folds = 5 ≥ 5` ⇒ the verdict is **NON-ADVISORY**. The pre-registration was honored exactly; no +constant was tuned after seeing results. (`--max-steps-per-fold 0` = no truncation, so the full TEST +segment of each fold is evaluated — the only run parameter not numerically pinned by the +pre-registration, chosen for honesty, not to favor any policy.) + +--- + +## 4. The numbers (REAL, cost-adjusted `return_pct`, `cost_bps = 25`, `n_folds = 5`) + +### Per-fold cost-adjusted return % (REAL, seed 100) + +| Policy | f0 | f1 | f2 | f3 | f4 | **mean** | vs `no_trade` | +|---|---|---|---|---|---|---|---| +| `no_trade` | 0.0000 | 0.0000 | 0.0000 | 0.0000 | 0.0000 | **0.0000** | — (baseline) | +| `equal_weight_candidate` | −0.1420 | −0.2194 | −0.1856 | −0.2041 | −0.5391 | **−0.2580** | loses | +| `buy_and_hold` | −0.1420 | −0.2194 | −0.1856 | −0.2041 | −0.5391 | **−0.2580** | loses | +| `rule_baseline` | −0.2372 | −0.2500 | −0.2784 | −0.0961 | −0.6016 | **−0.2927** | loses | +| **`supervised_ranker`** | −0.7843 | +0.8141 | −0.2469 | −0.2203 | −0.0739 | **−0.1023** | **loses** | + +### Worst-fold MDD / mean turnover / mean trades (REAL) + +| Policy | worst-fold MDD % | mean turnover | mean trades | +|---|---|---|---| +| `no_trade` | 0.0000 | 0 | 0.0 | +| `equal_weight_candidate` | −0.5391 | 997,506 | 4.0 | +| **`supervised_ranker`** | **−0.7843** | 997,506 | 4.0 | + +The ranker's **worst-fold MDD (−0.7843%) is worse than equal_weight's (−0.5391%)**. Its single +positive fold (f1, +0.8141%) is one lucky draw against four losing/break-even folds — the mean stays +negative and below `no_trade`. + +### `supervised_ranker` vs `equal_weight_candidate` — per-fold win/loss (REAL) + +| Fold | ranker % | equal_weight % | diff | result | +|---|---|---|---|---| +| 0 | −0.7843 | −0.1420 | −0.6424 | lose | +| 1 | +0.8141 | −0.2194 | +1.0335 | WIN | +| 2 | −0.2469 | −0.1856 | −0.0613 | lose | +| 3 | −0.2203 | −0.2041 | −0.0162 | lose | +| 4 | −0.0739 | −0.5391 | +0.4652 | WIN | + +**REAL majority over `equal_weight`: 2/5 folds** → strict-majority threshold (3/5) **NOT met.** + +--- + +## 5. Mandatory shuffle / permutation sanity check (LOCKED shuffle-seed 0) + +Mechanism (`stom_rl/portfolio_walk_forward.py:shuffle_candidate_signal`): within each timestamp, the +values of `rank_score` AND every `feature_*` column are **OVERWRITTEN** with an independent +within-timestamp permutation, while `price`/`fill_price`/`symbol`/`fillable` are left untouched — so +fills and cost accounting are byte-identical and ONLY the selection signal is destroyed. + +### REAL vs SHUFFLE — `supervised_ranker` win-rate over `equal_weight` + +| Run | ranker wins vs EW | ranker mean % | EW mean % | ranker beats `no_trade`? | +|---|---|---|---|---| +| REAL, seed 100 | **2/5** | −0.1023 | −0.2580 | NO | +| SHUFFLE, seed 0 | **3/5** | −0.0436 | −0.2353 | NO | + +**Reading the shuffle result (decisive):** the destroyed-signal SHUFFLE achieves a **higher** majority +(3/5) and a **better** mean (−0.0436%) than the REAL signal (2/5, −0.1023%). When random noise +out-selects your model, the model carries **no real selection information**. There is no positive, +shuffle-surviving edge — the alpha gate's shuffle clause fails outright. (Neither REAL nor SHUFFLE +beats `no_trade`: every active policy loses to cash after 25 bps.) + +--- + +## 6. NON-ADVISORY verdict + the alpha gate, item by item + +| Alpha-gate clause | Result | +|---|---| +| Ranker beats `equal_weight` on strict majority (≥3/5) — REAL | **FAIL** (2/5) | +| Ranker beats `no_trade` on the mean — REAL | **FAIL** (−0.1023% < 0.0000%) | +| Turnover gate (ranker ≤ EW × 1.25) | pass (ranker turnover ≈ EW; moot given the above) | +| Survives shuffle (real majority holds, shuffle does NOT reproduce it) | **FAIL** (shuffle 3/5 ≥ real 2/5) | +| Multiplicity (M=1, n_folds=5≥5, pre-registration honored) | clean (no correction owed) | + +> ### VERDICT: There is NO tradeable stock-selection edge at the 1-minute horizon on this universe/feature set. +> ### Deep-RL (MaskablePPO) is NOT justified. PPO stays DEFERRED. STOP — do not spend RL compute here. + +### Pivot recommendation (invest in SIGNAL/horizon, NOT more intraday RL) + +1. **Pivot to a daily / multi-day horizon**, where a 25 bps round-trip is a far smaller fraction of the + per-decision move and trend/momentum signals have a documented a-priori plausibility. The Stage-2 + net-new RL resampler + the four causal trend features are **durable, reusable infrastructure** for + this pivot (daily relocates — does not escape — the same aggregation hazard, already solved). +2. **Or ingest an external / cross-sectional signal** (fundamentals, sector/regime context, news/flow) + that the intraday tick feed does not contain. +3. **Do NOT** install `sb3-contrib`/MaskablePPO or launch a multi-hour full-universe RL run on this + data — the cheap supervised floor failed at both 1s and 1min, so the binding constraint is + confirmed to be **signal/data + intraday market efficiency**, not the algorithm. + +--- + +## 7. Direct 1s ↔ 1min contrast — did widening the horizon change the conclusion? + +**No.** Same universe (111 co-dated symbols, 2022-08-30), same harness, same 25 bps cost, same cheap +`supervised_ranker`, same mandatory shuffle test — only the decision horizon (and the added trend +feature class) changed. + +| Dimension | 1-second (proven null) | **1-minute (this verdict)** | +|---|---|---| +| Decision rate | every 1s (~1,800 bars/session) | every 1min (~31 bars/session, ~60× fewer) | +| Features | 18 microstructure | 22 (incl. 4 causal trend features) | +| Ranker beats EW (REAL) | nominal 3/5 (a +0.0001% tie mirage) | **2/5 (majority NOT met)** | +| Ranker mean vs `no_trade` | loses (−0.1472% < 0) | loses (−0.1023% < 0) | +| Shuffle outcome | real 3/5 → shuffle 1/5 & 2/5 (thin edge, no magnitude) | **real 2/5 ≤ shuffle 3/5 (noise out-selects the model)** | +| Beats `no_trade`? | NO | **NO** | +| Verdict | NON-ADVISORY NO | **NON-ADVISORY NO** | + +Amortizing the cost 60× and adding trend/momentum features did NOT surface a tradeable selection +edge. If anything, the 1-min shuffle result is **more damning** than the 1s one: at 1s the real signal +at least beat its shuffle (3/5 vs 1–2/5, a thin but real-direction edge with no magnitude); at 1-min +the real signal is beaten BY its own shuffle (2/5 vs 3/5). The conclusion is robust across the horizon +change: **intraday stock-selection alpha is absent for this data/feature set** — the next informative +experiment is a longer (daily/multi-day) horizon or external data, not more intraday RL. + +--- + +## 8. Anti-false-alpha guard status + +| Guard | Status | +|---|---| +| V-NONDEGEN (1-min features non-zero variance — ran BEFORE the alpha gate, R7) | **PASS** (incl. all 4 trend features) | +| V-COVERAGE (≥6 distinct 1-min timestamps) | **PASS** (31) | +| Disjoint, strictly-later holdout (runtime asserts, `portfolio_walk_forward.py` L691-696) | **PASS** (both runs exited 0; no AssertionError across 5 folds) | +| Explicit non-zero `cost_bps` in eval | **PASS** (`cost_bps = 25` on every fold row) | +| Mandatory shuffle / permutation test (LOCKED shuffle-seed 0) | **RUN** — real 2/5 ≤ shuffle 3/5 (edge does NOT survive) | +| Supervised-ranker floor | **REPORTED** — the floor model finds no edge over `no_trade`/`equal_weight`; RL correctly NOT run | +| Multiplicity + power (M=1, n_folds=5≥5) | `M = 1` (no correction owed) AND `n_folds = 5 ≥ 5` → **NON-ADVISORY** | +| Pre-registration honored (M=1, no post-hoc tuning) | **YES** — §3 constants used verbatim from the LOCKED Stage-1 doc | + +--- + +## 9. Reproduction + +All commands `py -3.11`, from repo root, `PYTHONUTF8=1` (console encoding). Artifacts land under +`.omx/artifacts/stom_rl_1min_signal/` (gitignored). The candidate build (111 symbols, 2022-08-30, +09:00–09:30) is a bounded per-session read — never a full-DB scan. + +```bash +# Stage 3 — 1-min candidates (buy_demand_pressure, top_k report=10): +py -3.11 -m stom_rl.candidate_gen \ + --db _database/stock_tick_back.db \ + --tables <111 co-dated symbols of 2022-08-30> \ + --session 20220830 --time-start 090000 --time-end 093000 \ + --freq 1min \ + --rules stom_rl/rules/buy_demand_pressure.json \ + --output .omx/artifacts/stom_rl_1min_signal/cand_buy_demand_pressure_20220830_1min.csv \ + --topk-report .omx/artifacts/stom_rl_1min_signal/topk_report_1min.json --top-k 10 + +# Stage 4 (REAL) — n_folds=5, all baselines + ranker, explicit cost, top_k=max_pos=10: +py -3.11 -m stom_rl.portfolio_walk_forward \ + --candidate-csv .omx/artifacts/stom_rl_1min_signal/cand_buy_demand_pressure_20220830_1min.csv \ + --output-dir .omx/artifacts/stom_rl_1min_signal/real \ + --baselines no_trade,equal_weight_candidate,buy_and_hold,rule_baseline,supervised_ranker \ + --n-folds 5 --cost-bps 25 --top-k-candidates 10 --max-positions 10 --max-steps-per-fold 0 --seed 100 + +# Stage 4 (SHUFFLE) — same config + LOCKED shuffle-seed 0: +py -3.11 -m stom_rl.portfolio_walk_forward \ + --candidate-csv .omx/artifacts/stom_rl_1min_signal/cand_buy_demand_pressure_20220830_1min.csv \ + --output-dir .omx/artifacts/stom_rl_1min_signal/shuffled \ + --baselines no_trade,equal_weight_candidate,buy_and_hold,rule_baseline,supervised_ranker \ + --n-folds 5 --cost-bps 25 --top-k-candidates 10 --max-positions 10 --max-steps-per-fold 0 --seed 100 \ + --shuffle-signal --shuffle-seed 0 +``` + +Both walk-forward runs exit 0 with real 5-fold output. + +--- + +*Stage 3–5 NON-ADVISORY 1-min signal verdict. Doc-only deliverable; the supporting `.omx/` artifacts +are intentionally not committed. M=1 pre-registration (`docs/stom_rl_1min_stage1_prereg_2026-05-27.md`) +honored exactly — no post-hoc tuning.* diff --git a/docs/stom_rl_1min_stage1_prereg_2026-05-27.md b/docs/stom_rl_1min_stage1_prereg_2026-05-27.md new file mode 100644 index 000000000..dbffb1b3c --- /dev/null +++ b/docs/stom_rl_1min_stage1_prereg_2026-05-27.md @@ -0,0 +1,253 @@ +# Stage 1 — 1-Min-Horizon Signal Probe: Feasibility + Pre-Registration (2026-05-27) + +Plan: `.omx/plans/ralplan-stom-rl-1min-signal-2026-05-27.md` (Stage 1 EXIT gates) +Repo: `Kronos` · Branch: `feature/stom-rl-lab` · Baseline: `e5a1116` · Python `py -3.11` · Windows +DB: `_database/stock_tick_back.db` (29.7 GB, read-only `mode=ro` / `query_only=ON`) +Probe (read-only, bounded — NOT committed): `.omx/artifacts/stom_rl_1min_stage1/stage1_1min_prereg_probe.py` +Probe artifact (NOT committed): `.omx/artifacts/stom_rl_1min_stage1/stage1_probe.json` + +> This document is the anti-data-mining PRE-REGISTRATION. Every value in §4 is +> LOCKED here, BEFORE any model/training run, and copied verbatim into the +> Stage-4 run report. No post-hoc tuning may alter these constants. + +--- + +## GO / NO-GO — conclusion first + +**GO for Stage 2.** All Stage-1 EXIT gates pass: + +- **Trend-feature feasibility:** the three source columns backing the four trend + features are **100.0% non-null post-1-min-resample in every sampled session** + (worst-case = 1.0, threshold = 0.80) → all `add`, no fallback/drop. +- **`amount` identity (CRITICAL gate):** resolves to **`초당거래대금` (per-second)**, + EMPIRICALLY confirmed → **1-min aggregation = SUM**; no cumulative-delta + recomputation needed. +- **V-COVERAGE:** every sampled session yields **27–31 distinct 1-minute buckets** + (20/20 sessions ≥ 6) → `n_folds ≥ 5` (= 6 disjoint segments) is achievable on a + **single session**; no stacking required for the fold-count gate. A co-dated + universe for cross-sectional ranking width is available (≥6 symbols share single + dates even in a bounded 60-table scan; the prior 1s phase used 111 co-dated). +- **Causal slope formula:** the locked trailing-OLS slope `cov(x,y)/var(x)` over + the last N bars passed the no-look-ahead self-check (appending future bars + leaves earlier rows byte-identical). + +--- + +## Method (bounded, read-only, NO full-DB scan) + +- Reused the established read-only helpers: `connect_readonly` (`mode=ro`, + `query_only=ON`), `list_stock_tables`, `_decode_table_columns`, + `_resolve_source_column`, and `STOM_RL_SOURCE_COLUMNS` — so the probe inspects + EXACTLY the columns the live feed path (`read_stom_table_rl_source`) resolves. +- **Sample:** 8 symbol tables (`000020 000040 000050 000060 000070 000080 000100 + 000120`) × up to 3 distinct sessions each = **20 sessions**, each bounded to + ≤25 000 rows over the recorded morning window (`ORDER BY index LIMIT 25000`). +- 1-min buckets via `timestamp.dt.floor("min")` (bucket-start labeling, matching + the planned Stage-2 resampler). +- `amount` identity decided EMPIRICALLY from within-session value shape + (monotonicity), cross-checked against which candidate column the resolver picks. + +--- + +## 1. Trend-feature feasibility (C-0 style, bounded) + +The four trend features derive from three existing source columns. Coverage is +the **worst (minimum) post-1-min-resample non-null %** across all 20 sessions. + +| Trend feature(s) | Korean ref | Source column (resolved) | Worst post-resample non-null | Threshold | Verdict | +|---|---|---|---|---|---| +| `ma_close_n`, `volatility_n` | 이동평균(N) / 변동성(N) | `현재가` → `close` | **100.0%** | ≥80% | **add** | +| `amount_slope_n` | 거래대금각도 | `초당거래대금` → `amount` | **100.0%** | ≥80% | **add** | +| `change_rate_slope_n`, `volatility_n` | 등락율각도 / 변동성(N) | `등락율` → `change_rate` | **100.0%** | ≥80% | **add** | + +All three source columns are dense at 1-min after resample (no session below the +80% non-null threshold). No fallback or drop is required for any trend feature. +This is consistent with the C-0 probe (`docs/stom_rl_page_c0_feature_probe_2026-05-27.md`), +which already found `등락율` and OHLC ≥99.8% non-null at the 1-s grid. + +> `변동성(N)` is computed as the trailing std of `change_rate` (등락율), per the +> plan's §5 table (population/sample std pre-registered in §4). `close` is the +> alternate source if a returns-from-close definition is later preferred — both +> source columns are 100% dense, so either is feasible. + +--- + +## 2. `amount` source-identity verdict (CRITICAL Stage-1 EXIT gate) + +**Verdict: `amount` = `초당거래대금` (PER-SECOND) → 1-min resample aggregation = SUM.** + +### Resolution evidence +`STOM_RL_SOURCE_COLUMNS["amount"]` candidates (resolution order): +`["초당거래대금", "거래대금", "당일거래대금"]` (`finetune/qlib_stom_pipeline.py:271`). +`_resolve_source_column` returns the FIRST present candidate. Across all 12 +tables inspected, every table carries BOTH `초당거래대금` and `당일거래대금`, and +because `초당거래대금` is first in the candidate list, **the resolver always +returns `초당거래대금`** (per-second), never the cumulative `당일거래대금`. + +> Note: the C-0 doc's abbreviated column dump listed only `당일거래대금`, which +> could mislead into a "cumulative" assumption. The full column set contains +> `초당거래대금` (per-second), `당일거래대금` (cumulative-daily), and `거래대금증감` +> (per-tick delta); the live resolver picks `초당거래대금` first. + +### Empirical per-second confirmation (monotonicity) +Inspecting the actual resolved-column values within each session: + +| Symbol / session | first | last | min | max | #decreases | #increases | frac non-decreasing | +|---|---|---|---|---|---|---|---| +| 000020 / 20221212 | 139 | 0 | 0 | 139 | 43 | 44 | 0.903 | +| 000040 / 20230906 | 394 | 8 | 0 | 394 | 601 | 590 | 0.606 | +| 000040 / 20240222 | 700 | 4 | 0 | 700 | 661 | 649 | 0.567 | +| 000050 / 20250512 | 905 | 2 | 0 | 905 | 732 | 718 | 0.586 | +| 000050 / 20251215 | 609 | 10 | 0 | 609 | 741 | 740 | 0.550 | + +All 20 sessions classified **per-second**. Decisive signals: +- Values oscillate up AND down every second (#decreases ≈ #increases); a + cumulative-daily series would be (near-)monotone non-decreasing (frac ≈ 1.0). +- `min = 0` recurs and the series returns to near-0 — a cumulative total cannot + decrease or reset. +- Magnitudes stay in the hundreds through 09:30; a cumulative daily amount would + be orders of magnitude larger by mid-session. + +### Resulting aggregation +- **`amount` → SUM** over each 1-min bucket (flow semantics). +- **`amount_slope_n` (거래대금각도)** is computed directly on the **per-bar SUMMED + amount** — NO cumulative-delta (`amount.diff()`) recomputation is needed + (that branch applies only to the cumulative `당일거래대금` case, which is NOT + selected). The existing `amount_delta = groupby.diff()` + (`qlib_stom_pipeline.py:215`) remains a per-bar change of the per-second flow, + which is consistent. + +--- + +## 3. V-COVERAGE (≥ n_folds+1 = 6 distinct 1-min timestamps) + +**PASS on a single session.** Distinct 1-minute buckets per sampled session: + +- Range: **27–31** distinct 1-min buckets per session. +- **20 / 20** sessions meet the ≥6 requirement (every session clears it ~5×). +- Recorded window per session ≈ **09:00–09:30 KST** (first ~30 min), e.g. + `090005 … 092959` → ~30 one-minute bars. This is the expected recording shape + (open + first 30 min), and ~30 one-min bars is ample for 6 disjoint + expanding-window segments (n_folds = 5). + +**Stacking decision:** single-session coverage already satisfies the fold-count +gate, so co-dated-session stacking is **NOT required** to reach n_folds ≥ 5. +However, the walk-forward ranker needs **cross-sectional width** (many symbols +per timestamp) to rank within each 1-min bar — exactly as the proven-null 1s +phase used 111 co-dated symbols. A co-dated universe is available: even a bounded +60-table scan found single dates shared by 6 symbols (`20231017`, `20230914`, +`20220415`), and the prior phase confirmed 111 co-dated symbols on the full DB. + +**Recorded universe plan for Stage 3/4:** use a **co-dated symbol universe on a +single shared session date** (target the 1s-phase 111-symbol co-dated set on its +date), giving ~30 distinct 1-min timestamps (≥6 folds) × ~100+ symbols of +ranking width. If a future need for more timestamps arises, stack additional +co-dated dates for the SAME symbols along the time axis (preserving per-symbol / +per-session grouping) — but this is a contingency, not required for the gate. + +--- + +## 4. LOCKED Pre-Registration (EXIT gates — fixed BEFORE any verdict) + +These constants are FROZEN. They are copied verbatim into the Stage-4 run report +and MUST NOT be tuned post-hoc. + +| Pre-registration item | LOCKED value | Rationale | +|---|---|---| +| **Trailing window `N`** | **10** (one-min bars) | Plan §10 proposed N=10; with ~30 one-min bars/session, N=10 leaves ample post-warmup rows for 6 folds. `min_periods=1` for level features, `min_periods=2` for slopes. | +| **변동성 ddof** | **0** (population std) | Population std of trailing returns; deterministic and avoids small-sample inflation at the window edge. Matches the plan §5 "population std" note. | +| **`cost_bps`** | **25.0** | Reuse the 1s value verbatim for a clean 1s↔1min decision-rate contrast on the same harness/cost. | +| **`M` (config count)** | **1** | Single pre-registered config — no multi-config search. Keeps the multiplicity correction trivial (no data-mining inflation). | +| **Primary config — algo/policy** | **`supervised_ranker`** | The cheap falsifier; `trained_ppo` is DEFERRED (authorized only if the ranker clears the gate). | +| **Primary config — `top_k`** | **10** | Reuse the 1s-phase top-k (positions selected per bar). | +| **Primary config — `max_positions`** | **10** (= top_k) | Hold the top-k selection; no extra position cap beyond top_k. | +| **Primary config — seed set** | **shuffle-seed `0`** for the mandatory paired shuffle run; ranker is deterministic (no stochastic training). | Reproducible paired real-vs-shuffle comparison. | +| **`n_folds`** | **5** (= 6 disjoint expanding-window segments) | Power floor from the plan (§1.1); avoids the "2/2 majority" trap. | +| **Majority threshold** | **⌈(N+1)/2⌉ = 3 of 5 folds** | The multiplicity-aware strict-majority already implemented in `portfolio_walk_forward.py`. | +| **Holding-period mapping** | **1-bar** = decide every 1-min bar; **fill at T+1** (next 1-min bar) | One-bar step (plan Open-Q §1 proposal CONFIRMED). T+1 fill via the grid-agnostic `condition_screener.py:274` `shift(-1)` = next 1-min bar. | +| **Turnover constraint** | ranker mean turnover ≤ `equal_weight` mean turnover × **1.25** | Plan §1 alpha-gate anti-churn clause. | + +### Locked causal slope formula (거래대금각도 / 등락율각도) + +**Trailing-OLS slope = `cov(x, y) / var(x)` over the last N bars**, where +`x = [0, 1, …, k-1]` is the within-window bar index and `y` is the series value: + +``` +slope_T = sum((x_i - x̄)(y_i - ȳ)) / sum((x_i - x̄)^2) over bars i in [T-N+1, T] +``` + +implemented as `y.rolling(N, min_periods=2).apply(ols_slope, raw=False)`, grouped +per symbol/session. + +- **Causal / trailing only:** window ⊆ `[T-N+1, T]` (bars ≤ current). **NO** + `shift(-k)`, **NO** `center=True`, **NO** full-series fit. +- **`거래대금각도`** = trailing slope of the per-bar **SUMMED** `amount` + (per-second → SUM; see §2). NOT a slope of a cumulative series. +- **`등락율각도`** = trailing slope of `change_rate` (등락율). +- **Self-check PASS:** appending future bars (`[…, 99.0, -50.0]`) left every + earlier-row slope byte-identical to the un-extended computation + (`extended_overlap_equal_to_base = True`). Base slopes for a monotone test + series: `[NaN, 2.0, 0.5, 1.4, 1.1, 1.43, …]` — strictly trailing. + +> A `min_periods=2` floor means the first bar of each symbol/session has an +> undefined slope → filled to 0.0 by the existing `replace([inf],0).fillna(0)` +> in `build_stom_rl_feature_frame` (`:246`); no look-ahead introduced. + +--- + +## 5. Per-column 1-min resample aggregation table (pre-registered, R2) + +The Stage-2 net-new RL resampler (keyed on `"timestamp"`, NOT the OHLCV-only +`_resample_group`) MUST aggregate all 18 canonical source inputs per this table. +Bar labeled at **bucket-start** via `floor("min")`, `close = last`. + +| Source column(s) | Class | 1-min aggregation | +|---|---|---| +| `open` | OHLC | **first** | +| `high` | OHLC | **max** | +| `low` | OHLC | **min** | +| `close` (현재가) | OHLC | **last** | +| `초당매수수량` (buy_qty_1s) | flow | **SUM** | +| `초당매도수량` (sell_qty_1s) | flow | **SUM** | +| `volume` (= buy+sell, derived) | flow | **SUM** | +| **`amount` (`초당거래대금`, per-second — §2)** | flow | **SUM** | +| `매수총잔량` (bid total qty) | order-book | **last** | +| `매도총잔량` (ask total qty) | order-book | **last** | +| `매수호가1` (bid price 1) | order-book | **last** | +| `매도호가1` (ask price 1) | order-book | **last** | +| `등락율` (change_rate) | rate/snapshot | **last** | +| `회전율` (turnover_rate) | rate/snapshot | **last** | +| `시가총액` (market_cap) | rate/snapshot | **last** | +| `고저평균대비등락율` (high_low_mid) | rate/snapshot | **last** | +| `체결강도` (trade_strength) | rate/snapshot | **last** | + +> `amount` is in the **flow → SUM** class because §2 resolved it to the +> per-second `초당거래대금`. (Had it resolved to cumulative `당일거래대금`, it would +> be order-book/rate → LAST with a per-bar-delta recompute — that branch does NOT +> apply.) Derived per-bar features (`net_buy_qty_1s`, `bid_ask_imbalance`, +> `spread_ticks`, `amount_delta`, trailing means/slopes) are recomputed by +> `build_stom_rl_feature_frame` on the already-resampled 1-min frame. + +--- + +## 6. Gate summary + +| Gate | Result | +|---|---| +| Trend-feature feasibility (≥80% non-null post-resample) | **PASS** (100% all sessions) | +| `amount` identity (per-second vs cumulative) | **per-second** (`초당거래대금`) → **SUM** | +| V-COVERAGE (≥6 distinct 1-min buckets) | **PASS** (27–31/session, 20/20) | +| Pre-registration locked (N, ddof, cost_bps, M, config, holding, slope) | **LOCKED** (§4) | +| Per-column aggregation table | **LOCKED** (§5) | +| Causal slope no-look-ahead self-check | **PASS** | + +**GO for Stage 2** (net-new RL resampler + `freq` through the live feed + the four +causal trend features). PPO remains DEFERRED behind the Stage-4 alpha gate. + +--- + +## Constraints honored +- Read-only DB (`mode=ro`, `query_only=ON`); bounded sampling only (8 symbols × + ≤3 sessions × ≤25 000 rows; column discovery ≤60 tables); **no full-DB scan**. +- No `eval` / `exec` / `__`-dunder tricks. No production code changes (this is a + decisions/feasibility stage). Causal-only formulas (trailing windows ≤ T). diff --git a/docs/stom_rl_cost_aware_policy_2026-05-26.md b/docs/stom_rl_cost_aware_policy_2026-05-26.md new file mode 100644 index 000000000..13b9c4fd2 --- /dev/null +++ b/docs/stom_rl_cost_aware_policy_2026-05-26.md @@ -0,0 +1,168 @@ +# Cost-Aware Policy 실험 (TRACK B 다음 방향, Page 14 후속) + +- 작성일: 2026-05-26 +- 베이스라인 커밋: `0a9a67e` (Page 16 full-universe 게이트) +- 브랜치: `feature/stom-rl-lab` +- 실행: Python 3.13 (Windows). **주의**: 본 환경에는 `stable_baselines3`/`gymnasium` 미설치 — 이 사실이 라우트 선택을 결정함(아래 §2). +- 비용 가정(명시): **`cost_bps = 25.0`**, `slippage_bps = 0.0`, `initial_cash = 1,000,000 KRW`, 체결 = **T+1** (`stom_rl/accounting.py`, `stom_rl/portfolio_env.py`). +- 가설(Page 14): 손실 원인은 **알파 부재가 아니라 거래 회전(turnover) 비용**. 비용 인지형 정책 — **TRAIN 구간에 fit, disjoint·후행 TEST 구간에서 평가** — 이 `cost_bps=25.0` 후 equal-weight 를 이기는가? + +--- + +## 0. 결론 먼저 (정직) + +**판정: MARGINAL / 데이터 의존적 (조건부 Yes).** + +- **demand_pressure set (227 candidate, Page 14 와 동일)**: 비용 인지형 정책이 `cost=25bps` 후 equal-weight 를 **+0.0817%p 로 이긴다 (Yes)**. 그러나 이는 **알파가 아니라 회전 비용 회피** 때문이다 — 정책이 TRAIN 에서 "거의 거래하지 않기"를 학습해(min-hold=8, 높은 score 임계값) no_trade(0.0%)에 근접했고, equal-weight 가 매 fold 비용으로 잃는 만큼을 피했을 뿐이다. +- **widev1 set (25 candidate, 더 엄격한 룰)**: equal-weight 자체가 **+0.0758% 흑자**라서, 정책이 TRAIN 에서 매우 높은 임계값을 fit → TEST 에서 **0 거래** → no_trade(0.0%)와 동률, **equal-weight 에 -0.0758%p 패배 (No)**. +- **비용 민감도(demand_pressure)**: gap(cost_aware − equal_weight) 이 **cost=0 에선 음수(-0.0521%p, 패배), cost↑ 할수록 양수로 커진다(+0.082%p @25bps)**. 즉 **이 정책의 우위는 전적으로 회전 비용 회피 효과이며, raw 알파는 없다** — Page 14 가설의 결정적 확증. + +**한 줄 요약**: 비용 인지형 reward 는 "churn 을 멈추라"를 정확히 학습시켰고, 그 결과 고빈도 대역(rl_baseline, -3.28%)을 압도하고 비용이 높을수록 equal-weight 도 이긴다. 그러나 종목 선택 알파는 만들지 못했다 — 정책의 최적해가 사실상 "현금 보유"로 수렴하기 때문이다. 이는 정당한 트랙 B 종착점이다. + +--- + +## 1. 라우트 선택: (b) 파라메트릭 비용 인지형 정책 (TRAIN-tuned) + +| 후보 | 채택? | 이유 | +|---|:--:|---| +| (a) SB3 학습 (DQN/PPO) | ✗ | 본 환경에 `stable_baselines3`/`gymnasium` **미설치**(import 실패 확인). `sb3_adapter` 는 단일 종목 env 만 래핑하며 portfolio_env 용 Gym 래퍼가 없다. Page 14 §6-4 가 MaskablePPO 도입을 invalid-action 게이트 통과 전까지 보류로 명시. → 무거운 의존성 추가 + 새 래퍼 = scope creep. | +| **(b) 파라메트릭 정책 (채택)** | ✓ | 가설("churn 이 edge 를 먹는다")을 **직접** 검증. rank_score 임계값 + min-hold 두 파라미터를 **TRAIN 구간에서 grid-search** 로 비용 조정 수익 최대화 → freeze → disjoint·후행 TEST 평가. 결정론적·고속·`_fit_policy` seam 에 그대로 삽입, holdout 기계 무변경. | + +### 1.1 정책 정의 (`_CostAwarePolicy`) + +두 개의 학습 파라미터: +- **`score_threshold`** (절대 rank_score): 후보의 `rank_score ≥ threshold` 일 때만 매수. TRAIN rank_score 분포의 quantile {0.0, 0.5, 0.75, 0.9} 에서 유도(저확신 진입 억제). +- **`min_hold_steps`** ∈ {1, 2, 4, 8}: 매수 후 N 바 동안 매도 금지(churn 직접 억제). + +매 스텝: ① min-hold 충족한 보유분만 매도 → ② 임계값 통과한 최상위 fillable 후보 매수 → ③ 아니면 HOLD. 동률 시 더 선택적/긴 보유(저회전) 쪽으로 결정론적 tie-break. + +--- + +## 2. 비용 인지형 reward 정식화 (additive, opt-in) + +`stom_rl/portfolio_env.py`, `PortfolioEnvConfig.turnover_penalty_lambda: float = 0.0`: + +``` +reward = Δnav_pct − λ · (execution_cost_this_step / nav_before) +``` + +- `Δnav_pct = (nav_after − nav_before) / nav_before` (기존 reward, 무변경). +- `execution_cost_this_step` = 이번 스텝 체결(fill)에 이미 계상된 브로커 비용(T+1 fill 의 `cost`), `nav_before` 로 정규화 → λ 는 무차원. +- **HOLD/무체결 스텝은 패널티 0** (거래하지 않으면 비용 없음). +- **`λ = 0.0` (기본) ⇒ 엄격한 no-op** — 레거시 NAV-변화 reward 와 비트 동일, 기존 테스트 전부 불변. + +### 2.1 fit/eval 시 λ 분리 (정직성 핵심) + +- **TRAIN 튜닝 시에만** `COST_AWARE_TRAIN_LAMBDA = 1.0` 적용 (`replace(config, turnover_penalty_lambda=1.0)`) → 튜너가 저회전 파라미터를 선호하도록 유도. +- **held-out TEST 평가는 항상 λ=0 의 무가공 비용 반영 수익** 으로 측정 → 다른 baseline 과 동일 척도로 정직하게 비교(정책을 유리하게 가공하지 않음). + +--- + +## 3. fit-on-train / eval-on-test 프로토콜 + 누수 안전성 + +기존 Page 11 expanding-window holdout 을 **무변경**으로 재사용 (`portfolio_walk_forward.py`): + +- fold N 은 segment `0..N` 으로 fit, **disjoint 하고 strictly-later** 인 segment `N+1` 에서 평가. +- `_fit_cost_aware_policy(train_frame, ...)` 는 **오직 `train_frame`** 만 받아 grid-search → 동결. +- 런타임 하드 가드: train/test 타임스탬프 교집합 = ∅, `min(test) > max(train)` (`run_portfolio_walk_forward` 의 `AssertionError`). + +### 3.1 누수 방지 증거 (테스트) + +- `test_cost_aware_fit_uses_only_train_segment_no_test_leakage`: TEST 구간(미래) 의 rank_score/price 를 +1000/×3 으로 교란해도 **earliest fold 의 동결 파라미터가 비트 동일** → fitter 가 TEST 를 엿보지 않음 입증. +- 기존 leakage canary 2종(forward-looking column 무시 / 가격 교란 탐지)은 cost_aware 포함 상태로 **여전히 통과**. + +### 3.2 fold 별 동결 파라미터 (demand_pressure, cost=25bps) + +| fold | TRAIN 범위 (KST) | TEST 범위 (KST) | fitted threshold | fitted min-hold | TEST return% | trade | +|---:|---|---|---:|---:|---:|---:| +| 0 | 09:00:05–09:12:44 | 09:12:45–09:16:03 | 107.90 | 8 | -0.0945 | 2 | +| 1 | 09:00:05–09:16:03 | 09:16:05–09:23:08 | 134.36 | 8 | 0.0000 | 0 | +| 2 | 09:00:05–09:23:08 | 09:23:43–09:29:58 | 127.03 | 8 | 0.0000 | 0 | + +→ 모든 fold 에서 **min-hold=8(그리드 최대)** 선택 = 회전 최대 억제. fold 1·2 는 임계값이 높아 TEST 에서 0 거래(현금 보유). + +--- + +## 4. 비용 반영 비교표 (3 holdout fold 평균, `cost_bps=25.0`) + +### 4.1 demand_pressure set (227 candidate, Page 14 와 동일 윈도우) + +| 정책 | 평균 수익률%(비용 후) | 평균 턴오버 | 평균 거래수 | 평균 비용(KRW) | +|---|---:|---:|---:|---:| +| no_trade | **0.0000** | 0 | 0.00 | 0 | +| equal_weight_candidate | **-0.1132** | 499,895 | 2.00 | 1,250 | +| buy_and_hold | -0.1132 | 499,895 | 2.00 | 1,250 | +| rule_baseline | -3.2816 | 13,453,032 | 54.67 | 33,633 | +| rl_baseline (대역) | -3.2816 | 13,453,032 | 54.67 | 33,633 | +| **cost_aware (TRAIN-fit)** | **-0.0315** | **166,769** | **0.67** | **417** | + +→ cost_aware 가 equal-weight 를 **+0.0817%p** 이김. 턴오버 13.45M→167K(**80배↓**), 거래수 54.7→0.67, 비용 33,633→417 KRW. **단, no_trade(0.0%)에는 -0.0315%p 미달** — 우위는 "거의 안 거래"에서 나온다. + +### 4.2 widev1 set (25 candidate, 더 엄격한 룰) + +| 정책 | 평균 수익률%(비용 후) | 평균 턴오버 | 평균 거래수 | 평균 비용(KRW) | +|---|---:|---:|---:|---:| +| no_trade | **0.0000** | 0 | 0.00 | 0 | +| equal_weight_candidate | **+0.0758** | 499,895 | 2.00 | 1,250 | +| rl_baseline (대역) | -0.2103 | 1,500,405 | 6.00 | 3,751 | +| **cost_aware (TRAIN-fit)** | **0.0000** | 0 | 0.00 | 0 | + +→ equal-weight 가 흑자(+0.0758%)인데, 정책이 TRAIN 에서 임계값을 너무 높게 fit(192–312) → TEST 0 거래 → no_trade 동률, **equal-weight 에 -0.0758%p 패배**. 정직한 음성 결과. + +### 4.3 비용 민감도 sweep (demand_pressure) — 우위의 출처 규명 + +| cost_bps | cost_aware% | equal_weight% | no_trade% | gap(ca−ew) | ca>ew? | +|---:|---:|---:|---:|---:|:--:| +| 0.0 | -0.0404 | +0.0117 | 0.0000 | **-0.0521** | ✗ No | +| 5.0 | +0.0019 | -0.0133 | 0.0000 | +0.0151 | ✅ Yes | +| 10.0 | -0.0065 | -0.0383 | 0.0000 | +0.0318 | ✅ Yes | +| 25.0 | -0.0315 | -0.1132 | 0.0000 | **+0.0817** | ✅ Yes | + +→ **결정적 증거**: gap 이 cost=0 에선 음수(정책 우위 없음), 비용↑ 할수록 단조 증가. **정책의 우위 = 100% 회전 비용 회피이지 알파가 아니다.** Page 14 의 "알파 부재가 아니라 회전 비용" 가설을 학습 정책으로 재확증. + +--- + +## 5. 판정 + +| 질문 | 답 | +|---|---| +| 비용 인지형 정책이 `cost=25bps` 후 equal-weight 를 이기는가? | **MARGINAL / 조건부**: demand_pressure 에선 Yes(+0.082%p), widev1 에선 No(-0.076%p) | +| 우위의 출처는? | **회전 비용 회피** (cost=0 에선 우위 소멸, cost↑ 단조 증가). 종목 선택 알파 아님 | +| 정책이 학습한 최적 행동은? | **거의 거래하지 않기** (min-hold=8, 높은 임계값) → no_trade 에 수렴 | +| 고빈도 대역(rl_baseline)은 이기는가? | **압도적 Yes** (-3.28% vs -0.03%, 비용 80배↓) | +| 누수 있는가? | **없음** — fit 은 train_frame 만, TEST 교란이 동결 파라미터 불변(테스트 입증) | + +**트랙 B 종착점 (정직)**: Page 14 §6 "대안 종착점"에 정확히 부합 — 비용 인지형 reward 보강 후에도 학습 정책이 종목 선택 알파를 만들지 못했고, 그 최적해는 "저회전(≈현금 보유)"이다. **이 데이터/비용에서 이 candidate 셋은 저회전 equal-weight/no_trade 가 강한 baseline**이라는 것이 정당한 결론. 알파를 짜내거나 수치를 가공하지 않았다. + +--- + +## 6. 다음 연구 방향 + +1. **알파 신호 자체 강화 (최우선)**: 회전 억제는 한계 효용에 도달(현금 보유로 수렴). 이제 필요한 건 **종목 선택 우위** — candidate 의 rank_score 가 미래 수익과 상관이 있는지부터 검증해야 한다. 상관이 없으면 어떤 정책도 no_trade 를 의미 있게 못 넘는다. +2. **데이터 폭 확대**: 단일 세션·2~3종목·30분. fold 1·2 가 0 거래로 끝나 신호가 빈약. 더 많은 세션/종목(Page 16 게이트)에서 cost_aware 가 거래를 하는 구간을 확보해야 진짜 비교가 됨. +3. **min-hold 그리드 상향 검토**: 모든 fold 가 min-hold=8(최대) 선택 → 더 긴 hold 가 더 나을 수 있음(그리드 경계 효과). 단 데이터 폭 확대가 선행되어야 의미. +4. **(보류) SB3 MaskablePPO**: gymnasium/SB3 설치 + portfolio_env Gym 래퍼 필요. 본 파라메트릭 결과가 "회전 억제로는 알파가 안 나온다"를 보였으므로, RL 도입 전 (1) 신호 검증이 우선. + +--- + +## 7. follow-up B (T+1 sell price 대칭) — **스킵** (정직한 사유) + +- 현 env 의 sell 경로는 보유 종목이 **현재 후보일 때 이미 T+1 `fill_price` 사용** (`_fill_price_for(symbol, _candidate_row_for(...))`); 비후보 보유분만 mark(`prices_before`) fallback. +- 본 소규모 universe(2~3종목)에선 보유 종목이 거의 항상 현재 후보 ⇒ follow-up B 는 **사실상 no-op**. +- 비후보 보유분에 panel T+1 을 주려면 **panel 전체를 env 에 주입**해야 함(env 는 현재 candidates 만 보유) = 작은 additive 변경이 아님 → 결정론·scope 위험. +- 과제 가이드("작은 additive 변경이면, 아니면 skip 후 note")에 따라 **스킵**. 데이터 폭 확대 시 재검토. + +--- + +## 8. 산출물 (커밋 안 함, `.omx/` gitignore) + +`.omx/artifacts/cost_aware_policy/`: +- `cand_demand_pressure.csv` (227), `cand_widev1.csv` (25) + topk JSON +- `demand_pressure/`, `widev1/`, `dp_cost{0,5,10,25}/`, `dp_determinism_rerun/` — 각 `portfolio_walk_forward_report.json` + `_folds.csv` +- `comparison.csv`, `comparison.json` — 전체 fold 평균 비교 + 비용 sweep (fold CSV read-only 집계) + +## 9. 메모 (정직성) + +- 기본 동작 무변경(`λ=0`) — 기존 stom_rl 회귀 테스트 비-감소 확인(+4 신규 테스트, 0 회귀; 사전 존재하던 gymnasium 미설치 실패 1건은 본 작업과 무관). +- TEST 평가는 λ=0 무가공 비용 반영 수익. TRAIN 튜닝만 λ=1 사용(분리 명시). +- DB 풀스캔 없음(2~3종목·30분·219 ts). `eval/exec/__` 미사용. cost=0 에서 우위 소멸을 숨기지 않고 비용 sweep 으로 정량화. diff --git a/docs/stom_rl_current_branch_handoff_2026-05-24.md b/docs/stom_rl_current_branch_handoff_2026-05-24.md new file mode 100644 index 000000000..5dffed7ba --- /dev/null +++ b/docs/stom_rl_current_branch_handoff_2026-05-24.md @@ -0,0 +1,493 @@ +# STOM 강화학습 현재 브랜치 핸드오프 문서 + +작성일: 2026-05-24 + +대상 저장소: `D:\Chanil_Park\Project\Programming\Kronos` + +현재 브랜치: `feature/stom-rl-lab` + +이 문서 작성 직전 개발 HEAD: `a5b92a6 5070 대시보드가 강화학습 화면으로 바로 열리게 하다` + +운영/확인 포트: `5070` + +핵심 화면: `http://127.0.0.1:5070/rl` + +이 문서는 새 Codex/OMX 대화를 만들어도 현재 브랜치 작업을 이어받을 수 있도록, 현재까지 완료된 내용, 검증 증거, 남은 위험, 다음 개발 순서, 추천 명령어를 한 곳에 모은 핸드오프 문서다. + +--- + +## 1. 가장 중요한 결론 + +| 구분 | 현재 상태 | +|---|---| +| RL 플랫폼 페이지 진행률 | 100% | +| 웹 RL Lab 직접 진입 | `http://127.0.0.1:5070/rl` 정상 | +| `/api/rl/progress` | `overall_progress_pct=100`, `status=complete` | +| Gymnasium / Stable-Baselines3 연결 | 완료 | +| `StomTickTradingEnv` Gymnasium adapter | 완료 | +| `check_env` | 통과 | +| DQN smoke / short 학습 | 완료 | +| PPO smoke / short 학습 | 완료 | +| 50k DQN/PPO 학습 | 완료, leaderboard 반영 | +| Performance leaderboard | 완료, `dqn_50k` 1위 | +| 실전 모델 여부 | 아직 아님. 현재는 **실전 후보 모델** | +| 다음 핵심 | 학습량 확대보다 먼저 **저장 모델 eval-only / 평가 episode 확대 / walk-forward 검증** | + +현재 웹과 RL 산출물 연결은 완료되어 있다. 다만 50k 모델의 평가 episode가 5개라서, 실거래 투입 전에는 더 긴 out-of-sample 평가와 walk-forward 검증이 필요하다. + +--- + +## 2. 현재 브랜치와 최근 커밋 흐름 + +현재 브랜치: + +```text +feature/stom-rl-lab +``` + +최근 주요 커밋: + +| 커밋 | 의미 | +|---|---| +| `a5b92a6` | 5070에서 `/rl` 직접 진입, repo-root 기준 artifact 탐색, 최신 v2 dist 연결 수정 | +| `f362b65` | RL 페이지 완료율을 실제 학습 결과 기준 100%로 닫음 | +| `5bbb3a8` | STOM 강화학습 이벤트를 실시간 화면으로 연결 | +| `097e331` | STOM 실시간 강화학습 화면 계획 고정 | +| `58fbb9e` | SB3 smoke 학습 경로를 실제 검증 루프로 연결 | +| `dcd6dbc` | 강화학습 성과 모델 검증 기준 마무리 | +| `075cdd5` / `fd56218` | 성과 leaderboard를 웹에서 비교 가능하게 연결 | +| `0d9e52c` | contextual bandit full test 결과를 기준선과 비교 | + +주의: + +- `webui/rl_runs/*`는 런타임 산출물이며 대용량/재생성 가능 성격이다. +- 현재 git status에는 `.omc/*`, `template/`, `webui/stom_predictions/` 같은 untracked가 남아 있을 수 있다. +- 위 untracked는 이번 RL handoff 문서와 직접 관련 없는 사용자/런타임 파일로 취급하고, 임의로 삭제하거나 커밋하지 않는다. + +--- + +## 3. 현재 웹 대시보드 상태 + +### 3.1 확인 URL + +| 목적 | URL | +|---|---| +| 강화학습 실험실 직접 진입 | `http://127.0.0.1:5070/rl` | +| v2 통합 대시보드 | `http://127.0.0.1:5070/` | +| legacy v1 index | `http://127.0.0.1:5070/v1/` | +| legacy v1 training | `http://127.0.0.1:5070/v1/training` | +| legacy v1 STOM | `http://127.0.0.1:5070/v1/stom` | + +`v2`는 새로 임의 생성한 프론트엔드가 아니라 기존 통합 대시보드의 정식 SPA 모드다. 기존 legacy 화면은 `/v1/*`로 보존되어 있다. + +### 3.2 포트 정리 + +| 포트 | 상태 / 의미 | +|---:|---| +| 5070 | 현재 운영/확인 기준 포트 | +| 7070 | AnyDesk가 점유하는 경우가 있어 사용하지 않는 것이 안전 | +| 7072 | 이전 임시 확인 서버. 최종 안내 기준 아님 | + +### 3.3 서버 실행 명령 + +PowerShell: + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos + +$env:KRONOS_WEBUI_PORT = "5070" +$env:KRONOS_V2_DIST = "1" +$env:KRONOS_WEBUI_OPEN_BROWSER = "0" + +py -3.11 webui\run.py +``` + +브라우저 열기: + +```powershell +Start-Process "http://127.0.0.1:5070/rl" +``` + +API 확인: + +```powershell +Invoke-RestMethod "http://127.0.0.1:5070/api/rl/progress" | + ConvertTo-Json -Depth 8 +``` + +기대값: + +```text +overall_progress_pct = 100 +status = complete +latest_sb3_run = stom_1s_2025_sb3_50k +max_sb3_training_timesteps = 50000 +``` + +--- + +## 4. 페이지별 완료 상태 + +`/api/rl/progress` 기준 현재 완료 상태: + +| 페이지 | 진행률 | 상태 | 주요 증거 | +|---|---:|---|---| +| RL Lab 개요 | 100% | complete | 10개 run 탐지, 상세 artifact 조회 가능 | +| 실시간 RL | 100% | complete | `rl_live_events.jsonl`, DQN/PPO event, 50k run | +| 실제 딥러닝 학습 | 100% | complete | `check_env`, CUDA 학습, DQN/PPO 모델 zip | +| Performance Leaderboard | 100% | complete | 13개 모델 row, DQN/PPO 50k 반영 | +| Artifacts / Models | 100% | complete | `dqn_model.zip`, `ppo_model.zip`, summary/csv/jsonl | +| Docs / 운영 경계 | 100% | complete | 구현 문서, 완료 보고 문서, 실주문 분리 | + +현재 화면상에서 강화학습이 특별히 안 보인다면 `/` 기본 탭이 아니라 `/rl`로 직접 들어가야 한다. + +--- + +## 5. 현재 RL 산출물과 모델 성과 + +현재 performance leaderboard summary: + +| 항목 | 값 | +|---|---| +| row count | 13 | +| best policy | `stable_baselines3_dqn` | +| best RL model | `dqn_50k` | +| max SB3 training timesteps | 50,000 | +| 비용 기준 | cost `25bp`, slippage `0bp` | +| buy-and-hold 평균 episode 순수익 | `+0.5126%` | +| no-trade 평균 episode 순수익 | `0.0%` | +| buy-and-hold 초과 RL 모델 | `dqn_50k`, `ppo_50k`, `dqn_5k` | +| cost gate 통과 RL 모델 | `dqn_50k`, `ppo_50k`, `dqn_5k`, `ppo_5k` | + +### 5.1 Leaderboard 상위 모델 + +| 순위 | 모델 | 학습량 | 평가 episode | 평균 episode 순수익 | 복리 수익 | Hit rate | Max DD | 판단 | +|---:|---|---:|---:|---:|---:|---:|---:|---| +| 1 | `dqn_50k` | 50,000 | 5 | `+1.6142%` | `+8.2825%` | 80.0% | `-5.1209%` | candidate | +| 2 | `ppo_50k` | 50,000 | 5 | `+1.5717%` | `+8.0648%` | 80.0% | `-5.1209%` | candidate | +| 3 | `dqn_5k` | 5,000 | 3 | `+0.5494%` | `+1.6558%` | 75.0% | `-3.2991%` | candidate | +| 4 | `buy_and_hold` | baseline | 2730 | `+0.5126%` | 매우 큼 | 49.3% | `-50.7280%` | baseline | +| 5 | `ppo_5k` | 5,000 | 3 | `+0.4040%` | `+1.2028%` | 66.7% | `-2.2514%` | watch | +| 6 | `contextual_bandit` | full test | 2730 | `+0.1254%` | `+1410.0169%` | 48.0% | `-51.5892%` | watch | +| 7 | `no_trade` | baseline | 2730 | `0.0%` | `0.0%` | 0.0% | `0.0%` | baseline | + +해석: + +- 현재 수치상 1위는 `dqn_50k`다. +- `ppo_50k`도 유사한 성과다. +- 하지만 50k DQN/PPO는 평가 episode가 5개뿐이라 실전 확정 근거로 부족하다. +- 따라서 현재 단계의 올바른 표현은 **실전 모델 구축 완료**가 아니라 **실전 후보 모델 구축 및 대시보드 연결 완료**다. + +--- + +## 6. `k`의 의미와 현재 학습 단계 + +| 표기 | 의미 | +|---|---| +| `5k` | 5,000 environment timesteps | +| `50k` | 50,000 environment timesteps | +| `100k` | 100,000 environment timesteps | +| `500k` | 500,000 environment timesteps | +| `1M` | 1,000,000 environment timesteps | + +`k`는 금액이나 캔들 개수가 아니라 강화학습 environment step 수다. 한 step은 대략 `상태 관측 -> action 선택 -> reward 계산 -> 다음 상태 이동` 1회다. + +현재 위치: + +| 구분 | 현재 상태 | +|---|---| +| smoke 학습 | 완료 | +| 5k 미니 학습 | 완료 | +| 50k short 학습 | 완료 | +| 100k 학습 | 미실행 | +| 500k 학습 | 미실행 | +| 1M 학습 | 미실행 | +| multi-seed 검증 | 미실행 | +| walk-forward 검증 | 미실행 | +| paper trading/replay | 미실행 | + +--- + +## 7. 현재 구현 파일 지도 + +| 영역 | 파일 / 위치 | 역할 | +|---|---|---| +| STOM RL core | `stom_rl/trading_env.py` | 순수 STOM tick trading env | +| Gymnasium/SB3 adapter | `stom_rl/sb3_adapter.py` | `StomTickTradingGymEnv`, `make_sb3_env` | +| SB3 학습 | `stom_rl/sb3_smoke.py` | `check_env`, DQN/PPO 학습, 모델 저장, live event 기록 | +| 실시간 이벤트 | `stom_rl/rl_events.py` | `RlLiveEvent`, JSONL writer/reader/summary | +| 기준선 | `stom_rl/baselines.py`, `stom_rl/leaderboard.py` | no-trade, buy-and-hold, random 등 baseline | +| contextual bandit | `stom_rl/contextual_bandit.py` | contextual bandit 학습/평가 | +| 성과 통합 | `stom_rl/performance_leaderboard.py` | baseline, bandit, SB3 결과 통합 leaderboard | +| RL API helper | `webui/rl_dashboard.py` | `webui/rl_runs` artifact read-only loader, progress 계산 | +| Flask routes | `webui/app.py` | `/api/rl/*` route wiring | +| v2 route alias | `webui/v2/__init__.py` | `/`, `/training`, `/dashboard`, `/rl`, `/rl-lab` shell 서빙 | +| v2 app route tab | `webui/v2_src/src/App.svelte` | `/rl` 진입 시 `rl-lab` 탭 선택 | +| RL Lab UI | `webui/v2_src/src/tabs/RLLabTab.svelte` | run 선택, live event, leaderboard, artifacts 표시 | +| v2 build output | `webui/static/v2/dist/*` | `KRONOS_V2_DIST=1`에서 서빙되는 SPA 산출물 | + +--- + +## 8. 이미 검증한 명령 + +최근 검증된 명령: + +```powershell +py -3.11 -m pytest tests\test_stom_rl_dashboard_api.py -q +``` + +결과: `3 passed` + +```powershell +py -3.11 -m ruff check webui\rl_dashboard.py webui\v2\__init__.py +``` + +결과: `All checks passed!` + +```powershell +cd webui\v2_src +npm run build +``` + +결과: `0 errors`, 기존 warning 4개 유지 + +HTTP 확인: + +```powershell +Invoke-RestMethod "http://127.0.0.1:5070/api/rl/progress" +Invoke-WebRequest "http://127.0.0.1:5070/" +Invoke-WebRequest "http://127.0.0.1:5070/rl" +``` + +확인값: + +```text +/api/rl/progress -> overall_progress_pct=100, status=complete +/ -> 200, 최신 dist bundle index-D0ThkgbJ.js +/rl -> 200, 최신 dist bundle index-D0ThkgbJ.js +``` + +--- + +## 9. 다음 개발 목표: 실전 후보 검증 단계 + +가장 중요한 다음 단계는 더 긴 학습을 바로 돌리는 것보다 **저장된 50k 모델을 더 많은 episode로 eval-only 재평가**하는 것이다. + +### 9.1 추천 순서 + +| 순서 | 작업 | 이유 | 완료 기준 | +|---:|---|---|---| +| 1 | `sb3_eval` 또는 eval-only 모드 추가 | 현재 50k 평가는 episode 5개라 근거 부족 | 저장된 zip 모델을 재학습 없이 50/100/500 episode 평가 | +| 2 | 50k DQN/PPO full eval | 성능이 우연인지 확인 | leaderboard에 eval-only row 반영 | +| 3 | 100k DQN/PPO 학습 | 짧은 확장으로 성능 추세 확인 | 모델 zip, summary, live events 생성 | +| 4 | 250k/500k multi-seed 학습 | 안정성 확인 | seed별 평균/분산 표시 | +| 5 | walk-forward split | 과최적화 방지 | 월별 또는 기간별 train/test 분리 결과 | +| 6 | RL Lab UI에서 training/eval 분리 | 화면 해석 혼동 방지 | 학습 run과 평가 run을 별도 badge/table로 표시 | +| 7 | paper trading/replay | 실시간처럼 보되 실주문 없음 | live replay 화면, risk gate 표시 | +| 8 | 위험관리 gate | 실전 전 필수 | Max DD, 연속손실, 거래횟수, 비용 민감도 기준 | + +### 9.2 다음 단계 진행률 정의 + +| 단계 | 현재 진행률 | 100% 기준 | +|---|---:|---| +| 저장 모델 eval-only | 0% | `dqn_model.zip`, `ppo_model.zip`을 재학습 없이 평가하는 CLI/API/테스트 | +| 50k full eval | 0% | 평가 episode 100개 이상, leaderboard 반영 | +| 100k 학습 | 0% | DQN/PPO 100k run, summary/event/model 파일 생성 | +| 500k 학습 | 0% | DQN/PPO 500k run, seed 2개 이상 | +| walk-forward | 0% | 기간 분할, 결과 표, dashboard 표시 | +| 실전 risk gate | 20% | cost gate는 있으나 실전 DD/연속손실/거래빈도 gate 필요 | +| paper replay | 0% | 실시간 replay UI와 read-only execution simulation | + +--- + +## 10. 추천 실행 명령 + +### 10.1 현재 상태 재확인 + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos + +git branch --show-current +git log --oneline -5 +git status --short +``` + +### 10.2 의존성 확인 + +PowerShell: + +```powershell +py -3.11 -c "import torch, gymnasium, stable_baselines3; print('torch', torch.__version__, 'cuda', torch.cuda.is_available()); print('gymnasium', gymnasium.__version__); print('stable_baselines3', stable_baselines3.__version__)" +``` + +### 10.3 100k DQN 학습 + +```powershell +py -3.11 -m stom_rl.sb3_smoke ` + --output-dir webui\rl_runs\stom_1s_2025_sb3_dqn_100k ` + --algorithms dqn ` + --total-timesteps 100000 ` + --max-eval-episodes 20 ` + --max-eval-steps-per-episode 2048 ` + --device cuda ` + --live-event-sample-interval 50 +``` + +### 10.4 100k PPO 학습 + +```powershell +py -3.11 -m stom_rl.sb3_smoke ` + --output-dir webui\rl_runs\stom_1s_2025_sb3_ppo_100k ` + --algorithms ppo ` + --total-timesteps 100000 ` + --max-eval-episodes 20 ` + --max-eval-steps-per-episode 2048 ` + --device cuda ` + --live-event-sample-interval 50 +``` + +### 10.5 Leaderboard 갱신 + +```powershell +py -3.11 -m stom_rl.performance_leaderboard --sb3-smoke-reports auto +``` + +### 10.6 대시보드 새로 확인 + +```powershell +Invoke-RestMethod "http://127.0.0.1:5070/api/rl/progress" | + ConvertTo-Json -Depth 8 + +Start-Process "http://127.0.0.1:5070/rl" +``` + +### 10.7 테스트 묶음 + +```powershell +py -3.11 -m pytest ` + tests\test_stom_rl_sb3_adapter.py ` + tests\test_stom_rl_dashboard_api.py ` + tests\test_stom_rl_dashboard_tab.py ` + tests\test_stom_rl_performance_leaderboard.py ` + -q + +py -3.11 -m ruff check stom_rl webui\rl_dashboard.py webui\v2\__init__.py +``` + +프론트 수정 후: + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos\webui\v2_src +npm run build +``` + +--- + +## 11. 예상 학습 시간 + +현재 실측 기준: + +| 모델 | 50k 실측 | 100k 예상 | 500k 예상 | 1M 예상 | +|---|---:|---:|---:|---:| +| DQN | 약 5.1분 | 약 10~15분 | 약 50~70분 | 약 1.7~2.3시간 | +| PPO | 약 6.0분 | 약 12~18분 | 약 60~85분 | 약 2~2.8시간 | +| DQN+PPO 합산 | 약 11분 | 약 25~35분 | 약 2~2.5시간 | 약 4~5시간 | + +실전 후보까지 추천 기준: + +| 수준 | 추천 실행 | +|---|---| +| 빠른 확인 | 100k, seed 1개 | +| 후보 비교 | 250k~500k, seed 3개 | +| 실전 후보 | 1M, seed 3~5개 | +| 운영 전 검증 | walk-forward + paper replay + risk gate | + +--- + +## 12. OMX 사용 추천 + +현재 사용자가 일반 git commit 방식을 선호했으므로, `ultragoal checkpoint`는 사용하지 않는 것이 안전하다. 계획/검증에는 OMX skill을 사용할 수 있지만 최종 마무리는 일반 git commit으로 한다. + +추천 흐름: + +```text +$ralplan STOM RL 50k 후보 모델을 실전 후보로 승격하기 위한 eval-only, 100k/500k 학습, walk-forward, dashboard 표시 계획 작성 +``` + +```text +$team sb3_eval 추가, leaderboard 확장, RL Lab 화면 개선, 테스트/문서화를 병렬 진행 +``` + +```text +$ultraqa http://127.0.0.1:5070/rl RL Lab 대시보드와 /api/rl/* 표시 검증 +``` + +```text +$code-review STOM RL 실전 후보 학습/평가/대시보드 변경분 리뷰 +``` + +Windows 현재 환경에서는 `omx explore`가 POSIX shell allowlist harness 문제로 실패할 수 있다. 그 경우 `rg`, `Get-ChildItem`, `pytest`, `ruff`, `npm run build` 같은 일반 PowerShell 명령으로 진행한다. + +--- + +## 13. 다음 대화 시작용 프롬프트 + +새 대화에서 아래 프롬프트를 그대로 붙여넣으면 이어서 진행하기 쉽다. + +```text +D:\Chanil_Park\Project\Programming\Kronos 에서 이어서 진행합니다. +현재 브랜치는 feature/stom-rl-lab 입니다. +먼저 docs/stom_rl_current_branch_handoff_2026-05-24.md 를 읽고 이어서 작업해주세요. + +중요 조건: +- ultragoal checkpoint는 사용하지 말고 일반 git commit으로 마무리합니다. +- 운영/확인 포트는 5070입니다. +- 강화학습 화면은 http://127.0.0.1:5070/rl 기준입니다. +- 7070은 AnyDesk 점유 가능성이 있어 사용하지 않습니다. +- 7072는 임시 서버였으므로 최종 기준으로 안내하지 않습니다. + +현재 완료 상태: +- RL Lab 페이지 진행률 100% +- /api/rl/progress overall_progress_pct=100, status=complete +- Gymnasium/SB3 연결 완료 +- StomTickTradingEnv adapter 완료 +- check_env 통과 +- DQN/PPO smoke, 5k, 50k 학습 완료 +- performance leaderboard 연결 완료 +- 현재 best model은 dqn_50k 이지만 평가 episode가 5개라 실전 확정 모델은 아닙니다. + +다음 목표: +1. 저장된 DQN/PPO 50k 모델을 재학습 없이 full eval 하는 sb3_eval 또는 eval-only 모드 추가 +2. 평가 episode 50/100/500개로 확대 +3. eval-only 결과를 performance leaderboard와 RL Lab에 training 결과와 분리 표시 +4. 이후 DQN/PPO 100k -> 500k -> 1M multi-seed 학습 계획/구현 +5. walk-forward 검증과 paper replay/risk gate까지 확장 +6. 테스트 후 한국어 Lore commit +``` + +--- + +## 14. 위험과 주의사항 + +| 위험 | 설명 | 대응 | +|---|---|---| +| 평가 표본 부족 | 50k DQN/PPO는 episode 5개 평가라 실전 근거 부족 | eval-only full evaluation 먼저 구현 | +| leaderboard 해석 혼동 | 학습 run 결과와 eval-only 결과가 섞이면 오해 가능 | run category/badge 분리 | +| 과최적화 | 동일 split 반복 학습/평가 가능성 | walk-forward split 추가 | +| 실주문 오해 | 현재 RL Lab은 read-only historical replay/smoke-short training | paper replay와 실주문 분리 문구 유지 | +| 포트 혼동 | 7070/7072 혼동 이력 | 5070 `/rl`만 최종 기준으로 안내 | +| Windows OMX explore | `omx explore` harness 실패 가능 | PowerShell/rg/pytest로 대체 | + +--- + +## 15. 완료 정의 + +다음 큰 단계인 “실전 후보 검증 모델” 완료 기준: + +- 저장된 `dqn_model.zip`, `ppo_model.zip`을 재학습 없이 평가 가능 +- 평가 episode 100개 이상 결과 생성 +- `dqn_50k`, `ppo_50k`가 eval-only 결과에서도 buy-and-hold/no-trade 기준을 안정적으로 초과하는지 확인 +- leaderboard가 training 결과와 eval-only 결과를 분리 표시 +- `/rl`에서 선택 run의 학습/평가/실시간 이벤트를 명확히 볼 수 있음 +- `pytest`, `ruff`, 필요 시 `npm run build` 통과 +- 한국어 Lore commit 완료 diff --git a/docs/stom_rl_deep_rl_verdict_2026-05-27.md b/docs/stom_rl_deep_rl_verdict_2026-05-27.md new file mode 100644 index 000000000..f28de8b47 --- /dev/null +++ b/docs/stom_rl_deep_rl_verdict_2026-05-27.md @@ -0,0 +1,214 @@ +# STOM Portfolio Deep-RL — Honest Two-Track Verdict (Stage E) + +Plan: `.omx/plans/ralplan-stom-rl-deep-rl-2026-05-27.md` (Stage E, §0 Two-Track, P0-1 multiplicity/power gate) +Repo: `Kronos` · Branch: `feature/stom-rl-lab` · Baseline: `d2104cc` · Python `py -3.11` (3.11.9) · Windows +Evidence artifact (NOT committed): `.omx/artifacts/deep_rl/stageB_train/stage_b_smoke_summary.json` +Date: 2026-05-27 + +--- + +## Pre-registration scaffold (P0-1 — recorded BEFORE the test-fold metrics below) + +Per the plan's hard data-mining guard, the search space is pre-registered so the +verdict cannot be a post-hoc cherry-pick: + +- **Primary config (the ONE pre-named config):** `algorithm=ppo`, `turnover_penalty_lambda=1.0`, + `top_k_candidates=3`, `seed_set=[100]`, `cost_bps=25.0`. +- **Full candidate config set:** exactly one — `{ppo, λ=1.0, top_k=3, seed=100}`. +- **`M` = distinct configs tried = 1.** +- **`n_folds` = 2.** +- **`advisory_only = true`** — reason (verbatim from the artifact): *"n_folds=2 < 5 power + floor (P0-1); 3-symbol universe. NO alpha claim. Real alpha verdict deferred to Stage E + (n_folds>=5)."* + +`M = 1` removes the multiple-comparisons multiplicity problem (nothing to Bonferroni/BH-correct +across), but it does **not** remove the **power** problem: `n_folds = 2 < 5` is below the plan's +hard power floor, so the gate is **advisory-only and NO alpha may be claimed** regardless of which +direction the numbers point. This is stated up front, before the numbers. + +--- + +## 1. Bottom line (conclusion first) + +**Track 1 (Engineering) is COMPLETE. Track 2 (Alpha) is ADVISORY-NEGATIVE, and an alpha +claim is FORBIDDEN at `n_folds = 2` per the P0-1 power gate.** + +On the small co-dated 3-symbol universe, with explicit `cost_bps = 25`: + +- the **trained PPO policy LOSES to a do-nothing baseline** (`no_trade`, exactly 0.000%), and +- it also **loses to a simple same-features supervised ranker**, and +- the **supervised ranker itself does NOT beat `no_trade`** — it merely matches `equal_weight`. + +So no model — neither deep RL nor a cheap supervised ranker — found a tradeable stock-selection +edge in this data/feature set. The honest reading: **the binding constraint looks like +signal/data, not the algorithm.** Because `n_folds = 2` is a statistical coin flip, this is reported +as an **advisory direction only** — a real (non-advisory) verdict requires `n_folds ≥ 5`, which in +turn requires the Stage-D full-universe data that has **not yet been run**. + +A documented negative is the expected, valid outcome of this phase (plan §0, §3 Stage E, +Principle 4). We are not massaging the result and we are not grinding compute hoping alpha appears. + +--- + +## 2. Track 1 — Engineering (gateable, REQUIRED): COMPLETE + +The full deterministic pipeline — Gymnasium adapter → SB3 PPO training → `_fit_policy` holdout +integration → supervised-ranker comparison → invalid-action/MaskablePPO trigger logging — now +exists, is leakage-guarded, and is reproducible. Every Track-1 criterion from plan §0 / §3 is met. + +| Stage | Commit | What was delivered | Test / gate evidence | +|---|---|---|---| +| **C-0** (DB feature feasibility probe) | `fe632cb` | Read-only bounded probe (`finetune/stom_rl_c0_feature_probe.py`) confirming the four candidate source columns EXIST and are dense: `등락율`/`시가총액`/`고저평균대비등락율` at **100%** worst-session non-null, `체결강도` at **≥99.84%** worst-session non-zero (V10 PASS). | Per-session coverage reported; no feature added on assumption; decision = WAIT for minimal expansion. Probe artifact `.omx/artifacts/deep_rl/stageC_export/c0_probe.json` (not committed). | +| **C** (feature expansion) | `3976403` | Canonical features expanded **14 → 18**: added `change_rate`, `market_cap`, `high_low_mid_change_rate`, `trade_strength_avg_n` (the last as a **trailing/causal** N-bar mean) into `STOM_RL_CANONICAL_FEATURES` / `_FEATURE_MAPPING` / `_SOURCE_COLUMNS` (`finetune/qlib_stom_pipeline.py`). | Per-feature causality test (V11): `feature(T)` unchanged when bars > T removed. Suite green. | +| **A** (portfolio Gym adapter) | `84c91e9` | `PortfolioEnv` → Gymnasium adapter with fixed obs/action shapes and exposed `action_masks()` for MaskablePPO consumption. | SB3 `check_env` PASS — `observation_space=Box(-inf,inf,(68,),float32)`, `action_space=Discrete(6)` (V3); adapter contract + SB3-wrapper leakage canary (V2/V6b). | +| **B** (deterministic train + `_fit_policy` + ranker) | `d2104cc` (baseline) | `stom_rl/portfolio_sb3_train.py`: cost-aware SB3 PPO training with determinism pins (`torch.use_deterministic_algorithms(True)`, `set_num_threads(1)`, `device=cpu`, fixed seed) + `atol=1e-6, rtol=1e-5` reproducibility assertion. P0-2 plumbing = `TRAINED_BASELINES` key acceptance + module-level `TRAINED_POLICY_FACTORIES` registry consulted by `_fit_policy` *before* its `del` at L390. `supervised_ranker` baseline = sklearn `LogisticRegression`, TRAIN-fit / same disjoint strictly-later holdout-eval (no leaky strawman). | Trained `PolicyFn` runs through `run_portfolio_walk_forward` with runtime leakage guards (L460-465) passing (V5); column + SB3-wrapper leakage canaries PASS (V6/V6b); disjoint strictly-later holdout enforced; explicit non-zero `cost_bps=25` logged (V13). | + +**Determinism (V4):** runtime pinned and logged in the artifact — `torch_version 2.9.0+cu128`, +`cuda_available=true` but `device_pinned=cpu`, `use_deterministic_algorithms=true`, `num_threads=1`, +`seed=100`. Determinism is enforced by the pins, not hoped for. + +**Test suite (fresh, this stage, `py -3.11`):** **218 passed, 2 skipped** across the `tests/` +tree (which contains all the Stage A/B deep-RL adapter, P0-2 wiring, leakage-canary, masking, and +subprocess determinism tests). This is consistent with — and broader than — the **146 passed** +`stom_rl`-scoped count recorded in the `d2104cc` commit body (146 was the deep-RL module subset; +the full `tests/` tree is 218). Both clear the plan's ≥132/122-green floor. + +> **Honest caveat on the test run:** a *whole-repo* `pytest -q` run additionally collects +> `finetune/qlib_test.py`, which this stage failed to **collect** with a transient torch DLL-init +> error (`OSError [WinError 1114]` loading `torch\lib\c10.dll`) — an environment/CUDA DLL +> initialization issue in that one qlib-import module, NOT a deep-RL test failure. The 218/2 +> figure above is from `tests/ --ignore=finetune/qlib_test.py`; the deep-RL Stage A/B tests +> themselves all pass. + +`ruff` clean on the deep-RL stage files (`stom_rl/portfolio_sb3_train.py`, +`portfolio_walk_forward.py`, `portfolio_sb3_adapter.py`, `finetune/stom_rl_c0_feature_probe.py` — +"All checks passed", re-confirmed this stage). Pre-existing lint debt elsewhere in the tree is +out of scope for this phase. + +**Track-1 completion is valid regardless of whether the model makes money** (plan §0). It is complete. + +--- + +## 3. Track 2 — Alpha (research outcome): ADVISORY-NEGATIVE + +### The numbers (real, pulled from `stage_b_smoke_summary.json`, `cost_bps = 25`, `n_folds = 2`) + +Mean cost-adjusted return across the 2 disjoint, strictly-later holdout folds +(`advisory_comparison.mean_return_pct_by_policy`): + +| Policy | Mean return % (2 folds) | Fold 0 | Fold 1 | vs `no_trade` | +|---|---|---|---|---| +| `no_trade` | **0.000%** | 0.000% | 0.000% | — (baseline) | +| `equal_weight_candidate` | **−0.1049%** | −0.0859% | −0.1239% | loses | +| `supervised_ranker` | **−0.1049%** | −0.0859% | −0.1239% | loses | +| `trained_ppo` | **−1.5464%** | −1.4897% | −1.6031% | **loses badly** | + +Artifact verdict fields (verbatim): `rl_vs_ranker = "RL_<=_ranker"`; +`ranker_floor_verdict = "RECOMMEND ABANDONING RL (trained_ppo <= supervised_ranker on holdout)"`; +`alpha_claim = "FORBIDDEN (advisory-only, n_folds<5 per P0-1)"`. + +### Reading the result honestly — three findings + +**(a) Plain PPO cannot learn this mostly-invalid action space without masking.** +The trained PPO churned (trade_count = 24/24 steps every fold, turnover ≈ 5.95M vs the +baselines' single trade of 250k), burning ≈14.9k in cost per fold to end at −1.55%. The raw +**invalid-action rate = 96.9% (62/64 steps)** on the unmasked policy — far above the 5% trigger +threshold — so the **MaskablePPO trigger FIRED** (`maskable_ppo_trigger_fired = true`, +recommendation `"ESCALATE: record sb3-contrib MaskablePPO recommendation (NOT installed in Stage B)"`). +A *fair* RL attempt would require `sb3-contrib`/MaskablePPO, which is **not yet installed**. So the +catastrophic PPO number is partly an artifact of an un-masked policy flailing in a sparse Discrete +action space — it is NOT, on its own, proof that "RL has no edge here." + +**(b) But the supervised ranker — which sidesteps the masking problem entirely — ALSO fails.** +The `supervised_ranker` (a simple causal LogisticRegression on the same features, no invalid-action +problem) does not beat `no_trade`. In fact its returns are **byte-identical to `equal_weight` +(−0.10487592… on both folds)**: on this thin 3-symbol set the learned ranker degenerated to the +same selection as naive equal-weighting and still bled the same cost-driven −0.10%. Since the +cheap model that does NOT suffer PPO's masking pathology *also* finds nothing, the binding +constraint looks like **SIGNAL / DATA, not the algorithm.** + +**(c) Why no alpha may be CLAIMED here (P0-1 power gate).** +`n_folds = 2` with a config search is statistically a coin flip — a "strict majority of 2" = 2/2 is +clearable by chance. The plan's hard power floor requires `n_folds ≥ 5` before any alpha claim. +`M = 1` means no multiplicity correction is owed, but the power floor alone forbids a claim. Hence +**advisory-only, no alpha claimed** — only the direction (no edge found) is reported. Reaching +`n_folds ≥ 5` requires the Stage-D full-universe data, which has not been run. + +--- + +## 4. What would change the verdict (Stage D decision — NOT yet run) + +The non-advisory test is gated behind the **full-universe run** (plan §3 Stage D), which has **not +been executed**: + +- **Full-universe run** via the existing Page-16 checkpoint/resume harness (`stom_rl/full_universe.py`) + — a multi-hour background job — would broaden the co-dated symbol universe and sessions enough to + support **`n_folds ≥ 5`**, the precondition for a real (non-advisory) alpha test. +- **A fair RL attempt also needs MaskablePPO** (`sb3-contrib`, not yet installed), because the 96.9% + invalid-action rate shows plain penalty-PPO cannot learn this action space cleanly. +- Stage D carries a fixed **kill criterion** (plan §3 Stage D): stop and go to verdict if, on the + first `K = 5` evaluated sessions, the mean cost-adjusted return does not beat `equal_weight` by + `≥ 0.10%` (10 bps) net of cost. + +**Honest tempering:** the advisory signal already in hand — that even the cheap supervised ranker +has no edge over `no_trade` on this data — **lowers the expected payoff** of the multi-hour +full-universe run. We are not promising the full run will find alpha; the current evidence points +the other way. + +--- + +## 5. Recommendation — prioritize SIGNAL over algorithm + +**Do not invest in more deep-RL machinery next. Invest in signal first.** The data so far shows no +tradeable selection edge with the current features even for a *supervised* model — and a supervised +model is far cheaper than RL to evaluate. Concretely: + +1. **Highest-value next step:** run the **full-universe + expanded-feature** export and re-evaluate + the **supervised ranker first** (cheap). If the *ranker* finds an edge over `no_trade` at + `n_folds ≥ 5`, then — and only then — is MaskablePPO / deep-RL worth the compute. +2. **Only after a ranker edge appears** should `sb3-contrib`/MaskablePPO be installed and a fair RL + attempt made; spending RL compute before any model shows signal is the compute-blowup failure + mode the plan explicitly guards against (Pre-mortem Scenario iii, R3). +3. **Abandon-RL-for-now is the honest reading** unless and until a full-universe supervised ranker + demonstrates an edge. This matches the artifact's own `ranker_floor_verdict` + ("RECOMMEND ABANDONING RL") and the plan's P1-5 supervised-ranker floor rule. + +--- + +## 6. Anti-false-alpha guard status (plan §0, Stage E blocking criteria) + +| Guard | Status at this stage | +|---|---| +| Column leakage canary (trained path) | PASS (Stage B, V6) | +| SB3-wrapper leakage canary (P1-4) | PASS (Stage A/B, V6b) | +| Disjoint, strictly-later holdout (runtime asserts L460-465) | PASS (V5) | +| Explicit non-zero `cost_bps` in eval | PASS — `cost_bps = 25` logged (V13) | +| Shuffle / permutation sanity check (V8) | N/A this stage — not required to forbid a claim that is already forbidden by the power floor; mandatory if/when a `n_folds ≥ 5` claim is ever attempted | +| Supervised-ranker floor (P1-5 / V9b) | REPORTED — RL ≤ ranker → verdict recommends abandoning RL | +| ≥2-seed agreement (V9) | N/A — single pre-registered seed (seed=100); a claim would require ≥2 seeds | +| Multiplicity + power (P0-1 / V9c) | `M = 1` (no correction owed) BUT `n_folds = 2 < 5` → **advisory-only, NO alpha claim** | + +--- + +## 7. Discrepancies between the plan's expected numbers and the artifact + +None material. The artifact numbers match the plan/task expectations to rounding: + +- `trained_ppo` mean = **−1.5464%** (task said ≈ −1.546%) ✓ +- `supervised_ranker` mean = **−0.1049%** (task said ≈ −0.105%) ✓ +- `equal_weight_candidate` mean = **−0.1049%** (task said ≈ −0.105%) ✓ +- `no_trade` = **0.000%** ✓ +- raw invalid-action rate = **96.875% ≈ 96.9%**, MaskablePPO trigger FIRED, `sb3-contrib` NOT + installed ✓ +- pre-registration `M = 1`, `n_folds = 2`, `advisory_only = true` ✓ + +**One nuance worth flagging (not a discrepancy, an honest detail):** `supervised_ranker` and +`equal_weight_candidate` returns are **byte-identical** on both folds (−0.08586448598130181 and +−0.12388735420503227). On this thin 3-symbol universe the learned ranker collapsed to the same +top-k selection as naive equal-weighting — which strengthens, not weakens, the "no signal in this +data" reading: a trained model that cannot distinguish itself from equal-weight has found no edge. + +--- + +*Stage E verdict. Doc-only deliverable; the supporting `.omx/` artifacts are intentionally not +committed.* diff --git a/docs/stom_rl_deeprl_opening20min_design_2026-05-29.md b/docs/stom_rl_deeprl_opening20min_design_2026-05-29.md new file mode 100644 index 000000000..451aa8e3a --- /dev/null +++ b/docs/stom_rl_deeprl_opening20min_design_2026-05-29.md @@ -0,0 +1,208 @@ +# 딥러닝/RL 장초 20분 수익모델 — 게이트형 실험설계 (ultracode 연구) + +- 작성일: **2026-05-29 KST** / 브랜치: `feature/stom-rl-lab` +- 생성: **13-에이전트 워크플로우** (연구 6각도 fan-out → 적대적 검증 **6/6 survives=False** → 우리 DB 맞춤 설계 종합, ~1.3M 토큰) + **DB 실측 grounding** +- 상위 앵커: `docs/stom_rl_rl_feasibility_research_2026-05-29.md`(R0), `docs/stom_rl_session_progress_2026-05-29.md` +- **RULE strategy, NOT RL**: 기존 룰이 배포 후보(incumbent)이고 RL/ML은 "재판 중(on trial)". 본 문서는 **수익 주장이 아니라, 자기기만 없이 가부를 가리는 설계**다. + +> 적대적 검증 결과: 6개 연구각도의 "이건 우리 데이터에서 될 수도 있다" 주장이 **전부 기각**됐다(킬러: DSR 게이트가 도는 tradeable episode 수 부족 + triggered-subset 선택편향). 이것이 아래 사전확률이 낮은 이유다. + +--- + +## DB 실측 grounding (2026-05-29 측정) + +| 사실 | 값 | 함의 | +|---|---|---| +| 마이크로구조 피처 | 14종 전부 존재(40/40 종목) — 5단 호가/잔량·초당 매수/매도 금액·수량·체결강도·초당거래대금·회전율·VI | 표현학습 입력 충분 | +| 세션 내부 밀도 | 09:00–09:20에 **median 1067봉**(p10 838·p90 1186) — 약 89% 초마다 기록 | 세션 내부는 sparse 아님; 인스턴스당 ~1000-step 시퀀스 | +| 지도학습 학습쌍(P1) | (상태, 미래수익) **ts_imb ~5M / 전체 ~29M** | 예측 게이트엔 데이터 풍부 | +| **tradeable episode (DSR 게이트 단위)** | ts_imb 전체 **~5,175**(70/30 OOS ~1,900); bounded-120 set은 235/81 | **이것이 진짜 구속** | + +**정정/refine (중요):** 아래 §1·§7의 사전확률·필요엣지는 **bounded N=235** 기준이다. **게이트를 full universe(5,175 episode)에서 돌리면** DSR/MinTRL 바가 약 2.2x 낮아져 필요 incremental 엣지가 **≈+0.17~0.35%/trade(OOS)**로 내려가고, **+0.10%/trade도 MinTRL 허용**(5,175 ≫ 288–633). → 정직한 사전확률을 **~8–15%에서 ~12–22%로 소폭 상향**(데이터가 더 많아 통계검정력↑). 단 **selection-bias·23bp 비용 구속은 불변**이고, 게이트-우선·딥RL-후순위 설계는 그대로다. + +--- + +# STOM Within-Window Micro-Timing: Gated Deep-Learning / RL Research Design (RULE-vs-RL) + +**Status:** Research DESIGN, not a profit claim. Prior is LOW-to-MODERATE. Everything below is explicitly labeled RULE (the existing pre-registered strategy) vs RL/ML (the candidate being tested). The candidate must beat the RULE *and* a linear baseline net of 23 bp OOS, or it is killed. + +--- + +## 0. Honesty statement (read first) + +This document specifies *what would make a deep-learning / RL micro-timing result trustworthy on our existing DB*, and the gates that should kill it cheaply if the edge is not there. It does **not** assert that RL will produce profit. Our own lab has already run the nearest-neighbor experiment (causal exit-timing search) and it **failed the gate** (in-sample-best variant −0.71%/trade OOS, deflated Sharpe 0.931 < 0.95). The realistic expectation is a **documented NULL**, and the value of this program is largely to produce that null rigorously, not to harvest alpha. The RULE remains the deployable strategy throughout; RL is on trial. + +--- + +## 1. Honest prior + +**Realistic probability that a deep-learning/RL within-window micro-timing model beats the RULE net-of-cost OOS at our deflated-Sharpe > 0.95 gate: ~8–15%.** (Low. Not zero, because we have not literally run a *deep representation over the full per-second sequence* — only entry-bar-feature rankers and a 9-variant causal exit grid. The residual probability lives entirely in "the full intra-window sequence carries causal timing information that point-in-time entry-bar features and 9 hand rules could not encode.") + +Why so low, concretely: + +1. **The DSR-gate arithmetic is the killer, and it is quantified.** The deflated-Sharpe test runs on *tradeable episodes*, not 1s rows. The realized exit analysis ran at **N≈235 ts_imb episodes, OOS≈81**. From the rule's own dispersion (mean +0.906%/trade, per-trade Sharpe ≈0.303 ⇒ sd ≈3.0%), a *baseline-relative (paired-difference)* series must show, to clear DSR>0.95 at OOS T=81 with a realistic deep-model trial count (n_trials 20–50): **a differential mean of roughly +0.37% to +0.76%/trade of pure timing alpha on top of the rule.** That is a large fraction of — or larger than — the rule's *entire* +0.906%/trade edge. Even at the full N=235, the bar is +0.30% to +0.64%/trade. A within-20-minute entry/exit *re-scheduling* edge of that magnitude, net of 23 bp, is implausible. (Numbers: §7.) +2. **MinTRL says the small edges we could plausibly hope for are statistically inadmissible at our sample size.** A +0.10%/trade incremental edge (sd_diff≈1.0–1.5%) needs **288–633 trades** to be distinguished from zero at 95% — we have ~81 OOS / ~235 total. Only a +0.30%/trade edge with low difference-variance is admissible at N≈81, and an edge that large is not credible from coarse 1s timing. +3. **Adjacent gates already fired NO-GO in-house:** cross-sectional selection alpha absent (shuffle tests, 1s/1min/session); causal exit improvements lose OOS (DSR 0.931); perfect-foresight exit ceiling (capture 17.8%, regret +4.18%/trade) proven to be **non-causal hindsight**; a logistic-regression entry-bar ranker collapsed to equal-weight. +4. **Domain-matched literature predicts deep loses to linear on exactly this market.** Kang 2026 (*The Limits of Complexity*, arXiv:2601.07131): Korean equities, 2020–2024, ICA-Wavelet-LSTM Sharpe ≈0 vs linear momentum Sharpe 1.30. We must clear *both* the rule *and* a linear baseline; the prior is that the linear baseline matches or beats the deep model. +5. **Data physics caps it.** 1s *aggregated*, event-triggered, irregular snapshots; **no L2 queue position, no order-by-order.** The published net-of-cost RL wins (Nevmyvaka–Kearns execution; queue-position alpha) all require microstructure we do not have. Honest fill modeling forces marketable-only fills + de-idealization (already costs −0.045% to −0.140%/trade in-house), eating the headroom. + +**Upside scenario (the ~8–15%):** the full per-second OFI / 체결강도 / depth-dynamics *sequence* contains a continuation-vs-reversal separation (Berkman et al. 2012 opening-overshoot mechanism, strong in retail-dominated KR attention stocks) that the entry-bar snapshot and 9 rules genuinely could not encode, and it is large enough to clear the gate. We test for this directly and cheaply before any deep RL. + +--- + +## 2. Problem formulation: what to ATTEMPT vs AVOID (ranked) + +Ranked best→worst by expected feasibility *on this data*. We attempt strictly in this order, gated. + +| Rank | Formulation | Verdict | Why | +|---|---|---|---| +| **A1 (DO FIRST)** | **Supervised short-horizon predictability PROBE** — does the full per-second panel predict a noise-robust, cost-relevant within-window target at all? Purged CV + Deflated Sharpe. | **DO — cheap gate** | Answers the only question that matters before any RL. If a supervised head on the full sequence cannot beat a naive baseline OOS, every RL formulation downstream is dead. (§3) | +| **A2** | **Supervised meta-labeling on entry timing**: layered on the RULE, classify "is the next k-second window a better marketable entry than entering now?" using only causal features; trade as a tie-breaker. Linear first, then deep. | **DO if A1 passes** | This is the honest operationalization of the open question. Ablation: (1) RULE, (2) RULE+linear-OFI-timing, (3) RULE+deep-representation-timing. Deep must beat **both** (Kang 2026). | +| **A3** | **Representation-learning state encoder** (CNN/transformer over the sequence) feeding a *fixed* execution policy, scored end-to-end on de-idealized net PnL — NOT on classification F1. | **DO only if A2-deep beats A2-linear** | DeepLOB/TLOB-style encoder repurposed for timing, not tick-direction. Pooled cross-symbol (Sirignano–Cont). Justified only if the deep representation has already shown incremental value at A2. | +| **A4** | **Offline RL, near-behavior, low-capacity**: IQL (advantage-weighted BC) or filtered-BC, cost-embedded sparse baseline-relative reward, seeded from the RULE. | **CONDITIONAL — last resort** | Only after A1–A3 show a supervised edge exists. IQL's benign failure mode (reverts to BC ≈ the rule) makes it the safest RL. Imitation target must be a **state-averaged** edge, never single-trajectory return (avoids cloning luck — Paster et al. 2022). | + +**AVOID (do NOT attempt on this data):** + +| Formulation | Why avoid | +|---|---| +| **Tick-direction prediction (DeepLOB/TLOB native target)** | Predicted moves are sub-bp to a few bp; crushed by 23 bp. Hopeless. | +| **Decision Transformer / return-conditioned sequence model from scratch** | (a) Return-conditioning fails in stochastic environments (Paster et al. 2022 — confuses luck with skill, near-maximal at 1s); (b) more data-hungry than CQL (Bhargava et al. 2024); (c) ~235 correlated episodes ≪ the millions DT needs; (d) filtered-BC matches/beats DT anyway (Omori et al. 2025). Overfit magnet. | +| **Naive online Q-learning / PPO with OOD action bootstrapping** | Value overestimation under our narrow, selection-biased support is lethal (Kumar et al. 2020). | +| **Per-step dense mark-to-market reward** | Reward-hacking / noise-fitting magnet on irregular 1s bars; agent churns. Use cost-embedded *sparse terminal* reward only. | +| **Queue-position / passive-limit-fill / optimal-placement alpha** | Requires L2/L3 FIFO position we do not have (Moallemi–Yuan; Maglaras–Moallemi–Zheng). Unsimulatable honestly. Off the table without new data. | +| **Distributional RL / CVaR as the learning objective; differential-Sharpe dense reward** | Add objective-design degrees of freedom (atoms, quantiles, α, risk-aversion weight) that fit noise on ~235 episodes. Use CVaR for downstream *sizing*, not as reward. | +| **BC of the perfect-foresight oracle** | The oracle conditions on the future; the lab proved the 17.8% capture is non-causal. Distilling it learns a non-causal target that collapses OOS. Distill from the RULE, never from the oracle. | + +--- + +## 3. CHEAP PRE-CHECKS / GATES before any deep RL + +These run in **hours on CPU**, before any GPU/RL spend. If any fails, STOP. + +### Gate P0 — MinTRL admissibility (cheapest, run first) +Compute, on the *incremental* (paired RL-minus-RULE) per-trade return series, the Minimum Track Record Length needed to distinguish a plausible incremental Sharpe from zero at 95% (Bailey–López de Prado 2012, skew/kurtosis-adjusted). +- **Metric:** MinTRL(SR_inc, skew, kurt, 0.95). +- **Threshold:** If N_OOS (≈81) and N_total (≈235) are **below MinTRL for the smallest incremental edge worth deploying** (target ≥+0.20%/trade after the +0.906% rule), the result is **statistically inadmissible regardless of backtest**. From §7: a +0.10%/trade edge needs 288–633 trades → inadmissible. A +0.20%/trade edge needs 79–167 trades → admissible *only* if difference-variance is low and we accept ~all of N=235. **GO only if the pre-registered target edge is admissible at our N.** This gate alone likely caps the program. + +### Gate P1 — Supervised predictability probe (the core cheap gate) +Train a **linear/logistic** model and a **shallow gradient-boosted** model on the **full per-second panel** (all §4 features, with the full sequence summarized into causal aggregates — see below) to predict a **noise-robust within-window target**: +- **Target (noise-robust, NOT single-trajectory return):** for each (symbol,session), the *state-conditioned average* net-of-23bp forward return over the next k∈{5s,15s,30s,60s} marketable entry windows, binned and cross-validated, so the label reflects the conditional edge of similar states rather than one lucky path (ESPER-style cluster target — Paster et al. 2022). +- **Validation:** Purged & embargoed CV at **(symbol,session)** granularity (never split a session; embargo by trading DAY; purge by SYMBOL so the model cannot memorize per-ticker behavior). López de Prado AFML Ch.7. +- **Gate metric & threshold (exact):** + 1. **OOS rank-IC / AUC vs naive baseline:** the model's OOS predictive score must exceed the naive baseline (predict the unconditional within-window mean) by a margin whose **stationary-bootstrap (block = (symbol,session)) 95% CI excludes zero**. + 2. **Translated to PnL:** a simple threshold policy on the probe's signal, scored net of 23 bp on the RULE-eligible population, must produce a per-trade mean whose **Deflated Sharpe > 0.95** charging **every probe trial** (feature sets × horizons × model families) to n_trials. +- **STOP rule:** If the probe cannot beat the naive baseline OOS with CI excluding zero, **STOP — do not build any deep representation or RL.** The edge is not in this data. (This is the gate the literature and our priors say will most likely fire NO-GO.) + +### Gate P2 — Linear-beats-deep pre-registration +Before training any deep model, freeze the **linear-OFI-timing baseline** result from P1. The deep model is only worth building if it is *pre-registered to beat the linear baseline by a margin admissible at our N*. (Kang 2026 predicts it will not.) + +--- + +## 4. State / feature design (from OUR columns) + +All features are **strictly causal** (point-in-time, no look-ahead). Built from the per-second sequence up to decision time t. + +**Price/return state** +- 등락율, 고저평균대비등락율, 저가대비고가등락율; first-bar return sign as a weak within-day prior; time-in-window (seconds since 09:00, the single most informative scalar for a 20-min decision). + +**Order-flow imbalance dynamics (strongest theoretical basis — Cont–Kukanov–Stoikov 2014; Kolm–Turiel–Westray 2023)** +- Per-second signed flow: 초당매수수량−초당매도수량, 초당매수금액−초당매도금액 (and ratios); 거래대금증감. +- **Generalized OFI** from 5-level depth deltas: Δ(매수잔량1-5) vs Δ(매도잔량1-5) across consecutive 1s snapshots, depth-weighted. Label this explicitly as *approximate* OFI (inferred from 1s deltas, not true book events) — noisier than message-level OFI. +- Rolling OFI over trailing windows {3s,5s,10s,30s} (the documented ~seconds horizon), plus OFI *acceleration* (the sequence carries this; the entry-bar snapshot does not — this is the candidate causal signal the rule cannot encode). + +**Trade-strength / pressure** +- 체결강도 level and its trailing slope; 초당거래대금, 당일거래대금; 회전율; 전일동시간비 (relative-volume regime). + +**Book shape (5-level)** +- Best-level imbalance 매수잔량1/(매수잔량1+매도잔량1); total-depth imbalance 매수총잔량/매도총잔량; spread = 매도호가1−매수호가1 (and in ticks via 호가단위); depth slope across levels 1→5; microprice = depth-weighted mid. + +**VI state (KR-specific — JRFM 2022; handle as hazard)** +- Binary in-VI flag, time-since-VI-release, time-to-VI (causal only). **Never use 해제시간 forward** (look-ahead). VI bars are non-executable → mask for fills. + +**Regime / context (for pooled cross-symbol training — Sirignano–Cont 2019)** +- 시가총액 bucket, KOSPI-vs-KOSDAQ flag (split — KOSDAQ is more retail/biotech with different dynamics). + +**Normalization:** per-(symbol,session) z-scoring of flow/depth using only trailing in-window data (causal); cross-symbol pooling with regime one-hots. + +--- + +## 5. Architecture + offline-RL method + reward + +**Encoder (only if Gate P1 passes and A2-deep is justified):** +- **Causal Temporal Convolutional Network (TCN) or small causal transformer** over the variable-length, irregular 1s sequence, with **time-gap embeddings** (Δt between event-triggered bars) so irregular spacing is modeled rather than assumed-regular. Transformer attention handles irregular spacing better than fixed-grid CNN; but **cap capacity hard** (≤2 layers, small width) — ~235 episodes cannot support DeepLOB-scale models. **Pool across all symbols** (per-symbol counts are tiny). +- Input tokens = per-second feature vector (§4). 5-level depth (vs DeepLOB's 10) loses some signal — accept it. + +**RL method (A4, last resort): IQL (Kostrikov et al. 2021) or TD3+BC (Fujimoto–Gu 2021).** +- **Why IQL/filtered-BC, not CQL/DT:** IQL never queries Q on OOD actions, sidestepping the overestimation lethal under our narrow selection-biased support; its advantage-weighted-BC extraction degrades *gracefully toward BC* (≈the rule) when signal is weak — the benign failure mode we want. CQL's pessimism would clamp back to behavior anyway given degenerate coverage; DT confuses luck with skill in our near-maximally-stochastic 1s environment and is more data-hungry. +- **Action space:** discrete, minimal — {enter now, wait 1 more bar (up to a cap), exit now, hold} within 09:00–09:20, marketable orders only. No passive/limit actions (unsimulatable). + +**Reward (cost-embedded, sparse-terminal, baseline-relative):** +``` +r_episode = NetPnL_agent(entry,exit | full 23bp deducted at every position change, marketable+slippage) + − NetPnL_RULE(same symbol-session) # paired baseline subtraction +r_step = 0 for all non-terminal bars # sparse: suppress 1s noise & churn +``` +- **Why:** Cost in-reward (not post-hoc filter) is the single most important anti-hacking choice (Amodei et al. 2016; Moody–Saffell 2001). Baseline subtraction is a potential-style constant shift per episode — it does **not** change the optimal policy (Ng–Harada–Russell 1999) but slashes reward variance and centers learning on the marginal timing edge, while making "just replicate the RULE" the zero-skill floor. +- **Risk term:** **none in the reward** (avoids objective-design DoF on a tiny sample). Apply CVaR / downside cap *downstream as a position-sizing / deployment guard*, not as the learning objective. +- **De-idealization (mandatory):** marketable-with-slippage only; add conservative size-aware slippage on top of 23 bp; SL gap-through modeled (already −0.045% to −0.140%/trade in-house); skip a bar after signal to avoid bid-ask-bounce capture (Heston–Korajczyk–Sadka 2010 — sub-hour "signal" is largely bounce/liquidity noise). Any result depending on passive fills is discarded as unverifiable. + +--- + +## 6. Data pipeline: the per-second SEQUENCE extractor (NEW — required) + +**Problem:** current artifacts store only **entry-bar features** per instance. Every formulation here needs the **full causal per-second multi-feature sequence** per (symbol,session). This extractor is the prerequisite build. + +**Extractor outline (implementable on the existing DB):** +1. **Iterate (symbol,session) keys** in the triggered subset (~29,139 gap≥2% instances; ts_imb subset ~5,175). For each, pull all 1s rows in [09:00:00, 09:30:00] ordered by timestamp. +2. **Build the causal feature panel** per row (§4): keep raw columns + compute trailing-window OFI/체결강도 slopes/microprice/depth-imbalance using **only rows ≤ t** (assert no forward reference in code; unit-test with a shuffled-future canary that must NOT change features). +3. **Handle irregular/sparse bars:** store actual timestamps + Δt gaps as features; do **not** forward-fill across VI halts (mask instead). Record an `is_VI`/`executable` flag per bar. +4. **Define the marketable-fill price series** per bar (현재가/호가-based) for honest PnL replay; precompute the RULE's per-episode net PnL (TP5/SL1/09:25) for the baseline-relative reward. +5. **Persist** as ragged sequences (e.g., Parquet with a `(symbol,session)` group key + ordered arrays, or `.npz` per episode) with a manifest mapping each episode to year / KOSPI-KOSDAQ / 시가총액 bucket for stratified CV and pooling. +6. **Leakage assertions in the pipeline itself:** purge/embargo keys are emitted at extraction time (session boundaries, symbol id, trading-day) so downstream CV cannot accidentally split a session. + +**Cost/scale:** ~29k episodes × ≤1,800 bars × ~40 features is modest (low-GB); fits in memory in shards. No new data is collected — this is pure re-extraction from the existing DB. + +--- + +## 7. Validation protocol (concrete) + +**Resampling unit = (symbol,session) episode. NEVER the 1s bar.** Within-session bars are strongly autocorrelated; episodes across distinct (symbol,session) are near-independent. + +1. **Purged & embargoed walk-forward** (López de Prado AFML Ch.7): train on earlier years, test on held-out *later* years AND a held-out *symbol* slice; purge any training episode whose label horizon overlaps a test session; **embargo by trading day**; **purge by symbol** so per-ticker memorization cannot leak. +2. **CPCV** (Combinatorial Purged CV, AFML Ch.12; skfolio `CombinatorialPurgedCV`): blocks cut at (symbol,session) level → yields a *distribution* of OOS Sharpes from one dataset (essential at our small N). +3. **PBO via CSCV** (Bailey–Borwein–López de Prado–Zhu 2016): config×time-block matrix over all architectures/seeds/hyperparameters. **Threshold: PBO < 0.5** (and report the value); this directly answers "did we pick a fluke config." +4. **Deflated Sharpe Ratio** (Bailey–López de Prado 2014): on the **baseline-relative incremental** per-trade series, charging **the complete trial ledger** to n_trials. **Threshold: DSR > 0.95.** Scope every claim as "*conditional on STOM-trigger + rule-eligibility*" — DSR here tests SR>0 *within the triggered population*, not unconditional gap alpha. +5. **Harvey–Liu haircut Sharpe** (2015) with a **complete trial ledger that includes all prior lab negatives** (1s/1min/session selection, 9-variant exit grid) — the family is already large; the incremental deep signal must clear a high bar. +6. **Multi-seed interval estimates** (rliable, Agarwal et al. 2021): report **IQM + stratified-bootstrap 95% CI + Probability-of-Improvement** over the RULE, stratified by **year** and **KOSPI/KOSDAQ**. **If the IQM CI of the incremental net-of-cost return crosses zero → no defensible improvement.** rliable handles seed variance only — necessary, not sufficient; combine with DSR/PBO. +7. **Dependence-aware CIs:** stationary/block bootstrap (Politis–Romano 1994) with block = (symbol,session), additionally block by **day** for cross-sectional (macro) dependence; powers a White Reality-Check / Hansen SPA across deep-RL configs. + +**Quantified bar (from §0 computation, so reviewers see the target before results):** to clear DSR>0.95 on the paired-difference series, the **required incremental edge is ≈+0.37% to +0.76%/trade at OOS N=81** (n_trials 20–50), or **≈+0.30% to +0.64%/trade at full N=235**. MinTRL: a +0.10%/trade edge is inadmissible (needs 288–633 trades); +0.20%/trade is borderline (79–167); only ≥+0.30%/trade with low difference-variance is comfortably admissible. **Pre-register the target edge and verify admissibility at P0 before spending GPU.** + +--- + +## 8. Go/No-Go gates and falsification at each stage + +| Stage | Gate | GO if | FALSIFIED / STOP if | +|---|---|---|---| +| **S0 Extractor** | Leakage canary | Shuffled-future canary leaves features unchanged; RULE PnL reproduces the known +0.6–0.95%/trade | Any forward leakage detected → fix before proceeding | +| **S1 MinTRL (P0)** | Admissibility | Pre-registered target edge (≥+0.20%/trade) is admissible at N≈235 | Target edge needs > N trades → **STOP; inadmissible** | +| **S2 Supervised probe (P1)** | OOS predictability | Probe beats naive baseline OOS, bootstrap CI excludes zero, threshold-policy DSR>0.95 charging all probe trials | Probe ≤ baseline OOS → **STOP; edge not in data.** Kills all RL. | +| **S3 Linear timing (A2-linear)** | Beats RULE | RULE+linear-OFI-timing beats RULE OOS, incremental DSR>0.95, PBO<0.5 | ≤ RULE OOS → **STOP** (rule already optimal among simple signals) | +| **S4 Deep timing (A2/A3-deep)** | Beats RULE *and* linear | Deep beats **both** baselines, incremental DSR>0.95, PBO<0.5, rliable PoI CI excludes zero | Deep ≤ linear (Kang-2026 prediction) → **STOP; depth buys nothing.** Deep ≤ RULE → **FALSIFIED.** | +| **S5 Offline RL (A4)** | Beats RULE | IQL/filtered-BC incremental DSR>0.95 on paired reward, multi-seed IQM CI excludes zero | IQL reverts to BC≈RULE (expected) → **documented NULL.** RL < RULE → **FALSIFIED.** | + +**Single overriding falsifier:** if the incremental (RL-minus-RULE) net-of-23bp per-trade series has an IQM 95% CI that includes zero after purged CV + DSR + PBO at the honest trial count, **the approach is falsified on this data** — exactly as the analogous causal-exit search was (DSR 0.931). We stop and publish the null. + +--- + +## 9. Honesty statement (restated, for the record) + +- This is an **experimental DESIGN with a LOW-to-MODERATE prior (~8–15%)**, not a profit claim and not a prediction of success. +- Every comparison is labeled **RULE** (the existing pre-registered, deployed, net-positive strategy) vs **RL/ML** (the candidate on trial). The RULE is the incumbent; RL must *beat* it net-of-cost OOS through the gates above, or it is rejected. +- **Reward design cannot create an edge the data does not contain.** The cost-embedded baseline-relative sparse reward is chosen to *avoid false positives*, not to manufacture profit. +- Claims are valid **only conditional on STOM-trigger + rule-eligibility**; no extrapolation to general opening gaps (no recorded control group exists to de-bias). +- The most likely honest outcome, given our priors and the DSR/MinTRL arithmetic, is a **rigorously documented NULL** at S1/S2. That is a legitimate and valuable result, and the program is designed to reach it cheaply (CPU-only pre-checks) before any deep/RL spend. + +--- + +**Key sources cited (all real, in the bundle):** Cont–Kukanov–Stoikov 2014 (arXiv:1011.6402); Kolm–Turiel–Westray 2023 (SSRN 3900141); Sirignano–Cont 2019 (arXiv:1803.06917); Zhang–Zohren–Roberts 2019 DeepLOB (arXiv:1808.03668); Berti–Kasneci 2025 TLOB (arXiv:2502.15757); Kang 2026 (arXiv:2601.07131); Berkman et al. 2012 (SSRN 1625495); Heston–Korajczyk–Sadka 2010 (arXiv:1005.3535); Nevmyvaka–Feng–Kearns 2006 (ICML); Fang et al. 2021 OPD (arXiv:2103.10860); Kostrikov et al. 2021 IQL (arXiv:2110.06169); Fujimoto–Gu 2021 TD3+BC (arXiv:2106.06860); Kumar et al. 2020 CQL (arXiv:2006.04779); Paster et al. 2022 (arXiv:2205.15967); Omori et al. 2025 (arXiv:2507.10174); Bailey–López de Prado 2014 DSR (SSRN 2460551), 2012 PSR/MinTRL (SSRN 1821643); Bailey et al. 2016 PBO/CSCV (SSRN 2326253); Harvey–Liu 2015 (SSRN 2345489); López de Prado AFML 2018 Ch.7/12; Agarwal et al. 2021 rliable (NeurIPS); Politis–Romano 1994 (JASA); Ng–Harada–Russell 1999 (ICML); Moody–Saffell 2001 (IEEE TNN); Amodei et al. 2016 (arXiv:1606.06565). \ No newline at end of file diff --git a/docs/stom_rl_dqn_ppo_extension_design_2026-05-23.md b/docs/stom_rl_dqn_ppo_extension_design_2026-05-23.md new file mode 100644 index 000000000..041211e33 --- /dev/null +++ b/docs/stom_rl_dqn_ppo_extension_design_2026-05-23.md @@ -0,0 +1,275 @@ +# STOM 강화학습 DQN/PPO 확장 설계 + +작성일: 2026-05-23 KST +브랜치: `feature/stom-rl-lab` +목적: **현재 STOM tick/back 1초봉 강화학습 실험실을 DQN/PPO 계열로 확장할 수 있는지 판단하고, 새 의존성 추가 전 필요한 계약·리스크·구현 순서를 고정한다.** + +--- + +## 1. 결론 요약 + +현재 STOM 강화학습 환경은 DQN/PPO 확장이 **가능한 구조**다. 하지만 바로 `stable-baselines3`를 붙여 장시간 학습을 시작하기보다는, 먼저 Gymnasium 호환 adapter를 추가하고 작은 smoke 학습이 `check_env`와 cost-gate 검증을 통과하는지 확인해야 한다. + +| 질문 | 판단 | +|---|---| +| DQN 적용 가능성 | 가능. action이 `hold/buy/sell` 3개 discrete라 DQN 후보가 맞다. | +| PPO 적용 가능성 | 가능. discrete action에도 적용 가능하지만 on-policy라 sample 비용이 더 크다. | +| 지금 바로 새 dependency 추가 | 보류. 사용자가 명시 승인하기 전까지 추가하지 않는다. | +| 현재 `StomTickTradingEnv` 그대로 SB3 사용 | 불완전. Gymnasium 스타일 반환값은 있으나 실제 `gymnasium.Env` 상속/space가 아니다. | +| 우선순위 | 1) Gymnasium adapter 2) DQN smoke 3) full train/val 4) PPO 비교 | + +--- + +## 2. 공식 가이드 기준 + +P006 설계는 다음 공식 문서를 기준으로 했다. + +| 자료 | 설계에 반영한 내용 | +|---|---| +| Gymnasium Env API: https://gymnasium.farama.org/api/env/ | `reset()`은 `(observation, info)`, `step()`은 `(observation, reward, terminated, truncated, info)` 형태여야 한다. | +| Stable-Baselines3 custom env: https://stable-baselines3.readthedocs.io/en/master/guide/custom_env.html | custom env는 Gymnasium interface와 `check_env` 검증이 필요하다. | +| Stable-Baselines3 DQN docs: https://stable-baselines3.readthedocs.io/en/v2.7.0/modules/dqn.html | DQN은 discrete action 학습 후보이며 `MlpPolicy`, `MultiInputPolicy` 등 정책을 선택한다. | +| Stable-Baselines3 custom policy docs: https://stable-baselines3.readthedocs.io/en/master/guide/custom_policy.html | 2D 시계열 observation을 그대로 쓸지, flatten/feature extractor를 둘지 결정해야 한다. | + +--- + +## 3. 현재 코드의 준비 상태 + +현재 핵심 파일은 `stom_rl/trading_env.py`다. + +| 항목 | 현재 상태 | DQN/PPO 관점 | +|---|---|---| +| `reset()` 반환 | `(observation, info)` | Gymnasium 스타일과 일치 | +| `step()` 반환 | `(observation, reward, terminated, truncated, info)` | Gymnasium 스타일과 일치 | +| action | `0 hold`, `1 buy`, `2 sell` | DQN/PPO 둘 다 가능 | +| observation shape | `(lookback_window, 9)` | SB3 MLP는 flatten 처리 필요 | +| observation columns | OHLCV/amount + position/unrealized/time | 최소 상태는 있음 | +| space | 자체 `BoxSpace`, `DiscreteSpace` | SB3에는 실제 `gymnasium.spaces` 필요 | +| env base class | 일반 class | SB3에는 `gymnasium.Env` adapter 필요 | +| reward mode | `horizon`, `mark_to_market` | DQN/PPO에는 `mark_to_market` 우선 검토 | +| dependency | `gymnasium`, `stable-baselines3` 없음 | 새 dependency 승인 전 문서화 단계 유지 | + +--- + +## 4. 왜 contextual bandit만으로 부족했는가 + +현재 full test split 리더보드에서 contextual bandit은 no-trade보다 낫지만 buy-and-hold를 이기지 못했다. + +| 모델/정책 | 평균 episode net % | MDD % | 판단 | +|---|---:|---:|---| +| buy_and_hold | 0.5126 | -50.7280 | 현재 최강 baseline | +| contextual_bandit | 0.1254 | -51.5892 | watch, 실거래 후보 아님 | +| no_trade | 0.0000 | 0.0000 | 리스크 기준선 | + +부족한 이유: + +1. contextual bandit은 한 시점의 feature로 “살지 말지”를 판단한다. +2. position 상태, 연속 의사결정, 청산 타이밍, drawdown 제어를 충분히 학습하지 못한다. +3. 25bp 비용 후 target 분포 자체가 음수로 치우쳐 있다. +4. 평균 수익은 양수지만 MDD가 buy-and-hold보다 나빠 cost gate를 통과하지 못했다. + +DQN/PPO를 검토하는 이유는 **한 번의 예측 점수**가 아니라 **상태→행동→보상 누적**을 학습하기 위해서다. + +--- + +## 5. DQN/PPO 적용 전 필요한 adapter + +현재 환경을 직접 바꾸기보다, 기존 read-only 환경을 감싸는 adapter를 추가하는 것이 안전하다. + +예상 파일: + +```text +stom_rl/gym_adapter.py +tests/test_stom_rl_gym_adapter.py +``` + +adapter 책임: + +| 책임 | 설명 | +|---|---| +| `gymnasium.Env` 상속 | SB3가 custom env로 인식하게 함 | +| `gymnasium.spaces.Discrete(3)` | action space를 실제 Gymnasium space로 제공 | +| `gymnasium.spaces.Box` | observation space를 실제 Gymnasium Box로 제공 | +| dtype 고정 | `np.float32` 유지 | +| reset/step 위임 | 기존 `StomTickTradingEnv` 계약 유지 | +| `check_env` 통과 | SB3 학습 전 필수 smoke 검증 | + +dependency를 추가하지 않는 현재 단계에서는 adapter 코드를 바로 넣지 않는다. 다음 구현 단계에서 사용자가 새 dependency 추가를 승인하면 위 파일부터 만든다. + +--- + +## 6. Observation 설계 + +현재 observation은 `(300, 9)`이다. + +| 방식 | 장점 | 단점 | 판단 | +|---|---|---|---| +| flatten + MlpPolicy | 가장 단순, DQN/PPO smoke 빠름 | 시계열 구조를 직접 학습하기 어려움 | 1차 smoke 후보 | +| Dict observation + MultiInputPolicy | 가격/포지션/시간 feature 분리 가능 | 구현 복잡도 증가 | 2차 후보 | +| custom CNN/Transformer extractor | 1초봉 시계열 구조 반영 가능 | 과최적화·학습 비용 증가 | 성과 확인 후 후보 | + +초기 DQN/PPO는 과도한 모델보다 **flatten MLP + 강한 검증**이 낫다. 현재 문제는 모델 복잡도보다 비용 후 일반화와 리스크 관리가 더 중요하다. + +--- + +## 7. Reward 설계 + +DQN/PPO에는 `reward_mode=mark_to_market`을 우선 검토한다. + +| reward mode | 장점 | 위험 | +|---|---|---| +| `horizon` | 300초 후 수익 목표와 직접 연결 | 매 step마다 미래 horizon 보상이 겹쳐 과도한 dense target이 될 수 있음 | +| `mark_to_market` | 실제 보유 상태 변화와 비용을 순차 반영 | 초기 학습이 느릴 수 있음 | +| realized PnL only | 실제 매매 손익에 가장 가까움 | sparse reward라 학습 난이도 상승 | + +권장 순서: + +1. DQN smoke: `mark_to_market` +2. 비교 실험: `horizon` vs `mark_to_market` +3. 최종 판단: full test leaderboard와 cost gate 기준 + +--- + +## 8. Action 설계 + +현재 action은 `hold/buy/sell`이다. invalid action은 penalty를 받는다. + +문제: + +- 이미 보유 중인데 `buy` +- 미보유 중인데 `sell` +- 잦은 buy/sell 반복으로 비용 폭증 + +개선 후보: + +| 후보 | 설명 | 적용 시점 | +|---|---|---| +| invalid penalty 유지 | 현재 구조 유지 | DQN smoke | +| target position action | `flat/long` 2개 action으로 단순화 | invalid action이 많으면 적용 | +| action masking | 불가능 action 차단 | SB3 기본 DQN/PPO에는 직접 지원 약함 | +| sb3-contrib MaskablePPO | action mask 지원 | 새 dependency 리스크 검토 후 | + +초기에는 현재 3-action을 유지하되, invalid action rate를 leaderboard에 추가해 판단한다. + +--- + +## 9. 학습/평가 프로토콜 + +| 단계 | split | 목적 | 통과 기준 | +|---|---|---|---| +| smoke | train 소량 | 코드 계약 검증 | 학습 완료, artifact 생성 | +| validation | val 전체 또는 대표 stride | hyperparameter 선택 | no-trade 초과, 비용 후 양수 | +| final | test 전체 | 최종 판단 | buy-and-hold 초과 또는 cost gate 통과 | + +절대 하지 말아야 할 것: + +1. test split으로 hyperparameter를 반복 튜닝 +2. 비용 없는 수익률만 보고 성공 판단 +3. trade count/MDD 없이 평균 수익만 보고 채택 +4. seed 1개만 보고 결론 확정 + +--- + +## 10. DQN 우선 실험안 + +DQN을 PPO보다 먼저 권장한다. + +| 이유 | 설명 | +|---|---| +| action이 discrete | DQN의 기본 적용 대상 | +| off-policy | replay buffer로 sample 효율 기대 | +| smoke 비용 | PPO보다 짧은 실험이 가능 | +| 비교 용이 | buy/hold/sell Q-value 정책 해석 가능 | + +예상 1차 파라미터: + +| 항목 | 값 | +|---|---| +| policy | `MlpPolicy` | +| reward mode | `mark_to_market` | +| train split | train | +| eval split | val → test | +| total timesteps smoke | 50k 이하 | +| full 후보 | 500k~2M timesteps | +| seed | 최소 3개 | +| artifact | model zip, eval summary, leaderboard row | + +--- + +## 11. PPO 후순위 실험안 + +PPO는 다음 조건을 만족하면 진행한다. + +1. DQN smoke가 정상적으로 학습된다. +2. DQN이 no-trade는 이기지만 buy-and-hold를 못 이긴다. +3. action 분포가 불안정하거나 Q-learning이 과최적화된다. + +예상 1차 파라미터: + +| 항목 | 값 | +|---|---| +| policy | `MlpPolicy` | +| n_steps | 512~2048 | +| batch_size | 64~256 | +| gamma | 0.99 | +| learning_rate | 3e-4 근처 | +| eval | validation cost gate 우선 | + +PPO는 sample 비용이 크므로 full test 판단 전 validation에서 먼저 걸러야 한다. + +--- + +## 12. 새 dependency 판단 + +현재 `requirements.txt`에는 `gymnasium`, `stable-baselines3`가 없다. + +| 선택지 | 장점 | 단점 | 판단 | +|---|---|---|---| +| dependency 추가 보류 | 현재 repo 안정성 유지 | DQN/PPO 실제 학습은 못 함 | 현재 P006 결론 | +| `gymnasium`만 추가 | env 계약 검증 가능 | SB3 학습은 못 함 | adapter 단계 후보 | +| `stable-baselines3` 추가 | DQN/PPO 즉시 사용 | 의존성/설치/버전 리스크 | 사용자 승인 후 | +| 자체 DQN 구현 | 의존성 최소화 | 검증된 RL 구현을 다시 만드는 리스크 | 비권장 | + +권장: + +1. 다음 구현 단계에서 사용자가 승인하면 `gymnasium`과 `stable-baselines3`를 명시적으로 추가한다. +2. 먼저 `check_env`와 DQN smoke만 실행한다. +3. smoke가 안정되면 full validation/test로 확장한다. + +--- + +## 13. 대시보드 확장 계획 + +DQN/PPO가 추가되면 기존 `performance_leaderboard` schema를 그대로 확장한다. + +추가 row 예: + +```json +{ + "source": "rl_model", + "model": "dqn", + "split": "test", + "cost_bps": 25, + "avg_episode_net_return_pct": 0.0, + "max_drawdown_pct": 0.0, + "passes_cost_gate": false, + "beats_buy_and_hold": false, + "usability": "watch" +} +``` + +웹은 이미 `performance_leaderboard` artifact를 읽으므로, DQN/PPO 결과 생성기만 같은 schema로 내보내면 대시보드 추가 수정은 최소화된다. + +--- + +## 14. 최종 P006 판단 + +| 항목 | 판단 | +|---|---| +| DQN/PPO 확장 | 가능 | +| 당장 실거래 개선 보장 | 불가 | +| 새 dependency 즉시 추가 | 보류 | +| 다음 구현 우선순위 | Gymnasium adapter + check_env + DQN smoke | +| 성공 기준 | full test split에서 buy-and-hold 초과 또는 cost gate 통과 | + +P006의 결론은 “DQN/PPO로 갈 수 있다”가 아니라, **어떤 순서와 검증 기준으로 가야 실패를 줄일 수 있는지 고정했다**는 것이다. diff --git a/docs/stom_rl_exit_baseline_2026-05-29.md b/docs/stom_rl_exit_baseline_2026-05-29.md new file mode 100644 index 000000000..c2ba6416f --- /dev/null +++ b/docs/stom_rl_exit_baseline_2026-05-29.md @@ -0,0 +1,100 @@ +# 인과적 청산 baseline 결과 (Page R1b) — 청산 RL 게이트 종결 + +- 작성일: **2026-05-29 KST** +- 브랜치: `feature/stom-rl-lab` +- 상위 앵커: `docs/stom_rl_oracle_exit_ceiling_2026-05-29.md`(R1), `docs/stom_rl_rl_feasibility_research_2026-05-29.md`(R0) +- 구현: `stom_rl/exit_baselines.py`(순수함수+CLI) / 테스트 `tests/test_stom_rl_exit_baselines.py`(22개) / 산출물 `.omx/artifacts/exit_baselines/summary.json` +- 대상: **시초 갭상승 `ts_imb` 룰 — RULE strategy, NOT reinforcement learning.** + +--- + +## 0. 한 줄 결론 (결정적) + +**어떤 인과적 청산 변형(SL 확대·트레일링)도 고정 TP5/SL1을 이기지 못했고, 인-샘플 최적 변형은 OOS에서 오히려 −0.71%/trade 더 나빴다(DSR 0.931<0.95). 따라서 R1의 큰 천장은 인과적으로 포착 불가능한 hindsight이며 → 청산 RL(R3)은 만들지 않는다. 운영 트랙(Page B)으로 복귀한다.** + +이는 R0 딥리서치(문헌상 청산 RL의 비용차감 OOS 우위 0건)와 정확히 합치한다. 게이트-우선 방식으로 두 번의 bounded 분석만으로 "청산 RL 해야 하나?"를 **데이터로 닫았다.** + +--- + +## 1. 전 표본 후보 비교 (ts_imb, N=235, realized, 23bp) + +| 후보 | 평균 net | 승률 | per-trade Sharpe | +|---|---:|---:|---:| +| **fixed_tp5_sl1 (현행)** | **+0.906%** | 42% | **+0.303** | +| fixed_tp5_sl1.5 | +0.850% | 46% | +0.267 | +| fixed_tp5_sl2 | +0.797% | 49% | +0.234 | +| fixed_tp5_sl3 | +0.876% | 54% | +0.238 | +| trail_1 | +0.532% | 49% | +0.283 | +| trail_2 | +0.800% | 51% | +0.252 | +| trail_3 | +0.750% | 48% | +0.200 | +| trail_2_tp5 | +0.737% | 51% | +0.277 | +| trail_3_tp5 | +0.764% | 49% | +0.238 | + +→ **현행 고정 TP5/SL1이 9개 후보 중 평균 net·Sharpe 모두 최고.** SL을 넓히면 승률은 오르지만 기대값·Sharpe는 떨어지고, 트레일링은 일찍 털려 기대값이 낮아진다. 청산 변형은 전부 edge를 깎는다. + +## 2. Walk-forward (인-샘플 선택 → OOS 보고, 누설 없음) + +| 항목 | 값 | +|---|---:| +| 경계일 / N(IS/OOS) | 20250410 / 154·81 | +| 인-샘플 최적 선택 | **trail_1** (현행 아님) | +| 선택 변형 OOS net | +0.373% | +| 현행 baseline OOS net | **+1.086%** | +| **OOS 개선** | **−0.713%** (선택 변형이 더 나쁨) | +| Deflated Sharpe Ratio | **0.931** (< 0.95) | + +→ 교과서적 과적합: 인-샘플에서 가장 좋아 보인 trail_1이 OOS에서 현행보다 **0.71%p/trade 열등**. 9개 시도를 보정한 DSR도 0.931로 유의 기준(0.95) 미달. + +--- + +## 3. 사전 등록 판정 규칙 적용 (둘 다 FAIL) + +| 게이트 | 기준 | 결과 | +|---|---|---| +| ① OOS net이 현행을 이김 | 개선 > 0 | ❌ −0.713% | +| ② DSR > 0.95 | 다중검정 보정 유의 | ❌ 0.931 | + +**둘 다 실패 → 청산 헤드룸은 hindsight → 청산 RL(R3) 폐기, 운영 트랙 복귀.** + +부수 확인: 현행 룰의 OOS net(+1.086%)이 전표본(+0.906%)보다 높다 → **최근 구간(2025-04 이후)에서 룰이 잘 버텼다**(룰 강건성 긍정 신호). + +--- + +## 4. 의미 + +1. **고정 TP5/SL1은 이미 인과적 청산 프런티어 근처다.** R1의 capture 17.8%·regret +4.18%는 "완전예지로만" 도달 가능한 hindsight였고, 실행 가능한 단순 규칙으로는 1mm도 못 가져온다. +2. **RL은 이 격차를 더 못 메운다**(RL ≤ 가능한 최선 인과정책; 단순 인과정책조차 현행을 못 이김). R0 문헌 결론과 동일. +3. 따라서 **AI/RL 청산 트랙은 여기서 종결**한다. 검증된 수익원은 변함없이 **룰 전략 + Page A 사이징**이다. + +--- + +## 5. 재현 명령 + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.exit_baselines --max-symbols 120 --cost-bps 23 --filter ts_imb +py -3.11 -m pytest tests/test_stom_rl_exit_baselines.py -q # 22 passed +``` + +핵심 출력(2026-05-29): +```text +fixed_tp5_sl1 mean_net=+0.906% win=42% sharpe=+0.303 <- 전 후보 중 최고 +walk-forward: selected=trail_1 OOS_net=+0.373% baseline_OOS_net=+1.086% +OOS_improvement=-0.713% deflated_sharpe_ratio=0.931 +``` + +--- + +## 6. 페이지 트랙 갱신 — RL 트랙 종결, 운영 트랙 복귀 + +| 페이지 | 상태 | +|---|---| +| R0 RL 타당성 딥리서치 | ✅ 완료 | +| R1 oracle-exit 천장 | ✅ 완료 (여지=hindsight 가능성) | +| **R1b 인과적 청산 baseline** | ✅ **완료 — 청산 RL 폐기 판정** | +| R2 메타라벨링(진입 필터) | ⬜ 선택(낮은 우선순위; 진입 알파 부재 이미 검증) | +| R3 offline RL 청산 | ❌ **폐기**(R1b 게이트 실패) | +| **B full universe 재검증** | ⬜ **다음 권고**(운영 본선) | +| C 유동성/꼬리 → D paper → E broker | 이후 | + +검증: R1b 코드리뷰 APPROVE(DSR 수식 Bailey-LdP 정확 일치), 테스트 22 passed. diff --git a/docs/stom_rl_final_review_2026-05-23.md b/docs/stom_rl_final_review_2026-05-23.md new file mode 100644 index 000000000..1e5e3b964 --- /dev/null +++ b/docs/stom_rl_final_review_2026-05-23.md @@ -0,0 +1,218 @@ +# STOM 강화학습 성과 모델 최종 리뷰 + +작성일: 2026-05-23 KST +브랜치: `feature/stom-rl-lab` +대상: **Kronos 비의존 STOM tick/back DB 기반 강화학습 실험실 + full test split 성과 검증 + 웹 대시보드** + +--- + +## 1. 최종 결론 + +현재 플랫폼은 **연구·검증 플랫폼으로는 사용 가능**하다. 다만 현재 학습된 `contextual_bandit` 모델은 full test split 기준으로 `buy_and_hold`를 이기지 못했고 25bp cost gate도 통과하지 못했으므로 **실거래 후보가 아니라 보류/watch 상태**다. + +| 질문 | 최종 답 | +|---|---| +| STOM 2025 1초봉 episode 기반 RL 실험실 구축 | 완료 | +| 전체 test split 기준 baseline 검증 | 완료 | +| contextual bandit full eval | 완료 | +| 모델별 performance leaderboard | 완료 | +| 웹 대시보드에서 성과 비교 | 완료 | +| DQN/PPO 확장 방향 | 설계 완료 | +| 현재 모델 실거래 사용 | 보류 | + +--- + +## 2. 전체 페이지 진행률 + +| 페이지 | 이름 | 상태 | 주요 산출물 | +|---:|---|---|---| +| 1 | 성과 기준 재정의 | 완료 | `docs/stom_rl_performance_goal_pages_2026-05-23.md` | +| 2 | full baseline/cost gate | 완료 | `stom_rl/leaderboard.py`, baseline full artifact | +| 3 | contextual bandit full eval | 완료 | contextual bandit full test artifact | +| 4 | leaderboard artifact | 완료 | `stom_rl/performance_leaderboard.py` | +| 5 | dashboard leaderboard | 완료 | RL Lab 리더보드 탭/차트/표 | +| 6 | DQN/PPO 확장 설계 | 완료 | `docs/stom_rl_dqn_ppo_extension_design_2026-05-23.md` | +| 7 | 최종 리뷰 | 완료 | 본 문서 | + +진행률: **7 / 7 = 100%** + +`███████ 100%` + +--- + +## 3. 핵심 커밋 흐름 + +| 커밋 | 의미 | +|---|---| +| `926e1b9` | 성과 검증 기준과 full/smoke 구분을 먼저 문서화 | +| `64e46f9` | full test split baseline leaderboard를 빠르게 계산 | +| `0d9e52c` | contextual bandit을 full test split으로 평가 | +| `fd56218` | baseline/RL 결과를 하나의 performance leaderboard로 통합 | +| `075cdd5` | 웹 대시보드에서 performance leaderboard를 비교 가능하게 통합 | +| `c62aa88` | DQN/PPO 확장 순서와 dependency 판단을 문서화 | + +--- + +## 4. 현재 데이터와 평가 범위 + +| 항목 | 값 | +|---|---:| +| 데이터 기준 | STOM 2025 1초봉 episode manifest | +| 전체 episode | 18,750 | +| 종목 수 | 1,638 | +| 거래일/session | 240 | +| train episode | 13,256 | +| val episode | 2,764 | +| test episode | 2,730 | +| 원본 export row | 33,360,325 | +| 시간 범위 | 09:00~09:30 | +| lookback | 300초 | +| reward horizon | 300초 | +| 비용 기준 | 25bp | + +중요: 최종 성과 판단은 smoke가 아니라 **test split 전체 2,730 episode** 기준이다. + +--- + +## 5. 최종 리더보드 판단 + +25bp 비용 기준 full test split 결과: + +| 순위 | 모델/정책 | 평균 episode net % | 거래 수 | MDD % | 사용 판단 | +|---:|---|---:|---:|---:|---| +| 1 | buy_and_hold | 0.5126 | 2,730 | -50.7280 | baseline | +| 2 | contextual_bandit | 0.1254 | 971 | -51.5892 | watch | +| 3 | no_trade | 0.0000 | 0 | 0.0000 | risk baseline | +| 4 | mean_reversion | -23.2925 | 170,868 | -47.7542 | 부적합 | +| 5 | volume_filter | -26.1600 | 167,923 | -49.7977 | 부적합 | +| 6 | momentum | -27.9136 | 164,944 | -62.5398 | 부적합 | +| 7 | random | -77.0568 | 806,047 | -81.8887 | 부적합 | + +해석: + +1. contextual bandit은 no-trade보다 낫다. +2. contextual bandit은 과도한 단순 매매 전략보다 낫다. +3. 그러나 buy-and-hold보다 낮다. +4. MDD도 buy-and-hold보다 더 나쁘다. +5. 따라서 실거래 후보가 아니라 연구용 watch 모델이다. + +--- + +## 6. 웹 대시보드 상태 + +현재 웹 강화학습 실험실은 다음을 표시한다. + +| 기능 | 상태 | +|---|---| +| RL run 목록 | 동작 | +| performance leaderboard artifact 감지 | 동작 | +| 리더보드 차트 | 동작 | +| 리더보드 표 | 동작 | +| cost gate 표 | 동작 | +| trades/equity artifact | 해당 run에 존재할 때 표시 | +| Korean UI | 기존 v2 테마와 통합 | + +검증 endpoint: + +```text +GET / -> 200 +GET /api/rl/runs?limit=10 -> 200 +GET /api/rl/runs/stom_1s_2025_performance_leaderboard_full_test -> 200 +GET /api/rl/runs/stom_1s_2025_performance_leaderboard_full_test/table/leaderboard?limit=7 -> 200 +GET /api/training/status -> 200 +``` + +--- + +## 7. 최종 QA 결과 + +### 7.1 Python 테스트 + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests -q +``` + +결과: + +```text +94 passed, 2 skipped, 2 warnings in 73.39s +``` + +경고는 Plotly datetime future warning 2개이며 이번 강화학습 변경의 실패는 아니다. + +### 7.2 프론트 빌드 + +```powershell +npm run build +``` + +결과: + +```text +svelte-check found 0 errors and 4 warnings +vite build completed +``` + +경고 4개는 기존 `ForecastWorkbenchTab.svelte`, `DocsTab.svelte` 접근성/CSS warning이다. 이번 RL Lab 변경으로 새 error는 발생하지 않았다. + +### 7.3 API smoke + +Flask test client 기준 핵심 endpoint가 모두 200을 반환했다. + +브라우저 UI는 build artifact와 API smoke까지 확인했다. 현재 세션에서 별도 browser-use tool이 노출되지 않아 실제 클릭 자동화는 수행하지 못했지만, 웹 페이지 HTML과 RL API는 정상 응답했다. + +--- + +## 8. 남은 리스크 + +| 리스크 | 설명 | 대응 | +|---|---|---| +| 현재 모델 성과 부족 | contextual bandit이 buy-and-hold 미달 | DQN/PPO 후보 검토 | +| MDD 큼 | buy-and-hold와 contextual bandit 모두 -50% 수준 | reward에 drawdown/risk penalty 반영 필요 | +| 비용 민감도 | 25bp 비용에서 대부분 단순 정책 붕괴 | trade count 제한/target position action 검토 | +| dependency 미추가 | DQN/PPO 실제 학습은 아직 미구현 | 사용자 승인 후 Gymnasium/SB3 추가 | +| test split 반복 튜닝 위험 | test 결과로 계속 튜닝하면 과최적화 | train/val/test discipline 유지 | + +--- + +## 9. 다음 권장 작업 + +현재 목표는 7페이지 기준으로 완료되었다. 다음 개발을 이어간다면 새 목표로 진행하는 것이 좋다. + +권장 명령: + +```powershell +omx ultragoal complete-goals +``` + +다만 현재 Codex thread는 이전 완료 legacy goal 때문에 OMX ultragoal checkpoint가 blocked reconciliation을 반복한다. 안정적인 다음 방식은 다음 중 하나다. + +1. **새 Codex thread에서 같은 repo/worktree를 열고 새 goal로 시작** +2. 또는 현재 브랜치에서 git commit 기준으로 계속 진행 + +다음 실제 구현 후보: + +```text +P008 Gymnasium adapter + check_env +P009 DQN smoke 학습 +P010 DQN validation/full test leaderboard 통합 +P011 PPO 비교 실험 +P012 risk-aware reward와 target-position action 개선 +``` + +--- + +## 10. 최종 사용 판단 + +| 용도 | 판단 | +|---|---| +| 연구/검증 대시보드 | 사용 가능 | +| STOM tick/back 데이터 기반 RL 실험 | 사용 가능 | +| 모델 성과 비교/기록 | 사용 가능 | +| 자동매매 실거래 후보 | 현재는 보류 | +| 다음 모델 개발 기반 | 사용 가능 | + +최종 결론: + +**플랫폼 구축 목표는 달성했다. 모델 수익성 목표는 아직 달성하지 못했다.** +따라서 다음 단계는 플랫폼 개발이 아니라, Gymnasium/SB3 기반 DQN smoke와 validation 중심의 모델 고도화다. diff --git a/docs/stom_rl_gap_up_backtest_2026-05-27.md b/docs/stom_rl_gap_up_backtest_2026-05-27.md new file mode 100644 index 000000000..770914935 --- /dev/null +++ b/docs/stom_rl_gap_up_backtest_2026-05-27.md @@ -0,0 +1,51 @@ +# 시초 갭상승(9시 갭상승) 모멘텀 백테스트 — 결과 + +- 작성일: 2026-05-27 +- 데이터: `_database/stock_tick_back.db` (1초봉, 개장 09:00–09:30, 이벤트 트리거 기록) +- 전략(사용자 지정): **진입 = 시초 `등락율` ≥ 2%(갭상승), 청산 = 목표수익(TP)/손절(SL) 또는 09:25 시간청산**, 비용 25bp 왕복. +- 스크립트: `stom_rl/gap_up_backtest.py` · 테스트: `tests/test_stom_rl_gap_up_backtest.py` (13 passed) + +## 결론 (먼저) + +**시초 갭상승 2% + TP/SL 전략은 비용(25bp) 차감 후 out-of-sample에서 수익이 나지 않는다 — 16개 TP/SL 조합 전부 OOS 순수익 음수(0/16).** 방향(모멘텀) 자체는 약하게 존재(gross 양수·승률 최대 67%)하지만, **얇은 gross 엣지(~+0.1~0.2%)가 25bp 왕복비용에 잡아먹힌다.** in-sample에서 양수로 보이는 조합들은 **과적합**으로, OOS에서 음수로 뒤집힌다. + +## 데이터 규모 +- 갭상승 인스턴스(시초 등락율≥2%): **387건**, 28종목, 387세션, 2022-03-23 ~ 2026-02-25. +- in-sample/out-of-sample 분할: boundary 2024-12-10 (IS 264 / OOS 123). + +## TP/SL 그리드 결과 — 순수익 expectancy %/trade (비용 25bp 후) + +| TP/SL | in-sample | **out-of-sample** | OOS 승률 | +|---|---:|---:|---:| +| TP1/SL1 | −0.072 | −0.150 | 55% | +| TP1/SL1.5 | −0.056 | −0.197 | 61% | +| TP1/SL2 | −0.069 | −0.224 | 64% | +| TP2/SL1 | **+0.058** | −0.147 | 37% | +| TP2/SL1.5 | **+0.066** | −0.263 | 41% | +| TP3/SL1 | **+0.135** | −0.009 | 34% | +| TP5/SL1 | **+0.267** | **−0.002** | 28% | +| TP5/SL1.5 | **+0.254** | −0.195 | 30% | +| … (16조합 전체) | 일부 IS 양수 | **전부 OOS 음수** | — | + +- **양수 OOS 조합: 0 / 16.** +- best OOS = TP5/SL1 = **−0.0016%/trade** (사실상 breakeven 약간 아래), 그러나 IS +0.267% → **전형적 과적합**(높은 TP를 초기 데이터에 맞추면 좋아 보이나 후기에 실패). +- baseline(buy@open, 09:25 단순보유, TP/SL 없음): OOS **−0.176%/trade** (역시 음수). + +## 진단 (왜 안 되는가) +1. **비용 지배**: gross 평균 +0.10~0.20%, 승률 55~67%(타이트 TP)로 **방향성은 약하게 있음**. 그러나 **25bp 왕복비용 > gross 엣지** → 순수익 음수. 문제는 방향이 아니라 비용/엣지 크기. +2. **과적합**: IS 양수 조합(TP3~5/SL1)이 OOS에서 음수로 붕괴 → TP/SL 그리드 튜닝은 일반화 안 됨. +3. **TP-SL 비대칭의 함정**: 높은 TP/낮은 SL은 IS 기대값을 부풀리나(드문 큰 수익 + 잦은 작은 손절) OOS에서 무너짐. + +## 한계/주의 (caveat) +- **universe 편향**: 인스턴스 = STOM이 기록한(=그날 조건 친) 종목 중 등락율≥2%. **시장의 모든 2% 갭상승이 아니라 트리거된 부분집합** → 미트리거 갭상승엔 일반화 불가. +- **아침 09:00–09:30**만. 09:25 시간청산은 데이터 끝(09:30) 직전. +- 28종목/387건은 표본이 크지 않음(특히 OOS 123건). + +## 실행 가능한 레버 (다음에 시도한다면) +1. **비용 가정 재확인**: 실제 왕복비용이 25bp보다 낮으면(예: 세금/수수료 최적화, 단타 우대) 결과가 바뀔 수 있음 — gross가 양수이므로 비용이 엣지 아래로 내려가면 양수 가능. **당신의 실제 체결비용이 핵심.** +2. **진입 필터 강화**: 등락율 2% + 체결강도/거래대금각도/호가압력 조건을 추가해 gross 엣지를 비용 위로 끌어올리기(연속성 높은 갭만 선별). — feature·falsifier 하니스로 검정 가능. +3. **갭 임계 상향**: 더 큰 갭(예 5%+)은 움직임이 크나 표본이 줄어듦 — 비용 대비 효율 재검정. +4. **보유/청산 재설계**: 트레일링 스탑 등(현 고정 TP/SL은 과적합). + +## 한 줄 +**갭상승 모멘텀의 방향성은 약하게 실재하나, 현 비용(25bp)·현 데이터에서는 고정 TP/SL로 순수익이 안 난다(0/16 OOS).** 핵심 레버는 **실제 체결비용 + 진입 필터로 gross 엣지를 비용 위로** 올리는 것이며, 고정 TP/SL 그리드 튜닝은 과적합이다. diff --git a/docs/stom_rl_gap_up_cost_filter_2026-05-27.md b/docs/stom_rl_gap_up_cost_filter_2026-05-27.md new file mode 100644 index 000000000..48e5f7f1b --- /dev/null +++ b/docs/stom_rl_gap_up_cost_filter_2026-05-27.md @@ -0,0 +1,45 @@ +# 시초 갭상승 — 현실 비용 모델 + 진입필터 결과 (cost sweep × filter) + +- 작성일: 2026-05-27 +- 베이스: `stom_rl/gap_up_backtest.py` (commit 48bbdef), 본 확장 = 현실 비용모델(수수료+세금) + 진입필터 + cost sweep +- 전략: 진입 등락율≥2%, 청산 TP/SL 또는 09:25, 1초 가격경로 모사, 날짜 IS/OOS holdout + +## 결론 (먼저) + +**현실 비용(≤18bp) + STOM 진입필터(체결강도/호가)를 적용하면, 시초 갭상승 전략은 OOS에서 양수 기대값을 보인다 — 이 조사 전체의 첫 긍정 신호다.** 앞선 "0/16 음수"는 (1) 임의 25bp가 raw breakeven(~24-43bp) 바로 위였고 (2) 진입필터가 없어 raw 2% 갭이 노이즈였기 때문. 필터를 얹으면 gross 엣지가 현실 비용을 확실히 넘는다. **단, universe 편향·레짐 의존 등 중대한 caveat이 남아 "검증된 알파"가 아니라 "추가 검증 가치가 있는 첫 신호"로 해석해야 한다.** + +## 비용 모델 +`round-trip = commission_bps/side × 2 + transaction_tax_bps(매도측만) + slippage`. 참조 시나리오: +- **국내(Korean-domestic) ~18bp**: 수수료 1.5bp/편 ×2 + 거래세 ~15bp(매도). +- **국제/저비용 ~5bp**: 수수료 2.5bp/편 ×2, 무거래세. +- cost sweep {0,5,10,15,18,25}bp로 **breakeven 비용**(OOS 기대값=0 교차점)을 보고 — breakeven은 튜닝 셀이 아니라 속성이라 honesty-safe. + +## 핵심 결과 — cost sweep × 진입필터 (PRIMARY TP5%/SL1%, 1349 인스턴스/115종목) +| 필터 | N(IS/OOS) | breakeven IS/OOS (bp) | OOS@0 | OOS@5 | OOS@10 | OOS@18 | OOS@25 | +|---|---|---|---:|---:|---:|---:|---:| +| 없음 | 938/411 | +49.8 / +42.7 | +0.427 | +0.377 | +0.327 | **+0.247** | +0.177 | +| +체결강도≥100 | 264/161 | +88.5 / +82.7 | +0.827 | +0.777 | +0.727 | **+0.647** | +0.577 | +| +체결강도+호가≥0.5 | 129/106 | +119.4 / +116.6 | +1.166 | +1.116 | +1.066 | **+0.986** | +0.916 | + +- **필터가 breakeven을 42→83→117bp로 끌어올림**(2.7배), OOS@18bp 기대값 +0.25→+0.65→+0.99%/trade. +- **IS↔OOS 일관 = 과적합 아님**: ts breakeven IS +88.5/OOS +82.7, ts_imb IS +119.4/OOS +116.6. (raw TP/SL 그리드 튜닝과 달리 필터는 일반화됨.) + +## TP/SL 그리드 (1349 인스턴스, 25bp 기준 OOS) — 참고 +- TP5/SL1 OOS +0.177%, TP3/SL1 +0.139%, TP5/SL2 +0.093% — **25bp에서도 일부 양수**(앞 387-인스턴스 run보다 우호적; 표본/레짐 차이). + +## 중대한 CAVEAT (반드시 함께 읽을 것) +1. **universe 편향(최대 confound)**: 인스턴스 = STOM이 그날 기록한(=조건 친) 종목 중 등락율≥2%. **시장의 모든 갭상승이 아니라 트리거된 부분집합** — 엣지가 "이미 주목받은 갭"에 한정. 미트리거 갭에 일반화 불가. 실거래 적용 전 정규 데이터 재현 필요. +2. **레짐 의존 의심**: baseline이 IS −0.070% vs OOS +0.306%로 비대칭 — OOS 구간(2024-12 이후)이 우호적 레짐일 수 있음. 더 넓은 기간/레짐 검증 필요. +3. **표본/꼬리 의존**: ts_imb는 N=129/106로 작음(오차 큼). TP5/SL1은 승률 28%, 소수의 큰 TP가 캐리 — 꼬리 의존. +4. **run 간 변동**: 387(28종목) run은 더 음수, 1349(115종목) run은 양수 — 결과가 universe 규모/구성에 민감. +5. **체결 현실성 미검증**: 갭상승 개장가에 실제로 체결되는지(슬리피지·유동성) 미반영. slippage 0 가정. +6. **비용 18bp는 가정** — 사용자 실제 왕복비용이 핵심 입력. + +## 검증 가능한 다음 단계 (긍정 신호를 신뢰로 바꾸려면) +1. **실제 체결비용 확정** → 그 값으로 재실행(breakeven 80-117bp면 웬만한 현실비용 통과). +2. **레짐 robustness**: 여러 IS/OOS 경계·연도별 분해로 OOS 양수가 특정 레짐 산물인지 확인. +3. **full-universe + 슬리피지 모델** + 정규(미편향) 갭상승 데이터로 universe 편향 제거. +4. (선택) 필터를 RL/ranker로 일반화 — 단 이번엔 필터 자체가 엣지의 핵심. + +## 한 줄 +**현실 비용 + 체결강도/호가 진입필터에서 시초 갭상승은 OOS 양수(필터 시 +0.6~1.0%/trade@18bp, breakeven 83~117bp, IS≈OOS 비과적합)** — 조사 첫 긍정 신호. 그러나 **트리거-universe 편향·레짐 의존**이 커서 "검증된 알파"가 아니라 "정규데이터·레짐검증으로 확인할 가치가 있는 첫 신호"다. diff --git a/docs/stom_rl_gap_up_fillmode_2026-05-28.md b/docs/stom_rl_gap_up_fillmode_2026-05-28.md new file mode 100644 index 000000000..3ce34a53d --- /dev/null +++ b/docs/stom_rl_gap_up_fillmode_2026-05-28.md @@ -0,0 +1,74 @@ +# 시초 갭상승 — 체결 de-idealization (realized / SL gap-through) 검증 + +- 작성일: 2026-05-28 +- 목적: Architect 권고 게이트 1+2 — 이상적 TP/SL 체결 가정을 제거(realized-fill)하고 SL 관통(gap-through) 최악체결을 적용해 엣지가 살아남는지 검정. +- 베이스: `stom_rl/gap_up_backtest.py` 에 `fill_mode` 추가(`--fill-mode`). 동일 universe 1349 인스턴스 / 115종목 / 2022-03~2026-02, 사용자 실비용 23bp. + +## 체결 모드 정의 (`simulate_trade(fill_mode=...)`) +| 모드 | TP 체결 | SL 체결 | 의미 | +|---|---|---|---| +| `idealized`(기존 default) | 정확 TP레벨 | 정확 SL레벨 | SL 관통 무시 = **낙관적** | +| `realized` | 실제 크로싱가(≥TP, 더 좋음) | 실제 크로싱가(≤SL, 더 나쁨) | 현실적 시장체결 | +| `sl_gap_stress` | 정확 TP레벨(보수) | 실제 크로싱가(관통, 최악) | **가장 비관적** 단일 가정 | + +## 결론 (먼저) + +**ts_imb(체결강도+호가 필터)는 체결 de-idealization 후에도 강하게 양수다 — 최악(sl_gap_stress) 기대값 +0.79~0.81%/trade @23bp.** 이상적 가정 제거의 비용은 작다(realized −0.045%p, 최악 −0.140%p). 단 **2022년(최약·소표본)은 realized/최악에서 marginally 음수(−0.01~−0.05)** 로 뒤집히고, **필터 없는(none) 전략은 최악모드에서 2개 연도 음수 + 낙폭 −65%** 로 크게 무너진다 → **필터(ts_imb)가 체결 견고성의 핵심.** + +## 1. 전체 기대값 비교 (ts_imb, TP5/SL1, @23bp) +| 모드 | N | exp %/trade | vs idealized | 누적%(비복리) | 승률 | 최대낙폭 | +|---|---:|---:|---:|---:|---:|---:| +| idealized | 235 | **+0.952** | — | +223.6 | 42% | −16.1 | +| realized | 235 | **+0.906** | −0.045%p | +213.0 | 42% | −20.0 | +| **sl_gap_stress** | 235 | **+0.811** | −0.140%p | +190.7 | 42% | −20.5 | + +→ breakeven 116.6bp 대비 최악에서도 여전히 **~4× 여유**. (deltas는 비용 무관 — 비용은 가산적.) + +## 2. 연도별 @23bp (mean_net %/trade), filter=ts_imb +| 모드 | 2022 | 2023 | 2024 | 2025 | 2026 | 매년양수? | +|---|---:|---:|---:|---:|---:|:--:| +| idealized | +0.09 | +1.45 | +1.10 | +1.00 | +0.91 | ✅ | +| realized | **−0.01** | +1.50 | +1.02 | +0.93 | +0.90 | ❌(2022) | +| sl_gap_stress | **−0.05** | +1.32 | +0.96 | +0.85 | +0.76 | ❌(2022) | + +→ **2023~2026은 전 모드 강한 양수(+0.76~+1.50).** **2022(최약·소표본)만 realized/최악에서 −0.01~−0.05로 뒤집힘** — 정직한 한계. 전체 기대값이 양수인 건 2023~2026이 지배하기 때문. + +## 3. 필터 강도별 체결 취약성 (de-idealization이 약한 필터를 더 때림) +| 필터 | idealized exp | sl_gap_stress exp | idealized maxDD | sl_gap_stress maxDD | +|---|---:|---:|---:|---:| +| none | +0.226 | +0.058 | −26.5 | **−65.2** | +| ts | +0.613 | +0.437 | −20.2 | −30.0 | +| **ts_imb** | +0.932 | **+0.791** | −16.1 | −20.9 | + +(여기 수치는 캐시 @25bp 기준; @23bp는 각 +0.02. 모드 간 비교·낙폭 결론은 동일.) +→ none은 최악모드에서 거의 소멸(+0.058, 낙폭 −65%), ts_imb는 견고(+0.79, 낙폭 −21%). **필터가 체결현실성의 안전마진을 만든다.** + +## 4. 게이트 3 (진짜 큐/부분체결 paper replay) — 데이터 한계 (정직) +- `stom_rl/paper_replay.py`는 PortfolioEnv(RL 종목선택)용이라 갭상승 룰에 직접 안 맞음. +- **진짜 큐포지션·부분체결 시뮬은 L2 깊이/큐 데이터가 필요한데 DB엔 없음**(매수총잔량/매도총잔량 합계만). → 가짜 큐 시뮬은 만들지 않는다. +- 우리 데이터로 가능한 체결현실성 상한 = **realized/sl_gap_stress 체결 + 슬리피지 38bp 스윕**(앞 문서). 이 둘로 체결낙관·슬리피지를 모두 bound했다. + +## 5. 대시보드 시각화 (de-idealized 곡선) +- 발행됨: `webui/rl_runs/gap_up_ts_imb_realized` (final_nav 3,130,059, +213.0%), `gap_up_ts_imb_sl_gap_stress` (final_nav 2,906,596, +190.7%). 각 235 events, `algorithm=rule:gap_up_ts_imb`, `is_reinforcement_learning=False`. +- ⚠️ 대시보드 서버(PID, 2026-05-27 기동)가 stale → 신규 run 요약카드가 안 뜸. **서버 재시작 필요**(파일·곡선 데이터는 정상, `/events`로 곡선 렌더 확인됨). + +## 6b. 2022 약세 심층분석 — 소표본 변동성, 레짐 실패 아님 (정직·다중비교 보정) +ts_imb @23bp, 2022 vs 2023~2026: +| 지표 | 2022 | 2023~2026 | +|---|---|---| +| N | 39 | 196 | +| mean (idealized) | +0.094% | +1.122% | +| mean (sl_gap_stress) | −0.048% | +0.982% | +| 표준편차 | ~2.5 | ~2.8 | +| SE | ~0.40 | — | +| 95% CI (idealized) | **[−0.70, +0.88]** | — | +| 95% CI (sl_gap_stress) | **[−0.87, +0.77]** | — | + +판정: +1. **2022 단독은 통계적으로 음수가 아님** — 95% CI가 0을 포함(소표본 SE ±0.40%/trade). +2. **2022 vs 나머지 Welch t = 2.27~2.35**(uncorrected p≈0.02)로 약하게 낮으나, **"5년 중 최약 연도"를 사후 지목 → 다중비교 보정 필요. Bonferroni 임계 |t|≈2.6 기준 유의하지 않음.** (본 프로젝트 일관 원칙.) +3. **구조 안 깨짐**: 2022 승률 26% > TP5/SL1 손익분기 승률 ~20.5%. (손익은 소수 TP가 캐리하는 꼬리 의존 — 모든 해 공통.) +4. **함의**: 단일 연도/분기는 변동성만으로 flat~음수 가능(SE ±0.4%). 엣지 무효가 아니라 **사이징·낙폭관리가 필수**라는 뜻. 2022는 표본 적고(39건) 초기 구간이라 오차가 큰 것. + +## 한 줄 +**체결 de-idealization(realized + SL gap-through 최악) 후에도 ts_imb는 +0.79~0.91%/trade @23bp, breakeven 대비 ~4× 여유 — 체결낙관 제거에 견고.** 2022 약세는 다중비교 보정 시 비유의(소표본 변동성, 레짐 실패 아님; 승률은 손익분기 위 유지). 필터 없는 전략은 최악모드에서 무너진다(낙폭 −65%). 큐/부분체결 진짜 replay는 L2 데이터 부재로 불가 — realized/stress + 슬리피지38bp가 가능한 상한. diff --git a/docs/stom_rl_gap_up_full_universe_2026-05-29.md b/docs/stom_rl_gap_up_full_universe_2026-05-29.md new file mode 100644 index 000000000..80900fe6b --- /dev/null +++ b/docs/stom_rl_gap_up_full_universe_2026-05-29.md @@ -0,0 +1,98 @@ +# Page B — full universe 재검증 결과 + +- 작성일: **2026-05-29 KST** +- 브랜치: `feature/stom-rl-lab` +- 상위 앵커: `docs/stom_rl_session_progress_2026-05-29.md`, `docs/stom_rl_resume_commit_2026-05-29.md` +- 실행: `gap_up_backtest.py --max-symbols 0 --regime-analysis --regime-cost-bps 23` → `.omx/artifacts/gap_up_full/` +- 대상: **시초 갭상승 `ts_imb` 룰 — RULE strategy, NOT reinforcement learning.** + +--- + +## 0. 한 줄 결론 (PASS) + +**ts_imb 엣지는 전체 universe(2314종목·29,139 instance·2022-03~2026-02)에서 살아남았다. 전 연도(2022~2026) 비용 후 양수이고, bounded-120에서 약했던 2022가 +0.742%로 해소되어 "소표본 노이즈" 해석이 확정됐다. breakeven ~98bp는 23bp 비용의 약 4배, OOS는 경계 전반에서 안정, 슬리피지 +20bp 추가에도 양수.** 기대값은 bounded보다 다소 희석(+0.95%→약 +0.80%/trade)됐으나 강건한 양수다. + +--- + +## 1. bounded-120 vs full-universe 비교 (ts_imb, 23bp) + +| 지표 | bounded-120 (문서값) | **full-universe** | 판정 | +|---|---:|---:|---| +| 종목 수 | 120 | **2314** | ~19x | +| ts_imb N | 235 | **5175** | ~22x | +| 기대값/trade @23bp | +0.952%(ideal) | **OOS +0.750% / 연도평균 +0.74~0.92%** | 양수 유지(희석) | +| breakeven (IS/OOS) | 116.6bp(OOS) | **+107.0 / +98.0bp** | 비용의 ~4x | +| 2022 | +0.09%(N=39, 약세) | **+0.742%(N=861)** | ✅ 해소 | +| 전 연도 양수 | 아니오(2022 flat) | **예 (5/5년)** | ✅ | +| 슬리피지 +20bp(=43bp) | — | **+0.606%** | ✅ 생존 | + +> 비용 주의: 그리드/baseline 헤드라인은 엔진 기본 25bp로 출력됐고, 위 ts_imb 수치는 cost-sweep/regime 섹션의 **23bp 기준**값이다. breakeven은 비용 무관(총 gross edge). + +--- + +## 2. 연도별 기대값 @23bp (전 연도 양수) + +| 필터 | 2022 | 2023 | 2024 | 2025 | 2026 | +|---|---:|---:|---:|---:|---:| +| none (N=29139) | +0.286 | +0.252 | +0.304 | +0.196 | +0.156 | +| ts (N=9576) | +0.491 | +0.485 | +0.631 | +0.605 | +0.607 | +| **ts_imb (N=5175)** | **+0.742** | **+0.924** | **+0.831** | **+0.744** | **+0.759** | + +- ts_imb는 전 연도 +0.74% 이상. 필터 강도(none .omx/artifacts/gap_up_full/{summary,instances,regime_analysis}.json +``` +핵심 출력(2026-05-29): `instances=29139 symbols=2314 dates=20220323->20260227`; ts_imb breakeven IS/OOS +107.0/+98.0bp; 연도별 +0.742~+0.924. + +--- + +## 7. 페이지 트랙 갱신 + +| 페이지 | 상태 | +|---|---| +| Page A 사이징 / R0·R1·R1b (RL 청산 폐기) | ✅ 완료 | +| **B full universe 재검증** | ✅ **완료 — 엣지 유지, 2022 해소(PASS)** | +| **C 유동성/슬리피지·gap-through 꼬리 정밀** | ⬜ **다음 권고** | +| D read-only paper/forward → E broker | 이후 (E 승인 전 금지) | + +검증: 전체 게이트 138 passed(이 run은 기존 엔진 재사용, 새 코드 없음). 수치는 `.omx/artifacts/gap_up_full/summary.json`에 영속. diff --git a/docs/stom_rl_gap_up_realcost_2026-05-28.md b/docs/stom_rl_gap_up_realcost_2026-05-28.md new file mode 100644 index 000000000..7121bc77f --- /dev/null +++ b/docs/stom_rl_gap_up_realcost_2026-05-28.md @@ -0,0 +1,40 @@ +# 시초 갭상승 — 실제 체결비용(23bp) 확정 결과 + +- 작성일: 2026-05-28 +- **사용자 실제 비용**: 매수 0.015%(수수료) + 매도 0.015%(수수료) + 0.20%(증권거래세, 매도) = **왕복 0.23% = 23bp** +- 전략: 등락율≥2% 진입 + 진입필터, TP5%/SL1%, 09:25 청산. (검증: `stom_rl_gap_up_regime_validation_2026-05-28.md`) +- 비용은 flat 가산적이라 검증 데이터에서 23bp로 정확 환산(@18bp − 0.05). + +## 결론: 실비용 23bp에서 전부 양수, breakeven 대비 큰 여유 + +| 진입필터 | N | 전체 @23bp | de-idealized* | breakeven | 여유 | +|---|---|---:|---:|---:|---:| +| 없음(2% 갭만) | 1349 | +0.246% | +0.206% | 42.7bp | 1.9× | +| +체결강도≥100 | 425 | +0.633% | +0.593% | 82.7bp | 3.6× | +| **+체결강도+호가≥0.5** | 235 | **+0.952%** | **+0.912%** | 116.6bp | **5.1×** | + +\* de-idealized = Architect가 지적한 "이상적 TP/SL 체결" 낙관 보정(realized crossing-bar fill, ~−0.04%). + +### 연도별 @23bp (net %/trade) — 모두 양수 +| 필터 | 2022 | 2023 | 2024 | 2025 | 2026 | +|---|---|---|---|---|---| +| 없음 | +0.05 | +0.37 | +0.37 | +0.16 | +0.37 | +| +체결강도 | +0.20 | +0.91 | +0.69 | +0.59 | +0.77 | +| **+체결강도+호가** | +0.09 | +1.45 | +1.10 | +1.00 | +0.91 | + +### 5개 holdout 경계 OOS @23bp — 모두 양수 +- 없음 +0.20~+0.31, 체결강도 +0.61~+0.78, **체결강도+호가 +0.90~+1.27.** + +## 해석 +- **당신 비용(23bp)은 모든 필터의 breakeven 아래** — 특히 ts_imb는 116.6bp breakeven 대비 **5배 여유**. 비용 변동(슬리피지 등)에 견고. +- ts_imb(체결강도+호가 필터) ≈ **+0.9%/trade** (de-idealized), **2022~2026 매년·5경계 전부 양수.** +- 단, **거래당 +0.9%이지 연수익률이 아님** — 빈도(연 ~50건/универse)·종목수·자금배분에 따라 총수익 결정. 승률 41~51%, TP5/SL1 비대칭(분할·사이징 필요). + +## 실거래 직전 잔여 게이트 (Architect 권고) +1. **realized-fill 모드**: TP/SL를 정확레벨 아닌 실제 crossing-bar 가격으로 booking(이미 ~−0.04% 반영했으나 코드로 고정 권장). +2. **SL gap-through 스트레스**: −1% 관통 시 최악체결(가장 낙관적 단일 가정). +3. **paper replay**: 시점별 read-only 재생으로 실주문 체결 현실성(부분체결·큐) 점검. +4. **기록 충실도**: DB가 실시간 STOM 신호를 충실히 재현하는지(사후 누락 없는지). + +## 한 줄 +**당신 실비용 23bp에서 시초 갭상승+체결강도/호가 필터는 +0.9%/trade(de-idealized), 매년·5경계 전부 양수, breakeven 대비 5배 여유.** universe=실거래 대상 일치. 남은 건 realized-fill/SL-gap 스트레스 + paper replay로 체결 현실성 확정. diff --git a/docs/stom_rl_gap_up_regime_validation_2026-05-28.md b/docs/stom_rl_gap_up_regime_validation_2026-05-28.md new file mode 100644 index 000000000..5a886ef50 --- /dev/null +++ b/docs/stom_rl_gap_up_regime_validation_2026-05-28.md @@ -0,0 +1,58 @@ +# 시초 갭상승 — 레짐 robustness + 슬리피지 검증 + +- 작성일: 2026-05-28 +- 대상: 시초 갭상승(등락율≥2%, TP5%/SL1%, 09:25 청산) + 진입필터, 현실 비용 18bp +- 베이스: `stom_rl/gap_up_backtest.py` (`--regime-analysis`), 1349 인스턴스 / 115종목 / 2022-03~2026-02 + +## 결론 (먼저) + +**필터된 시초 갭상승 엣지는 레짐-견고하다 — "최근 강세장 산물"이 아니다.** 2022~2026 **모든 해 양수**, 5개 holdout 경계 **전부 OOS 양수**, 슬리피지 38bp까지 생존. 앞서 의심했던 레짐 의존(baseline IS/OOS 비대칭)은 **TP/SL 없는 baseline에 국한**된 것이고, TP/SL+필터 전략은 매년 일관 양수다. **남은 핵심 한계는 universe(트리거된 갭상승) 단 하나인데 — 이는 당신의 실제 매매 universe와 일치하므로(아래 reframe) 배포 관점에선 편향이 아니다.** + +## 1. 연도별 기대값 @18bp (net %/trade, win%) +| 필터 | 2022 | 2023 | 2024 | 2025 | 2026 | +|---|---|---|---|---|---| +| none | +0.100 (26%) | +0.417 (30%) | +0.420 (32%) | +0.212 (29%) | +0.419 (28%) | +| ts (체결강도≥100) | +0.248 (28%) | +0.957 (40%) | +0.737 (37%) | +0.637 (38%) | +0.815 (34%) | +| **ts_imb (+호가≥0.5)** | +0.144 (26%) | **+1.500 (51%)** | **+1.155 (45%)** | **+1.046 (43%)** | +0.962 (37%) | +→ **모든 필터·모든 해 양수.** 필터 강할수록 기대값↑·승률↑. (2022는 표본 적고 약함.) + +## 2. 다중 holdout 경계 OOS (경계 cherry-pick 아님) +ts_imb OOS 기대값 @18bp, 경계를 0.5~0.9로 이동: +| frac | 경계 | N(IS/OOS) | IS | OOS | +|---|---|---|---|---| +| 0.50 | 20240729 | 109/126 | +1.057 | +0.954 | +| 0.60 | 20241224 | 131/104 | +0.981 | +1.028 | +| 0.70 | 20250410 | 154/81 | +0.904 | +1.187 | +| 0.80 | 20250804 | 180/55 | +0.904 | +1.321 | +| 0.90 | 20251202 | 202/33 | +0.983 | +1.118 | +→ **5/5 경계 OOS +0.95~+1.32, IS≈OOS.** 단일 경계 운빨 아님. + +## 3. 슬리피지 민감도 (net @ cost+slippage) +| 필터 | 18bp(slip0) | 23bp(slip5) | 28bp(slip10) | 38bp(slip20) | +|---|---|---|---|---| +| none | +0.296 | +0.246 | +0.196 | +0.096 | +| ts | +0.683 | +0.633 | +0.583 | +0.483 | +| **ts_imb** | +1.002 | +0.952 | +0.902 | **+0.802** | +→ ts_imb는 **38bp(현실 비용+큰 슬리피지)에도 +0.80% 생존.** none조차 28bp까지 양수. + +## 4. 판정 +**레짐-견고 + 경계-안정 + 슬리피지-생존 = 내부 타당성 강함.** 필터된 시초 갭상승(등락율≥2% + 체결강도≥100 + 호가≥0.5, TP5%/SL1%, 09:25)은 현실 비용+슬리피지에서 **~+0.8~1.0%/trade, 매년 양수**. + +## 5. universe에 대한 핵심 REFRAME (중요) +앞 문서들이 "트리거-universe 편향"을 최대 confound로 들었으나 — **당신의 실거래 universe = STOM이 플래그한(트리거된) 종목**이다. 즉 백테스트 universe = **당신이 실제로 매매할 대상**과 일치한다. 따라서: +- **배포 관점**: 이건 편향이 아니라 **올바른 모집단**. 결과가 직접 적용된다. +- **학술적 일반화 관점**(비STOM 종목까지): 그때만 편향. — 당신 목적엔 해당 안 됨. + +## 6. 그래도 남는 실거래 caveat (정직) +1. **체결 현실성**: 빠른 시초 갭에 실제 진입가/청산가 체결 가능성·부분체결·물량. 슬리피지 38bp까지는 검증했으나 실주문 fill은 별개. +2. **기록 충실도**: DB 기록이 실시간 STOM 신호를 충실히 재현하는지(사후 누락 여부). 같으면 결과 직접 유효. +3. **낮은 승률(TP5/SL1 ~28~45%)**: 잦은 작은 손절 + 드문 큰 수익 → 심리적/사이징 부담. 분할·자금관리 필요. +4. 비용 18bp는 가정 — 당신 실제 왕복비용으로 확정 필요(breakeven 80~117bp라 웬만하면 통과). + +## 7. 다음 (신뢰→실거래) +1. **실제 체결비용 확정** → 재실행(거의 확실히 통과). +2. **paper replay**(기존 read-only 하니스)로 시초 갭상승 룰을 시점별 재생, 체결 현실성 점검. +3. (선택) full-universe + 실주문 슬리피지 모델. + +## 한 줄 +**시초 갭상승 + 체결강도/호가 필터 + TP5/SL1은 2022~2026 매년·5개 경계 전부 OOS 양수(+~1%/trade@18bp), 슬리피지 38bp 생존 — 레짐-견고한 실신호.** universe는 당신 실거래 대상과 일치하니 배포 관점 편향 아님. 남은 건 체결 현실성·실비용 확정. diff --git a/docs/stom_rl_gap_up_risk_sizing_2026-05-29.md b/docs/stom_rl_gap_up_risk_sizing_2026-05-29.md new file mode 100644 index 000000000..8401c70ea --- /dev/null +++ b/docs/stom_rl_gap_up_risk_sizing_2026-05-29.md @@ -0,0 +1,225 @@ +# STOM 시초 갭상승 룰 전략 — 사이징/리스크 설계 (Page A) + +- 작성일: **2026-05-29 KST** +- 브랜치: `feature/stom-rl-lab` +- 상위 재개 앵커: `docs/stom_rl_resume_commit_2026-05-29.md` (§9 Page A) +- 대상 전략: **시초 갭상승 `ts_imb` 룰 전략 — RULE strategy, NOT reinforcement learning.** +- 구현 모듈: `stom_rl/gap_up_risk_sizing.py` (순수 함수) / 테스트: `tests/test_stom_rl_gap_up_risk_sizing.py` + +> **정직성 가드레일(상속).** 이 문서의 어떤 곡선·수치도 강화학습/RL 결과가 아니다. +> 누적·기대값은 **비복리 per-trade % 또는 fixed-notional 환산**이며, 연복리·실계좌 보장 수익이 아니다. +> 이 문서는 **실거래 준비 완료 선언이 아니다.** 사이징 룰 설계 단계이며, 실주문 전에는 Page C(유동성/슬리피지)·Page D(read-only forward)가 남아 있다. + +--- + +## 0. 한 줄 결론 + +**`ts_imb` 갭상승 룰을 계좌의 f=10%/회, 동시보유 3종목, 일손실 -3% 중단으로 운용하면, 검증된 백테스트 기준 계좌 단위 최대낙폭은 순차 -1.6%(idealized)~-2.0%(stress), 최장연패 9가 와도 누적손실 약 -0.6~-1.1%에 그친다.** + +왜냐하면 SL이 -1%로 짧아 1회 손실(1R)이 계좌의 0.123%에 불과하고, f·K로 노출을 30%로 묶었기 때문이다. 이 사이징에서 진짜 위험은 연패가 아니라 **체결 gap-through 꼬리(Page C)와 레짐 감쇠(2022형)**이며, 이를 월간 감액 룰과 후속 페이지로 흡수한다. + +--- + +## 1. 확정 파라미터 (사용자 선택, 2026-05-29) + +| 항목 | 기호 | 값 | 근거 | +|---|---|---:|---| +| 1회 진입 노셔널 비중 | f | **10%** | 사용자 선택(중도). full Kelly 대비 ~1/220로 초보수적(§4). | +| 동시보유 상한 | K | **3종목** | 사용자 선택. 09:00~09:25 다중신호를 신호강도 top-K로 제한. | +| 일손실한도 | — | **계좌 -3%** | 사용자 선택. 도달 시 그날 신규 진입 중단(§5). | +| 익절/손절 | TP/SL | +5% / -1% | 검증된 주력 셀(고정). | +| 왕복비용 | — | 23bp | 실제 비용(수수료 0.015%×2 + 매도세 0.20%). | +| 주력 필터 | — | `ts_imb` | 체결강도≥100 AND 호가 imbalance≥0.5. | + +검증된 전략 통계(상위 문서 §5, bounded universe N=235): + +| 지표 | idealized | stress(sl_gap) | +|---|---:|---:| +| 기대값/trade @23bp | +0.952% | +0.811% | +| 승률 | 42% | 42% | +| 최장연패 | 9 | 9 | +| 전략 자체 MDD(전액투입 곡선) | -15.7% | ~-20% | + +--- + +## 2. 리스크 모델 정의 + +모든 정의는 **per-trade 노셔널**(계좌가 아니라 1회 진입금액) 기준 수익률을 계좌 단위로 환산하는 구조다. + +### 2.1 1회 진입 노셔널 + +``` +notional = f × account +(유동성 캡 적용 시) notional = min(f × account, max_participation × 초당거래대금_entry) +``` + +유동성 캡은 진입봉 `초당거래대금`(entry_sec_amount)을 한도로 주문이 과도하게 시장을 밀지 않게 한다. Page A에서는 보수적 가드(기본 `max_participation=1.0`, 즉 "1초 거래대금 이내")로만 두고, 정밀 모델은 **Page C**에서 다룬다. + +### 2.2 1회 손실 단위 R + +손절 1회의 손실(명목)은 노셔널 기준 `SL% + 비용` 이다. + +``` +R_won = notional × (SL/100 + cost_bps/10000) = notional × (0.01 + 0.0023) = notional × 0.0123 +R_account% = f × 1.23% = 0.123% (f=10%) +``` + +즉 **1R = 계좌의 0.123%**. 이것이 모든 한도를 R 단위로 환산하는 기준이다. + +### 2.3 동시보유 노출 + +``` +max_concurrent_exposure = f × K × account = 30% of account (f=10%, K=3) +worst_case_concurrent_loss = K × R = 3 × 0.123% = 0.369% of account (동시 3종목 전부 명목 손절; gap-through 꼬리는 제외 → Page C, 따라서 절대 상한이 아니라 하한) +``` + +### 2.4 계좌 단위 MDD 환산 + +전략 곡선은 "전액투입 per-trade % 합"이다. f 비중·고정노셔널로 운용하면 계좌 낙폭은 선형 축소된다. + +``` +account_MDD ≈ strategy_MDD × f × concurrency_factor +``` + +| 시나리오 | concurrency_factor | idealized(-15.7%) | stress(-20%) | +|---|---:|---:|---:| +| 순차(비중첩) | 1.0 | **-1.57%** | **-2.0%** | +| 중간 군집 | 2.0 | -3.14% | -4.0% | +| 최악(K 동시) | 3.0 | -4.71% | -6.0% | + +> 주의: 백테스트 곡선은 순차·고정노셔널 가정이다. 동시보유 시 손실이 군집되면 *경로*가 가팔라진다(깊이는 유사). 진짜 동시보유 곡선은 Page B/C에서 재산출한다. 위 표는 1차 근사다. + +--- + +## 3. 계좌 규모별 Worked Example + +f=10%, K=3, 일손실 -3%, SL1/cost23bp 기준. (모듈 `plan_for_account`가 동일 값을 산출하고 테스트로 고정.) + +| 항목 | 1,000만원 | 5,000만원 | 1억원 | +|---|---:|---:|---:| +| 1회 진입 노셔널 | 100만 | 500만 | 1,000만 | +| 1R(명목 손절손실) | 12,300 | 61,500 | 123,000 | +| 일손실한도(-3%) | 300,000 | 1,500,000 | 3,000,000 | +| 동시보유 최대노출(30%) | 300만 | 1,500만 | 3,000만 | +| 동시 3종목 전부 손절(명목, gap-through 제외) | 36,900 | 184,500 | 369,000 | +| 기대손익/trade idealized | +9,520 | +47,600 | +95,200 | +| 기대손익/trade stress | +8,110 | +40,550 | +81,100 | +| 계좌 MDD 순차 idealized | -157,000 | -785,000 | -1,570,000 | +| 계좌 MDD 순차 stress | -200,000 | -1,000,000 | -2,000,000 | + +(단위 원) + +--- + +## 4. 왜 Kelly가 아니라 낙폭 기준 사이징인가 + +Kelly는 이 전략에서 **퇴화**한다. 이항 결과(승 +4.77%=TP-비용, 패 -1.23%=SL+비용, p=0.42)에 대한 성장최적 노셔널 비중은 + +``` +f*_kelly = (p·a − q·b) / (a·b), a=0.0477, b=0.0123, p=0.42, q=0.58 + ≈ 21.99 (= 노셔널 2,199%) +``` + +손실이 -1.23%로 작게 캡되어 Kelly가 "계좌의 22배를 노셔널로 태우라"는 비현실적 값을 준다. 따라서 Kelly는 짧은 SL 전략의 사이징 도구로 부적합하다. 우리가 고른 **f=10%는 full Kelly의 약 1/220** 수준으로 극도로 보수적이다. + +결론: 사이징의 구속조건은 Kelly가 아니라 **(1) 자본/동시보유 한계, (2) gap-through 꼬리, (3) 추정오차·과최적화 할인, (4) 사용자 낙폭 허용도**다. f=10%는 이 모든 면에서 안전 측이다. + +--- + +## 5. 운영 중단/감액 룰 + +### 5.1 일손실한도 — 계좌 -3% + +그날 누적 실현손실이 `account × 3%`에 도달하면 신규 진입 중단(보유분은 자체 TP/SL/시간청산 유지). + +- R 환산: 3% / 0.123% ≈ **24.4R**. +- K=3·장중청산 구조상 하루 거래수가 적어 -3%는 사실상 **거의 바인딩되지 않는 재난 백스톱**이다. 현실적 최악일(동시 3종목 전부 손절)도 약 -0.37%에 그친다. +- 그럼에도 둔다: gap-through 다발·이상장세에서의 안전판. + +### 5.2 연속손실 감액 (최장연패 9 기준) + +현재 연패 수에 따라 다음 진입의 f를 단계 축소한다. + +| 현재 연패 | f 배율 | f=10% → 실효 f | +|---:|---:|---:| +| 0~2 | 1.0 | 10% | +| 3~4 | 0.5 | 5% | +| 5~6 | 0.25 | 2.5% | +| 7+ | 0.0 | 진입 중단(그날/리셋까지) | + +- 승리 1회로 연패 카운터 리셋. +- 역사적 최장연패 9가 와도 7에서 중단되므로, 한 연패 구간의 누적손실은 약 **4.5R ≈ 계좌 -0.55%**(감액 적용) ~ **9R ≈ -1.1%**(감액 미적용 상한)에 그친다. + +### 5.3 월간/연간 감액 (2022형 레짐) + +2022는 N=39 소표본으로 flat~소폭 음수였다(레짐 붕괴 단정 금지, 상위 문서 §5.5). 이를 흡수하기 위해: + +- 진행 중인 **월 누적 수익률 ≤ -1.5%**(계좌)면 다음 진입 f를 **0.5배**로 감액. +- 양(+)의 월로 복귀하면 정상 복원. +- 연 단위로 음수가 누적되면 운용 중단 후 Page B(full universe) 재검증으로 회귀. + +--- + +## 6. 유동성/체결 한도 (Page A는 가드만, 정밀은 Page C) + +- 진입봉 `초당거래대금`(entry_sec_amount), `체결강도`, 호가 imbalance가 신호이자 유동성 근거다. +- Page A 가드: `notional ≤ max_participation × 초당거래대금_entry` (기본 1.0배). +- 얇은 종목(초당거래대금 작음)에서는 이 캡이 바인딩되어 주문이 자동 축소된다. +- 실제 체결가능금액·슬리피지 곡선은 **Page C**에서 별도 설계(현재 DB로 진짜 큐포지션 replay는 불가, 상위 문서 §2.5). + +--- + +## 7. 실전 전 forward 조건 (Page D 예고) + +- 실주문 전, **read-only live signal을 최소 N일(권장 20거래일) 이상** 생성·관찰. +- 신호 빈도·체결 특성·기대값 부호가 백테스트와 허용오차 내일 때만 다음 단계. +- broker/order 연동(Page E)은 **명시적 별도 승인 전 금지**. + +--- + +## 8. 모듈/함수 지도 (`stom_rl/gap_up_risk_sizing.py`) + +순수 함수(무 I/O, 무 DB). 정책은 frozen `RiskConfig`, 동적 상태(계좌·연패·월수익)는 인자. + +| 함수 | 역할 | +|---|---| +| `RiskConfig` | 정책 파라미터(f, K, 일손실%, TP/SL, 비용, 유동성, 감액 티어) + 검증. | +| `position_notional_won(account, config, *, fraction=None, entry_liquidity_won=None)` | 1회 노셔널(유동성 캡 포함). | +| `risk_per_trade_won(notional, config)` | 1R(명목 손절손실, 원). | +| `risk_unit_account_pct(config)` | 1R의 계좌 % (=0.123%). | +| `daily_loss_limit_won(account, config)` / `should_halt_day(realized_pnl, account, config)` | 일손실한도/중단 판정. | +| `daily_limit_in_r(config)` | 일손실한도의 R 환산(≈24.4R). | +| `max_concurrent_exposure_won(account, config)` / `worst_case_concurrent_loss_won(account, config)` | 동시노출/동시손절 최악. | +| `consecutive_loss_scale(streak, tiers)` | 연패 감액 배율(1.0/0.5/0.25/0.0). | +| `effective_fraction(config, consecutive_losses, month_return_pct=None)` | 연패+월간 감액 결합 실효 f. | +| `account_mdd_estimate_pct(strategy_mdd_pct, fraction, concurrency_factor)` | 계좌 MDD 환산. | +| `expected_pnl_won(notional, expectancy_pct)` | 기대손익(원). | +| `full_kelly_fraction(win_rate, win_return_pct, loss_return_pct)` | Kelly(퇴화 확인용, ≈21.99). | +| `plan_for_account(config, account_won)` | 계좌별 전 수치 번들(§3 표 산출). | + +--- + +## 9. Page A 완료 기준 답변 (상위 문서 §9.5) + +- **계좌 1천만/5천만/1억 1회 진입금액?** → 100만 / 500만 / 1,000만원 (§3). +- **하루 최대 몇 종목?** → 최대 3(K). 추가로 일손실 -3% 또는 7연패 시 중단. +- **하루 최대 손실?** → 한도 -3%(30만/150만/300만). 현실적 최악(동시 3손절) ≈ -0.37%. +- **최장연패 9 감내?** → 감내. 감액 적용 ≈ -0.55%, 미적용 상한 -1.1%. +- **2022형 약세 자동 감액/중단?** → 월 -1.5%에서 f 0.5배, 연 음수 누적 시 중단·재검증. +- **dashboard/paper에 동일 룰 적용?** → 전부 순수 함수라 Page D paper run이 그대로 import. + +--- + +## 10. 다음 페이지 + +1. **Page A — 사이징/리스크 설계** ← (이 문서) 완료 시. +2. **Page B — full universe(`--max-symbols 0`) 재검증** — N=235 수치가 전체 2427종목에서 유지되는지. +3. **Page C — 유동성/슬리피지·gap-through 꼬리 정밀 모델** — `max_participation` 정밀화, 실주문가능금액. +4. **Page D — read-only live paper/forward** — 본 모듈 룰 적용, 최소 20거래일 관찰. +5. **Page E — broker/order 연동** — 명시적 별도 승인 전 금지. + +--- + +## 11. 인수인계 멘트 + +`ts_imb` 갭상승 **룰 전략(NOT RL)**의 사이징은 **f=10%, K=3, 일손실 -3%**로 확정했다. 이 사이징에서 계좌 낙폭·연패 손실은 모두 한 자릿수 % 이하로 작고, 진짜 잔여 위험은 **gap-through 꼬리(Page C)와 레짐 감쇠(2022형, 월간 감액으로 일부 흡수)**다. 다음은 수익을 더 찾는 것이 아니라 Page B(full universe)와 Page C(유동성/꼬리)로 이 사이징의 가정을 실측 검증하는 것이다. diff --git a/docs/stom_rl_lab_final_qa_report_2026-05-23.md b/docs/stom_rl_lab_final_qa_report_2026-05-23.md new file mode 100644 index 000000000..457d52945 --- /dev/null +++ b/docs/stom_rl_lab_final_qa_report_2026-05-23.md @@ -0,0 +1,276 @@ +# STOM 독립 강화학습 실험실 최종 QA / 리뷰 보고서 + +작성일: 2026-05-23 KST +브랜치: `feature/stom-rl-lab` +기준 시작점: `af4c5b1` 이후 STOM 독립 강화학습 실험실 구현 +대상 goal: **G009 통합 QA / 리뷰** + +--- + +## 1. 최종 결론 + +**구현 목표는 달성했다.** + +Kronos를 사용하지 않고 STOM tick/back DB 기반 독립 강화학습 실험실을 구축했다. + +```text +STOM read-only 데이터 적재 +→ episode manifest +→ Gymnasium-style trading env +→ baseline runner +→ 5/10/15/25bp cost gate +→ 1차 contextual bandit 모델 학습/평가/저장/사용 +→ /api/rl/* backend API +→ 웹 강화학습 실험실 대시보드 +→ 통합 QA / code review / 보류 판단 +``` + +다만, 현재 1차 모델은 **smoke 검증용 모델**이다. 플랫폼과 모델 사용 흐름은 검증되었지만, 실거래 자동화를 바로 켜도 된다는 의미는 아니다. 자동매매 적용은 full test split에서 비용 반영 후 baseline 대비 우위가 반복 검증될 때까지 보류해야 한다. + +--- + +## 2. 요구사항 대응 현황 + +| 요구사항 | 구현/근거 | 상태 | +|---|---|---| +| Kronos 독립 RL 구현 | `stom_rl/*`, `/api/rl/*`, `RLLabTab.svelte` | 완료 | +| STOM DB read-only 사용 | SQLite URI `mode=ro`, write probe 회귀 테스트 | 완료 | +| 2025 09:00~09:30 episode manifest | 18,750 episodes / 1,638 symbols / 240 sessions | 완료 | +| train/val/test split | train 13,256 / val 2,764 / test 2,730, overlap 0 | 완료 | +| 미래 데이터 누수 방지 | observation timestamp가 action timestamp보다 앞서도록 테스트 | 완료 | +| Gymnasium-style trading env | `StomTickTradingEnv.reset/step`, action 0/1/2 | 완료 | +| baseline runner | no-trade/random/buy-and-hold/momentum/mean-reversion/volume_filter | 완료 | +| 비용 관문 | 5/10/15/25bp, slippage, MDD, rolling fold | 완료 | +| 1차 RL 모델 생성 | `contextual_bandit.py`, `model.json` 저장 | 완료 | +| 학습 모델 사용 흐름 | saved model 기반 eval/actions/trades/equity 산출물 생성 | 완료 | +| Backend API | `/api/rl/runs`, detail, trades, equity, episodes, cost-gate | 완료 | +| 웹 대시보드 | `강화학습 실험실` 탭, KPI/차트/표/해석 | 완료 | +| 최종 검증 | pytest/build/browser smoke/API smoke/code review | 완료 | + +--- + +## 3. 실제 산출물 요약 + +| runtime artifact | 상태 | 핵심 내용 | +|---|---|---| +| `stom_1s_2025_episode_manifest` | 생성됨 | 18,750 episodes, split delta 0 | +| `stom_1s_2025_baselines_smoke` | 생성됨 | smoke 기준 best policy: buy_and_hold | +| `stom_1s_2025_cost_gate_smoke` | 생성됨 | 25bp 통과 정책 1개: buy_and_hold | +| `stom_1s_2025_contextual_bandit_smoke` | 생성됨 | avg episode net +1.8960%, hit rate 63.64%, MDD -2.6211% | +| 웹 대시보드 | 동작 | run 4개 탐지, cost gate/equity/trade chart 표시 | + +### 성과 해석 + +- contextual bandit smoke는 비용 반영 후 양의 수익률을 보였고 smoke cost gate를 통과했다. +- 그러나 같은 smoke 조건에서 buy-and-hold가 더 우수했다. +- 따라서 현재 모델은 “학습·저장·사용·시각화가 가능하다”를 증명하지만, “실거래 자동화에 충분하다”를 증명하지 않는다. + +--- + +## 4. 검증 증거 + +### 4.1 전체 Python 테스트 + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests -q +``` + +결과: + +```text +92 passed, 2 skipped, 2 warnings +``` + +비고: + +- Windows 환경에서 Torch DLL 초기화가 실패하는 테스트는 `KRONOS_RUN_TORCH_TESTS=1` opt-in으로 분리했다. +- 기존 테스트 의도는 이미 optional Torch dependency였고, 현재 워크스테이션에서는 Torch import가 프로세스에 fatal trace를 남기므로 검증된 PyTorch 환경에서만 실행되게 했다. +- Windows 외 환경에서는 별도 subprocess preflight 후 Torch 초기화 가능 여부를 확인한다. +- 이 guard는 production fallback이 아니라 테스트 환경 optional dependency 처리다. + +### 4.2 Frontend build + +```powershell +cd webui\v2_src +npm run build +``` + +결과: + +```text +svelte-check 0 errors +기존 경고 4개 유지 +vite build OK +``` + +### 4.3 Python compile + +```powershell +C:\Python\64\Python3119\python.exe -m py_compile ` + stom_rl\__init__.py ` + stom_rl\episode_manifest.py ` + stom_rl\trading_env.py ` + stom_rl\baselines.py ` + stom_rl\cost_gate.py ` + stom_rl\contextual_bandit.py ` + webui\rl_dashboard.py ` + webui\app.py +``` + +결과: 통과 + +### 4.4 Browser smoke + +검증 방식: + +- Flask test server를 `http://127.0.0.1:5070/`로 실행 +- Playwright headless Chromium으로 접속 +- `강화학습 실험실` 탭 클릭 +- RL tab marker, cost gate table, run item, contextual bandit 표시 확인 + +결과: + +```text +OK +screenshot: .omx/tmp/rl-lab-g009-final-smoke.png +``` + +### 4.5 API smoke + +| endpoint | 결과 | +|---|---| +| `/api/rl/runs?limit=10` | 200 | +| contextual bandit detail | 200 | +| contextual bandit trades | 200, rows 반환 | +| contextual bandit equity | 200, rows 반환 | +| contextual bandit episodes | 200, rows 반환 | +| cost-gate compact endpoint | 200 | +| path traversal probe | 400으로 차단 | + +--- + +## 5. AI Slop Cleaner 점검 + +### 5.1 점검 범위 + +| 범위 | 내용 | +|---|---| +| `stom_rl/*` | episode manifest, env, baseline, cost gate, contextual bandit | +| `webui/rl_dashboard.py` | RL artifact API helper | +| `webui/app.py` | `/api/rl/*` route wiring | +| `webui/v2_src/src/tabs/RLLabTab.svelte` | 웹 대시보드 | +| `webui/v2_src/src/lib/api.ts` | RL API wrapper | +| `tests/*stom_rl*` | 회귀/스모크 테스트 | +| 문서 | goal pages, plan, final QA report | + +### 5.2 동작 잠금 + +정리 전후 동작 검증은 다음으로 잠갔다. + +- 전체 pytest: `92 passed, 2 skipped, 2 warnings` +- frontend build: OK +- py_compile: OK +- browser smoke: OK +- API smoke: OK + +### 5.3 fallback/마스킹 점검 + +| 발견 | 판단 | +|---|---| +| Windows Torch opt-in + subprocess preflight in tests | Windows Torch DLL 초기화 실패를 optional dependency skip으로 처리. production masking 아님 | +| production code fallback-like branch | 치명적인 오류 은폐 fallback 없음 | +| API path traversal 처리 | 400으로 차단되어 안전 | +| read-only DB | write probe로 보호 | + +### 5.4 구조 점검 + +| 항목 | 판단 | +|---|---| +| `RLLabTab.svelte` 크기 | 현재는 단일 탭 맥락상 허용. 더 커지면 chart/table 하위 컴포넌트 분리 권장 | +| baseline/cost gate/model runner | 책임 분리 양호 | +| API helper | run directory direct child 제한으로 안전 | +| 테스트 범위 | manifest/env/baseline/cost gate/model/API/UI smoke 포함 | + +--- + +## 6. Code Review 결과 + +권고: **APPROVE** +Architect status: **CLEAR** + +| 등급 | 결과 | +|---|---| +| Critical | 없음 | +| High | 없음 | +| Medium | 없음 | +| Low | 3개 후속 권장 | + +### Low 후속 권장 + +| 항목 | 권장 | +|---|---| +| `RLLabTab.svelte` | 기능이 더 늘어나면 KPI/CostGate/TradeTable 컴포넌트로 분리 | +| 기존 Svelte 경고 | Forecast/DocsTab의 기존 unused/export warning은 별도 정리 | +| Torch 환경 | Windows 로컬에서 Torch DLL 실패가 반복되므로 `KRONOS_RUN_TORCH_TESTS=1`은 CUDA/PyTorch 재설치 또는 별도 venv 확인 후 사용 | + +### 보안/안전 판단 + +- STOM DB는 read-only로 접근한다. +- RL API는 artifact 조회 전용이며 학습 실행/삭제 같은 상태 변경 endpoint가 없다. +- run/policy path traversal은 차단된다. +- secret/token 노출 없음. + +--- + +## 7. 자동매매 적용 판단 + +| 질문 | 판단 | +|---|---| +| 지금 만든 모델을 실거래에 바로 써도 되는가? | 아니오 | +| 이유 | smoke 성과이며 full test split과 비용/슬리피지/거래제약 검증이 아직 부족 | +| 플랫폼은 사용할 수 있는가? | 예. 학습, 저장, 평가, 시각화 흐름은 갖춰짐 | +| 다음에 해야 할 일 | full test split leaderboard와 비용 관문 반복 검증 | + +자동매매 전환 조건은 다음을 만족해야 한다. + +1. full test split에서 no-trade/random/buy-and-hold/momentum 대비 우위 +2. 25bp 이상 비용에서 양의 기대값 유지 +3. 종목/일자별 성과 편중이 과하지 않음 +4. rolling validation에서 성과 붕괴 없음 +5. 거래 횟수와 turnover가 현실적인 수준 + +--- + +## 8. 다음 권장 단계 + +| 우선순위 | 작업 | 목적 | +|---|---|---| +| 1 | full test split baseline/cost gate 실행 | smoke가 아닌 전체 검증 기준 확보 | +| 2 | contextual bandit full train/eval | 1차 모델이 전체에서도 유효한지 확인 | +| 3 | DQN/PPO/SB3 후보 비교 | action sequence 학습 가능성 검토 | +| 4 | transaction model 보강 | 호가/체결/슬리피지 현실성 개선 | +| 5 | leaderboard 운영 | 모델별 성과를 웹에서 비교 | + +추천 OMX 명령: + +```powershell +omx ultragoal create --brief "STOM RL full test split leaderboard와 contextual bandit 전체 학습/평가를 구현하고 웹 대시보드에 모델별 성과 비교를 추가한다." +``` + +--- + +## 9. 최종 상태 + +| 페이지 | 상태 | 완료율 | +|---|---|---:| +| 1. 설계와 기준 고정 | 완료 | 100% | +| 2. DB loader / episode manifest | 완료 | 100% | +| 3. StomTickTradingEnv | 완료 | 100% | +| 4. Baseline runner | 완료 | 100% | +| 5. Reward / cost gate | 완료 | 100% | +| 6. 1차 RL 모델 | 완료 | 100% | +| 7. Backend API | 완료 | 100% | +| 8. 웹 대시보드 | 완료 | 100% | +| 9. 통합 QA / 리뷰 | 완료 | 100% | + +전체 진행률: **100%** diff --git a/docs/stom_rl_lab_goal_pages_2026-05-22.md b/docs/stom_rl_lab_goal_pages_2026-05-22.md new file mode 100644 index 000000000..060527387 --- /dev/null +++ b/docs/stom_rl_lab_goal_pages_2026-05-22.md @@ -0,0 +1,499 @@ +# STOM 독립 강화학습 실험실 장기 Goal 구현 페이지 + +작성일: 2026-05-22 KST +브랜치: `feature/stom-rl-lab` +목표: **Kronos를 사용하지 않고 STOM tick/back DB 기반 독립 강화학습 대시보드와 모델 생성·사용 흐름을 완성한다.** + +--- + +## 1. 전체 Goal + +최종 목표는 다음 한 문장으로 정의한다. + +> STOM tick/back DB를 read-only로 사용하여 강화학습 환경, baseline, 비용 검증, 모델 학습, 웹 대시보드, 실제 사용 가능성 평가까지 연결하고, 비용 차감 후 기존 baseline과 Kronos 300초 결과보다 나은지 검증한다. + +이 목표는 단일 커밋으로 끝낼 수 없으므로, 페이지 단위로 나눠 각 페이지마다 다음을 반복한다. + +1. 구현 범위 고정 +2. 코드/문서 수정 +3. 테스트/검증 +4. 커밋 +5. 진행률 업데이트 + +--- + +## 2. 장기 페이지 계획 + +| 페이지 | 이름 | 핵심 산출물 | 완료 기준 | 상태 | +|---:|---|---|---|---| +| 1 | 설계와 기준 고정 | `stom_independent_rl_lab_plan_2026-05-22.md` | 실측 데이터, baseline, reward horizon 문서화 | 완료 | +| 2 | DB loader / episode manifest | `stom_rl.episode_manifest` | read-only DB 검증, train/val/test episode manifest 생성 | 완료 | +| 3 | `StomTickTradingEnv` | RL 환경 skeleton | reset/step/reward/invalid action 단위 테스트 | 완료 | +| 4 | baseline runner | no-trade/random/momentum 등 | baseline report와 trade/equity artifact 생성 | 완료 | +| 5 | reward / cost gate | 5/10/15/25bp 비용 검증 | 25bp cost gate와 rolling validation | 완료 | +| 6 | 1차 RL 모델 | contextual bandit 또는 DQN | 300초 reward horizon 기준 walk-forward 평가 | 완료 | +| 7 | backend API | `/api/rl/*` | manifest/run/metric/trade/equity API smoke | 완료 | +| 8 | 웹 대시보드 | `강화학습 실험실` 탭 | build + browser smoke | 완료 | +| 9 | 통합 QA / 리뷰 | 최종 보고서 | 테스트, 코드리뷰, 확장/보류 결정 | 완료 | + +--- + +## 3. 페이지 2: DB loader / episode manifest 상세 + +### 3.1 목적 + +페이지 2의 목적은 모델을 학습하는 것이 아니다. **이후 모든 강화학습 실험이 사용할 episode 계약을 고정**하는 것이다. + +### 3.2 입력 + +| 입력 | 경로/값 | +|---|---| +| 원본 DB | `_database/stock_tick_back.db` | +| 기존 export report | `finetune/qlib_exports/stom_1s_grid_pred60_2025/stom_qlib_export_report.json` | +| 기존 1초봉 CSV episode | `finetune/qlib_exports/stom_1s_grid_pred60_2025/qlib_csv/*.csv` | +| 기본 기간 | 2025-01-03 ~ 2025-12-30 | +| 기본 시간 | 09:00~09:30 | +| 기본 reward horizon | 300초 | + +### 3.3 출력 + +| 출력 | 기본 경로 | +|---|---| +| manifest JSON | `webui/rl_runs/stom_1s_2025_episode_manifest/episode_manifest.json` | +| manifest CSV | `webui/rl_runs/stom_1s_2025_episode_manifest/episode_manifest.csv` | +| summary JSON | `webui/rl_runs/stom_1s_2025_episode_manifest/episode_summary.json` | + +`webui/rl_runs/`는 런타임 산출물 성격이므로, 대규모 manifest artifact는 원칙적으로 커밋 대상이 아니라 재생성 대상이다. + +### 3.4 검증 기준 + +| 검증 | 기대값 | +|---|---| +| DB 연결 | SQLite `mode=ro` | +| query-only | `PRAGMA query_only=ON` | +| write probe | 차단되어야 함 | +| split overlap | train/val/test session overlap 0 | +| chronological split | train → val → test 시간 순서 | +| manifest count | export report의 group 수와 일치 | +| unknown split | 0 | + +--- + +## 4. 페이지 2 실행 명령 + +```powershell +C:\Python\64\Python3119\python.exe -m stom_rl.episode_manifest ` + --db _database\stock_tick_back.db ` + --export-report finetune\qlib_exports\stom_1s_grid_pred60_2025\stom_qlib_export_report.json ` + --output-dir webui\rl_runs\stom_1s_2025_episode_manifest ` + --reward-horizon-seconds 300 ` + --lookback-window 300 +``` + +행 수까지 검증하려면 시간이 더 걸릴 수 있으므로 필요할 때만 다음 옵션을 추가한다. + +```powershell +--count-csv-rows +``` + +--- + +## 5. 현재 진행률 + +| 기준 | 완료 페이지 | 전체 페이지 | 진행률 | +|---|---:|---:|---:| +| 설계 기준 | 1 | 9 | 11.1% | +| 페이지 2 코드/검증 | 2 | 9 | 22.2% | +| 페이지 3 환경 skeleton | 3 | 9 | 33.3% | +| 페이지 4 baseline runner | 4 | 9 | 44.4% | +| 페이지 5 reward / cost gate | 5 | 9 | 55.6% | +| 페이지 6 1차 RL 모델 | 6 | 9 | 66.7% | +| 페이지 7 backend API | 7 | 9 | 77.8% | + +--- + +## 6. 페이지 2 완료 기록 + +페이지 2에서 다음을 완료했다. + +| 항목 | 결과 | +|---|---| +| read-only DB 연결 | `mode=ro`, `PRAGMA query_only=ON` | +| 쓰기 probe | `attempt to write a readonly database`로 차단 | +| episode manifest | 18,750 episodes | +| symbol 수 | 1,638 | +| session 수 | 240 | +| split | train 13,256 / val 2,764 / test 2,730 | +| split overlap | 0 | +| chronological split | true | +| manifest delta | export report 대비 0 | + +검증 명령: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_rl_episode_manifest.py tests\test_stom_qlib_pipeline.py -q +``` + +결과: + +```text +9 passed, 1 warning +``` + +다음 페이지는 **페이지 3: `StomTickTradingEnv`** 이다. + +--- + +## 7. 페이지 3 완료 기록 + +페이지 3에서 강화학습 모델과 baseline runner가 공통으로 사용할 단일 episode 매매 환경 skeleton을 추가했다. + +| 항목 | 결과 | +|---|---| +| 환경 클래스 | `stom_rl.trading_env.StomTickTradingEnv` | +| API 스타일 | Gymnasium 호환 `reset()` / `step()` 반환 형식 | +| action | `0=hold`, `1=buy`, `2=sell` | +| 기본 reward horizon | 300초 | +| 기본 비용 | 25bp | +| observation shape | `[lookback_window, 9]` | +| 기본 feature | OHLCV/amount + position/unrealized_return/time_in_position | +| 누수 방지 | observation 마지막 timestamp < action timestamp | +| invalid action | 보유 중 재매수, 미보유 청산을 penalty와 count로 기록 | +| deterministic replay | 동일 seed/행동열에서 동일 observation/reward | + +실제 manifest smoke: + +```powershell +C:\Python\64\Python3119\python.exe -m stom_rl.trading_env ` + --manifest webui\rl_runs\stom_1s_2025_episode_manifest\episode_manifest.json ` + --split train ` + --episode-index 0 ` + --lookback-window 300 ` + --reward-horizon-seconds 300 +``` + +대표 결과: + +| 항목 | 값 | +|---|---| +| episode | `000100_20250103` | +| observation_shape | `[300, 9]` | +| action_timestamp | `2025-01-03T09:05:18` | +| horizon_timestamp | `2025-01-03T09:10:18` | +| no_future_observation | `true` | + +검증 명령: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_rl_trading_env.py tests\test_stom_rl_episode_manifest.py -q +``` + +다음 페이지는 **페이지 4: baseline runner** 이다. + +--- + +## 8. 페이지 4 완료 기록 + +페이지 4에서는 강화학습 모델을 만들기 전에 반드시 비교해야 하는 모델 없는 기준선을 구현했다. 이 단계의 목적은 “RL이 아무 전략보다 나은가?”를 검증할 기준표를 먼저 만드는 것이다. + +| 항목 | 결과 | +|---|---| +| 구현 모듈 | `stom_rl.baselines` | +| 실행 함수 | `run_baselines(BaselineRunConfig)` | +| CLI | `python -m stom_rl.baselines` | +| 정책 | `no_trade`, `random`, `buy_and_hold`, `momentum`, `mean_reversion`, `volume_filter` | +| 기본 split | `test` | +| 기본 비용 | 25bp | +| 산출물 | `baseline_summary.json`, `baseline_summary.csv`, 정책별 `actions.csv`, `trades.csv`, `equity.csv`, `episodes.csv` | + +실제 2025 STOM manifest smoke는 test split 3개 episode로 실행했다. + +```powershell +C:\Python\64\Python3119\python.exe -m stom_rl.baselines ` + --manifest webui\rl_runs\stom_1s_2025_episode_manifest\episode_manifest.json ` + --split test ` + --max-episodes 3 ` + --policies no_trade,random,buy_and_hold,momentum,mean_reversion,volume_filter ` + --output-dir webui\rl_runs\stom_1s_2025_baselines_smoke +``` + +대표 smoke 결과는 다음과 같다. + +| 정책 | episode | 거래 수 | 평균 episode net | hit rate | MDD | +|---|---:|---:|---:|---:|---:| +| no_trade | 3 | 0 | 0.0000% | 0.0000 | 0.0000% | +| random | 3 | 885 | -77.2541% | 0.0124 | -98.8246% | +| buy_and_hold | 3 | 3 | +3.3240% | 1.0000 | 0.0000% | +| momentum | 3 | 214 | -34.1359% | 0.0421 | -71.5770% | +| mean_reversion | 3 | 195 | -22.5387% | 0.0359 | -53.5298% | +| volume_filter | 3 | 224 | -31.2288% | 0.0045 | -67.6145% | + +주의: 이 표는 전체 성과 확정이 아니라 **코드·artifact·metric 경로가 작동하는지 확인한 smoke**다. 전체 test split 기준의 엄밀한 판단은 페이지 5 cost gate에서 수행한다. + +검증 명령: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_rl_baselines.py tests\test_stom_rl_trading_env.py tests\test_stom_rl_episode_manifest.py -q +C:\Python\64\Python3119\python.exe -m py_compile stom_rl\baselines.py stom_rl\trading_env.py stom_rl\episode_manifest.py +``` + +검증 결과: + +```text +10 passed +py_compile OK +``` + +다음 페이지는 **페이지 5: reward / cost gate** 이다. 페이지 5에서는 5/10/15/25bp 비용 시나리오와 전체 test split 기준으로 baseline이 비용 차감 후 살아남는지 검증한다. + +--- + +## 9. 페이지 5 완료 기록 + +페이지 5에서는 강화학습 모델 학습 전에 사용할 비용 검증기를 추가했다. 이 단계의 목적은 “수익률이 좋아 보이는 전략이 실제 비용·슬리피지·회전율·MDD 조건에서도 살아남는가?”를 자동 판정하는 것이다. + +| 항목 | 결과 | +|---|---| +| 구현 모듈 | `stom_rl.cost_gate` | +| 실행 함수 | `run_cost_gate(CostGateConfig)` | +| CLI | `python -m stom_rl.cost_gate` | +| 비용 시나리오 | 5bp, 10bp, 15bp, 25bp | +| slippage 시나리오 | CLI에서 복수 지정 가능, smoke는 0bp | +| target gate | 기본 25bp | +| gate 조건 | net positive, MDD 한도, trades/episode 한도, 최소 거래 수, rolling positive fold rate | +| 산출물 | `cost_gate_report.json`, `scenario_summary.csv`, `rolling_folds.csv`, `gate_summary.csv` | + +실제 2025 STOM manifest smoke는 test split 3개 episode, rolling 2개 fold로 실행했다. + +```powershell +C:\Python\64\Python3119\python.exe -m stom_rl.cost_gate ` + --manifest webui\rl_runs\stom_1s_2025_episode_manifest\episode_manifest.json ` + --split test ` + --max-episodes 3 ` + --policies no_trade,random,buy_and_hold,momentum,mean_reversion,volume_filter ` + --cost-bps-values 5,10,15,25 ` + --slippage-bps-values 0 ` + --target-cost-bps 25 ` + --rolling-sessions-per-fold 3 ` + --rolling-max-folds 2 ` + --rolling-max-episodes-per-fold 3 ` + --output-dir webui\rl_runs\stom_1s_2025_cost_gate_smoke +``` + +대표 25bp target gate 결과는 다음과 같다. + +| 정책 | 평균 episode net | 거래/episode | hit rate | MDD | positive fold rate | gate | +|---|---:|---:|---:|---:|---:|---| +| buy_and_hold | +3.3240% | 1.00 | 1.0000 | 0.0000% | 0.5000 | 통과 | +| no_trade | 0.0000% | 0.00 | 0.0000 | 0.0000% | 0.0000 | 실패 | +| mean_reversion | -22.5387% | 65.00 | 0.0359 | -53.5298% | 0.0000 | 실패 | +| volume_filter | -31.2288% | 74.67 | 0.0045 | -67.6145% | 0.0000 | 실패 | +| momentum | -34.1359% | 71.33 | 0.0421 | -71.5770% | 0.0000 | 실패 | +| random | -77.2541% | 295.00 | 0.0124 | -98.8246% | 0.0000 | 실패 | + +주의: 위 표는 smoke 범위다. `buy_and_hold`가 smoke에서 통과했더라도 전체 2,730 test episode 기준 통과를 의미하지 않는다. 다음 모델 단계에 들어가기 전에 필요하면 동일 모듈로 전체 test split 실행을 별도로 수행한다. + +검증 명령: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_rl_cost_gate.py tests\test_stom_rl_baselines.py tests\test_stom_rl_trading_env.py tests\test_stom_rl_episode_manifest.py tests\test_stom_qlib_pipeline.py -q +C:\Python\64\Python3119\python.exe -m py_compile stom_rl\cost_gate.py stom_rl\baselines.py stom_rl\trading_env.py stom_rl\episode_manifest.py +``` + +검증 결과: + +```text +18 passed, 1 warning +py_compile OK +``` + +다음 페이지는 **페이지 6: 1차 RL 모델** 이다. 기본 추천은 바로 복잡한 PPO가 아니라 300초 reward horizon 기준 contextual bandit 또는 단순 DQN prototype으로 시작하는 것이다. + +--- + +## 10. 페이지 6 완료 기록 + +페이지 6에서는 Kronos를 사용하지 않는 첫 학습 모델을 구현했다. 복잡한 PPO/DQN으로 바로 가지 않고, 먼저 300초 horizon의 “매수할지/관망할지”를 학습하는 fixed-horizon contextual bandit을 만들었다. + +| 항목 | 결과 | +|---|---| +| 구현 모듈 | `stom_rl.contextual_bandit` | +| 모델 종류 | ridge regression 기반 fixed-horizon contextual bandit | +| 입력 feature | 과거 OHLCV에서 만든 수익률, range, 이동평균 이격, 거래량/거래대금 ratio | +| action | predicted 300초 net return이 threshold보다 크면 `buy`, 아니면 `hold` | +| reward target | 300초 후 round-trip net return | +| 기본 비용 | 25bp | +| 산출물 | `config.json`, `model.json`, `train_metrics.jsonl`, `eval_summary.json`, `actions.csv`, `trades.csv`, `equity_curve.csv`, `episodes.csv` | + +실제 2025 STOM manifest smoke는 train 5 episode, test 3 episode로 실행했다. + +```powershell +C:\Python\64\Python3119\python.exe -m stom_rl.contextual_bandit ` + --manifest webui\rl_runs\stom_1s_2025_episode_manifest\episode_manifest.json ` + --train-split train ` + --eval-split test ` + --max-train-episodes 5 ` + --max-eval-episodes 3 ` + --train-sample-stride 20 ` + --eval-sample-stride 1 ` + --lookback-window 300 ` + --reward-horizon-seconds 300 ` + --cost-bps 25 ` + --decision-threshold-bps 0 ` + --output-dir webui\rl_runs\stom_1s_2025_contextual_bandit_smoke +``` + +대표 결과는 다음과 같다. + +| 항목 | 값 | +|---|---:| +| train episode | 5 | +| train sample | 299 | +| train target positive rate | 27.42% | +| eval episode | 3 | +| eval trade count | 11 | +| trades / episode | 3.67 | +| avg episode net | +1.8960% | +| compounded return | +5.7773% | +| avg trade net | +0.5240% | +| hit rate | 63.64% | +| MDD | -2.6211% | +| 25bp cost gate | 통과 | + +단, 같은 3개 episode smoke에서 Page 5의 `buy_and_hold`는 +3.3240%였으므로, 이 모델은 “최종 우위 모델”이 아니라 **학습·저장·사용·평가 흐름이 실제 데이터에서 작동하는 첫 prototype**으로 본다. + +검증 명령: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_rl_contextual_bandit.py tests\test_stom_rl_cost_gate.py tests\test_stom_rl_baselines.py tests\test_stom_rl_trading_env.py tests\test_stom_rl_episode_manifest.py tests\test_stom_qlib_pipeline.py -q +C:\Python\64\Python3119\python.exe -m py_compile stom_rl\contextual_bandit.py stom_rl\cost_gate.py stom_rl\baselines.py stom_rl\trading_env.py stom_rl\episode_manifest.py +``` + +검증 결과: + +```text +20 passed, 1 warning +py_compile OK +``` + +다음 페이지는 **페이지 7: backend API** 이다. 웹 대시보드에서 RL run 목록, model summary, trade/equity/cost gate artifact를 읽으려면 API가 필요하다. + +--- + +## 11. 페이지 7 완료 기록 + +페이지 7에서는 `webui/rl_runs` 아래의 강화학습 산출물을 웹 대시보드가 읽을 수 있도록 read-only 백엔드 API를 추가했다. 이 단계는 새 학습을 실행하는 API가 아니라, 이미 생성된 manifest/baseline/cost gate/model artifact를 안전하게 조회하는 API다. + +| 항목 | 결과 | +|---|---| +| 구현 helper | `webui.rl_dashboard` | +| Flask route | `/api/rl/*` | +| run listing | `GET /api/rl/runs` | +| run detail | `GET /api/rl/runs/` | +| action/trade/equity/episode table | `GET /api/rl/runs//actions`, `/trades`, `/equity`, `/episodes` | +| generic table | `GET /api/rl/runs//table/
` | +| cost gate compact API | `GET /api/rl/runs//cost-gate` | +| path safety | run/policy는 direct child name만 허용 | +| table limit | 기본 500, 최대 5,000 row | + +실제 `webui/rl_runs` smoke 결과: + +| endpoint | 결과 | +|---|---| +| `/api/rl/runs?limit=10` | 200, `contextual_bandit_smoke`, `cost_gate_smoke`, `baselines_smoke`, `episode_manifest` 탐지 | +| `/api/rl/runs/stom_1s_2025_contextual_bandit_smoke` | 200, `artifact_type=contextual_bandit` | +| `/api/rl/runs/stom_1s_2025_contextual_bandit_smoke/trades?limit=3` | 200, 3 rows, truncated=true | +| `/api/rl/runs/stom_1s_2025_contextual_bandit_smoke/equity?limit=3` | 200, 3 rows, truncated=true | +| `/api/rl/runs/stom_1s_2025_cost_gate_smoke/cost-gate?limit=3` | 200, passing policy `buy_and_hold` | + +검증 명령: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_rl_dashboard_api.py tests\test_stom_rl_contextual_bandit.py tests\test_stom_rl_cost_gate.py tests\test_stom_rl_baselines.py tests\test_stom_rl_trading_env.py tests\test_stom_rl_episode_manifest.py tests\test_stom_qlib_pipeline.py tests\test_v2_route.py -q +C:\Python\64\Python3119\python.exe -m py_compile webui\rl_dashboard.py webui\app.py stom_rl\contextual_bandit.py stom_rl\cost_gate.py stom_rl\baselines.py +``` + +다음 페이지는 **페이지 8: 웹 대시보드** 이다. 이제 API가 있으므로 프론트엔드에서 “강화학습 실험실” 탭을 만들어 run 목록, cost gate, 모델 성과, trade/equity를 시각화할 수 있다. + + +--- + +## 12. 페이지 8 완료 기록 + +페이지 8에서는 Page 7의 `/api/rl/*` read-only API를 받아 v2 웹 대시보드에 연결했다. 사용자는 CMD 로그만 보지 않고 브라우저에서 강화학습 산출물, 비용 관문, 거래 기록, 모델 사용 흐름을 확인할 수 있다. + +| 항목 | 결과 | +|---|---| +| 화면 탭 | 사이드바/헤더/App shell에 `rl-lab` 등록 | +| 핵심 컴포넌트 | `webui/v2_src/src/tabs/RLLabTab.svelte` | +| API 연동 | `/api/rl/runs`, run detail, trades, equity, episodes, cost-gate | +| 시각화 | 비용 관문 bar/line chart, equity curve, episode net return chart | +| 표 | 25bp target gate, 거래 내역, artifact 목록 | +| 해석 보조 | smoke 모델이 아직 test split 생산 모델이 아니라는 보수적 안내 | +| 정적 산출물 | `webui/static/v2/dist` 갱신 | + +웹 탭 구성은 다음과 같이 정리했다. + +| 영역 | 표시 내용 | +|---|---| +| Run selector | contextual bandit, cost gate, baseline, episode manifest 산출물 선택 | +| 핵심 지표 | 평균 episode net, compounded return, MDD, episode/trade/artifact 수 | +| 비용 관문 | 25bp 기준 baseline/policy별 통과 여부, hit rate, MDD, 거래/episode | +| 거래 위치 | 진입/청산 timestamp, gross net, cost net | +| 모델 사용 흐름 | `model.json` feature와 inference 사용 방식 안내 | + +검증 명령: + +```powershell +npm run build +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_rl_dashboard_tab.py tests\test_stom_rl_dashboard_api.py tests\test_v2_route.py -q +``` + +검증 결과: + +```text +svelte-check 0 errors, 기존 경고 4개 +vite build OK +8 passed +``` + +브라우저 smoke: + +```text +Flask test server http://127.0.0.1:5070/ +Playwright headless Chromium +- `button[data-tab="rl-lab"]` 클릭 성공 +- `[data-rl-lab-tab]` 존재 확인 +- `[data-rl-cost-gate-table]` 존재 확인 +- run item 4개 확인 +- contextual_bandit_smoke 표시 확인 +- screenshot: .omx/tmp/rl-lab-page8-smoke.png +``` + +주의: 페이지 8은 “웹에서 smoke 산출물을 읽고 해석할 수 있음”을 증명한다. 아직 실거래 성과를 보장하지 않는다. 다음 페이지 9에서 전체 회귀 테스트, build, browser smoke, code review, 확장 또는 보류 판단을 완료해야 한다. + +다음 목표는 **페이지 9: 통합 QA / 리뷰**다. + + +--- + +## 13. 페이지 9 완료 기록 + +페이지 9에서는 전체 구현을 최종 QA, build, browser smoke, code review, 확장/보류 판단까지 닫았다. + +| 항목 | 결과 | +|---|---| +| 최종 보고서 | `docs/stom_rl_lab_final_qa_report_2026-05-23.md` | +| 전체 테스트 | `92 passed, 2 skipped, 2 warnings` | +| Frontend build | `npm run build` 통과, Svelte error 0 | +| Python compile | `stom_rl/*`, `webui/rl_dashboard.py`, `webui/app.py` 통과 | +| Browser smoke | Playwright headless Chromium 통과 | +| API smoke | `/api/rl/*` 주요 endpoint와 traversal probe 통과 | +| AI slop cleaner | production masking fallback 없음, optional Torch test guard 정리 | +| Code review | `APPROVE`, architect status `CLEAR` | + +최종 판단: + +- **구현 목표 달성**: 독립 RL 실험실, 데이터 적재/분할, 환경, baseline, 비용 관문, 첫 모델, API, 웹 대시보드가 동작한다. +- **실거래 자동화 보류**: 현재 모델은 smoke 성과 기준이며 full test split에서 buy-and-hold 대비 우위가 아직 증명되지 않았다. +- **다음 권장 작업**: full test split baseline/cost gate, contextual bandit 전체 학습·평가, DQN/PPO 후보 추가, transaction model 보강, leaderboard 운영. diff --git a/docs/stom_rl_liquidity_slippage_2026-05-29.md b/docs/stom_rl_liquidity_slippage_2026-05-29.md new file mode 100644 index 000000000..6eeef62c9 --- /dev/null +++ b/docs/stom_rl_liquidity_slippage_2026-05-29.md @@ -0,0 +1,109 @@ +# Page C — 유동성/슬리피지 & gap-through 꼬리 + +- 작성일: **2026-05-29 KST** +- 브랜치: `feature/stom-rl-lab` +- 상위 앵커: `docs/stom_rl_session_progress_2026-05-29.md`, `docs/stom_rl_gap_up_full_universe_2026-05-29.md`(Page B) +- 구현: `stom_rl/liquidity_model.py`(순수함수+CLI) / 테스트 `tests/test_stom_rl_liquidity_model.py`(15개) / 산출물 `.omx/artifacts/liquidity/summary.json` +- 대상: **시초 갭상승 `ts_imb` 룰 — RULE strategy, NOT reinforcement learning.** + +--- + +## 0. 한 줄 결론 + +**유동성: PASS.** 계좌 1천만~1억(f=10% → 주문 100만~1000만원)에서 주문은 진입봉 1초 거래대금의 **0.2~1.6%(median)**에 불과하고 **100% 트레이드가 feasible(≤1x)**, 슬리피지는 비관적 coef(20bp/√)에서도 **<3bp**라 slip-adjusted 기대값이 **+0.72~0.75% 유지**(breakeven ~75bp 여유 대비 무시 가능). **gap-through 꼬리: PASS.** full-universe 최악체결(sl_gap_stress)에서도 ts_imb 전 연도 양수(**2022 +0.603 포함**, bounded 음수 해소), breakeven OOS 81bp(비용 3.5배), 슬리피지 43bp서도 +0.445%. + +--- + +## 1. ⚠️ 단위 발견 (중요 — 잘못된 결론을 막은 sanity check) + +초기 분석은 `초당거래대금`(entry_sec_amount)을 raw 원으로 가정 → median participation 1606x, "0% feasible"라는 **불가능한 결과**가 나옴(622원/초는 1주 값도 안 됨). 점검 결과: + +- 같은 DB 행에서 **`초당매수금액 2,072,136원 / 초당매수수량 3,102주 = 668 = 현재가`** → 초당매수/매도금액은 **raw 원**. +- **`초당거래대금` = `당일거래대금`(누적, 백만원)의 초당 증분 = (초당매수금액+초당매도금액)/1e6** (예: 5.8M원→6, 41.8M원→42, 2.27M원→2). ✅ +- 결론: **`초당거래대금`은 백만원 단위.** 로더에서 ×1,000,000로 원 환산. + +median 622 = **6.22억원/초** 거래대금. 보정 후 결과가 물리적으로 타당. + +--- + +## 2. 유동성 feasibility (보정 후, ts_imb N=5175, full universe) + +f=10%, base 23bp, gross 0.98%(full OOS). 슬리피지 = coef·√(participation), coef는 **무보정 가정**(L2 없음) → sweep. + +| 계좌 | 1회 주문 | median participation | feasible(≤1x) | strict(≤0.1x) | slip-adj exp (coef 5/10/20bp) | +|---|---:|---:|---:|---:|---:| +| 1,000만 | 100만 | **0.2%** | 100% | 100% | +0.748 / +0.746 / +0.742 | +| 5,000만 | 500만 | **0.8%** | 100% | 97% | +0.746 / +0.741 / +0.732 | +| 1억 | 1,000만 | **1.6%** | 100% | 93% | +0.744 / +0.737 / +0.725 | + +- 세 계좌 모두 **median 슬리피지 <3bp** → +0.75% 엣지를 거의 깎지 않음(breakeven 여유 ~75bp 대비 무시 가능). +- 1개 entry-second는 거래대금 0(유동성 공백)이라 drop. 나머지 전 트레이드 feasible. + +### 2.1 스케일 한도 (어디서 유동성이 binding?) +- median 초당거래대금 6.22억원 → 주문 ≤ 1x(한 초 거래대금)이려면 **median 기준 계좌 ~62억원**까지 여유. +- p10 = 1.46억원(얇은 트레이드) → 그 구간은 **계좌 ~14.6억원**부터 1x 접근. +- 즉 **1억 계좌는 매우 여유**, 수억~수십억까지 대부분 feasible. 본 전략은 상당한 계좌 규모까지 유동성 확장 가능. + +--- + +## 3. 슬리피지 해석 (정직) + +- √-impact는 표준 형태지만 **coef는 L2 없이 보정 불가 → 가정**. 그래서 5/10/20bp sweep으로 제시. +- 핵심: 이 작은 participation(≤1.6%)에서는 어떤 coef를 써도 슬리피지가 <3bp라 **결론(엣지 유지)이 coef에 둔감**. 이것이 유동성 PASS의 강건성. + +--- + +## 4. gap-through 꼬리 (체결 de-idealization) + +R1에서 손절이 regret의 55%였고, sl_gap_stress(손절 gap-through 최악체결)가 실제 꼬리를 잡는다. + +- **bounded(120) 확정**(핸드오프 §5.4–5.5, 23bp): idealized +0.952 / realized +0.906 / **sl_gap_stress +0.811%/trade**. 최악 스트레스에도 양수. + - 연도별 sl_gap_stress: 2022 −0.05 / 2023 +1.32 / 2024 +0.96 / 2025 +0.85 / 2026 +0.76 (2022만 소폭 음수, 소표본). +- **full-universe sl_gap_stress: PASS** (2026-05-29 완료, `.omx/artifacts/gap_up_full_stress/`). ts_imb N=5175, 최악 손절 gap-through 체결에서도: + +| 지표 | 값 | +|---|---:| +| 연도별 @23bp | 2022 **+0.603** / 2023 +0.757 / 2024 +0.674 / 2025 +0.578 / 2026 +0.577 (전 연도 양수) | +| breakeven IS/OOS | +91.3 / +81.3bp (비용 23bp의 ~3.5배) | +| OOS multi-boundary | +0.583 ~ +0.650 (전 경계 양수) | +| 슬리피지 23→43bp | +0.645 → +0.445 | + +→ **bounded sl_gap_stress의 2022 −0.05가 대표본에서 +0.603으로 해소**(Page B idealized와 동일한 소표본 노이즈 패턴). 최악 체결 가정·전 종목·전 연도에서 양수 유지. idealized(+0.75) 대비 ~0.15%p 깎이지만 robust. + +--- + +## 5. 정직성 캐비엇 + +1. `초당거래대금`은 진입봉 1초값 **PROXY**. 첫 세션바가 09:00 누적값일 수 있어 분모가 부풀면 participation/슬리피지가 **실제보다 낙관적**일 수 있음 → feasibility 여유는 상한으로 해석. +2. 슬리피지 coef는 **가정**(L2 없음). 단 결론은 coef에 둔감(§3). +3. 여전히 triggered-subset DB. 시장 전체 일반화 미입증. +4. 이 분석은 주문 **크기** 유동성 검증이지 완전한 체결 realism(큐포지션·부분체결)이 아님 — 그건 데이터 한계로 불가. + +--- + +## 6. 재현 명령 + +```powershell +$env:PYTHONIOENCODING='utf-8' +# 유동성 분석 (full-universe instances.json 사용) +py -3.11 -X utf8 -m stom_rl.liquidity_model +# gap-through 꼬리(full) +py -3.11 -X utf8 stom_rl/gap_up_backtest.py --max-symbols 0 --fill-mode sl_gap_stress --regime-analysis --regime-cost-bps 23 --artifacts-dir .omx/artifacts/gap_up_full_stress +# 테스트 +py -3.11 -m pytest tests/test_stom_rl_liquidity_model.py -q # 15 passed +``` + +핵심 출력(2026-05-29): ts_imb N=5175, 1억 계좌 median participation 1.6%, 100% feasible, slip<3bp, slip-adj +0.73~0.74%. + +--- + +## 7. 페이지 트랙 갱신 + +| 페이지 | 상태 | +|---|---| +| A 사이징 / R0·R1·R1b(RL 청산 폐기) / B full universe | ✅ 완료 | +| **C 유동성/슬리피지** | ✅ **완료 — 유동성 PASS + gap-through PASS** (최악체결 전 연도 양수) | +| **D read-only paper/forward** | ⬜ **다음 권고** (≥20거래일 신호 관찰) | +| E broker/order 연동 | 🔒 승인 전 금지 | + +검증: liquidity_model 테스트 15 passed, code-reviewer APPROVE(단위 보정 실데이터 재확인). 단위 버그는 sanity check로 출시 전 차단. diff --git a/docs/stom_rl_opening_ppo_candidate_2026-06-01.md b/docs/stom_rl_opening_ppo_candidate_2026-06-01.md new file mode 100644 index 000000000..b55d1d553 --- /dev/null +++ b/docs/stom_rl_opening_ppo_candidate_2026-06-01.md @@ -0,0 +1,108 @@ +# STOM tick-data PPO opening-trade candidate — autoresearch-goal result + +Date: 2026-06-01 KST +Workflow: `$autoresearch-goal` +Mission slug: `stom-tick-data-rl-opening-trade-candidate-model` + +## Conclusion + +A small Stable-Baselines3 PPO candidate was trained and evaluated on the tick-derived opening-trade episode manifest. + +**Verdict: `NO-GO_USABLE_MODEL`.** + +This is a historical RL experiment record, not a usable trading model. The 500-episode OOS evaluation failed the cost/drawdown gate, and this run did not establish superiority over the `ts_imb` RULE baseline, no-trade, or buy-and-hold under the 23bp round-trip cost assumption. + +## Experiment contract + +| Item | Value | +|---|---| +| Model family | Stable-Baselines3 PPO | +| Training timesteps | 5000 | +| Manifest | `webui/rl_runs/stom_1s_2025_episode_manifest/episode_manifest.json` | +| Train split | manifest `train` | +| Eval split | manifest `test` | +| Cost | 23bp round trip | +| Model artifact | `.omx/artifacts/autoresearch_goal/stom_opening_ppo_5k/ppo_model.zip` | +| Smoke summary | `.omx/artifacts/autoresearch_goal/stom_opening_ppo_5k/sb3_smoke_summary.json` | + +## Commands + +### Training plus 200-episode smoke evaluation + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.sb3_smoke ` + --manifest webui/rl_runs/stom_1s_2025_episode_manifest/episode_manifest.json ` + --output-dir .omx/artifacts/autoresearch_goal/stom_opening_ppo_5k ` + --algorithms ppo ` + --total-timesteps 5000 ` + --max-eval-episodes 200 ` + --max-eval-steps-per-episode 2048 ` + --cost-bps 23 ` + --device auto ` + --no-live-events +``` + +### 500-episode OOS evaluation + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.sb3_eval ` + --model-dir .omx/artifacts/autoresearch_goal/stom_opening_ppo_5k ` + --algorithms ppo ` + --eval-episodes 500 ` + --max-eval-steps-per-episode 2048 ` + --output-dir .omx/artifacts/autoresearch_goal/stom_opening_ppo_5k_eval500 ` + --manifest webui/rl_runs/stom_1s_2025_episode_manifest/episode_manifest.json ` + --cost-bps 23 ` + --device cpu ` + --no-live-events ` + --source-run stom_opening_ppo_5k +``` + +## Results + +### 200-episode smoke evaluation + +| Metric | Value | +|---|---:| +| avg episode net | 0.965772% | +| median episode net | 0.784546% | +| hit rate | 0.660 | +| max drawdown | -19.276049% | +| passes cost gate | True | + +### 500-episode OOS evaluation + +| Metric | Value | +|---|---:| +| episodes | 500 | +| trades | 501 | +| avg episode net | 0.528793% | +| median episode net | 0.284910% | +| avg trade net | 0.527787% | +| hit rate | 0.563 | +| max drawdown | -30.641114% | +| passes cost gate | False | + +Action counts on the 500-episode eval: + +```text +{'hold': 590067, 'sell': 704, 'buy': 519} +invalid_action_count=721, invalid_action_rate=0.001219 +``` + +## Interpretation boundaries + +- This is an `RL EXPERIMENT`, not the mainline strategy. +- Do not call this a live-ready, profitable, or broker-ready model. +- The `ts_imb` opening gap-up curve remains a RULE baseline, not RL. +- Promotion would require OOS superiority over no-trade, buy-and-hold, and the `ts_imb` RULE baseline after 23bp costs, with acceptable drawdown and negative/shuffle controls. +- Later June 2026 docs supersede this candidate with stricter OOS/baseline/control requirements and keep RL candidates in `NO-GO` or research-only status. + +## Follow-up requirements before any future PPO/DQN claim + +1. Run the full test split or a preregistered bounded OOS split. +2. Include deterministic negative/shuffle controls. +3. Compare paired OOS net against no-trade, buy-and-hold, and `ts_imb` RULE baseline under the same 23bp cost assumption. +4. Surface max drawdown, trade count, invalid action rate, and failure reasons in docs and dashboard. diff --git a/docs/stom_rl_oracle_exit_ceiling_2026-05-29.md b/docs/stom_rl_oracle_exit_ceiling_2026-05-29.md new file mode 100644 index 000000000..0a9df90ee --- /dev/null +++ b/docs/stom_rl_oracle_exit_ceiling_2026-05-29.md @@ -0,0 +1,109 @@ +# Oracle-exit 천장 테스트 결과 (Page R1) + +- 작성일: **2026-05-29 KST** +- 브랜치: `feature/stom-rl-lab` +- 상위 앵커: `docs/stom_rl_rl_feasibility_research_2026-05-29.md`(R0 딥리서치), `docs/stom_rl_resume_commit_2026-05-29.md` +- 구현: `stom_rl/exit_oracle.py` (순수함수 + CLI) / 테스트 `tests/test_stom_rl_exit_oracle.py` (15개) / 산출물 `.omx/artifacts/oracle_exit/summary.json`(gitignored) +- 대상: **시초 갭상승 `ts_imb` 룰 — RULE strategy, NOT reinforcement learning.** + +> 가드레일: 이 문서는 RL이 수익난다고 말하지 않는다. "RL 청산을 만들 가치가 있는가"를 **완전예지 상한(oracle)**으로 판정하는 게이트일 뿐이다. + +--- + +## 0. 한 줄 결론 + +**완전예지 청산 대비 룰의 capture는 17.8%뿐이라 "여지"는 분명히 크지만, 이는 RL을 시작할 *필요조건*일 뿐 *충분조건*이 아니다.** 완전예지 regret은 변동성 있는 어떤 종목에서도 크게 나오기 때문이다. 따라서 R1의 정직한 판정은 "RL을 만들어라"가 아니라 **"인과적 청산 개선(트레일링/SL 조정)이 OOS에서 룰을 이기는지 값싸게 먼저 검정하라(R1b)"**이며, regret의 55%가 손절(SL) 청산에서 나온다는 점이 가장 강한 단서다. + +--- + +## 1. 결과 수치 (ts_imb, N=235, realized 체결, 비용 23bp) + +> regret은 비용 불변(oracle·rule 둘 다 1회 왕복비용 차감 → 상쇄). 아래는 검증 corpus와 동일한 bounded universe(`--max-symbols 120`). + +| 지표 | 값 | 의미 | +|---|---:|---| +| rule_mean_net | **+0.906%/trade** | 룰의 realized 기대값(핸드오프 realized 수치와 일치 → 모듈 정합성 확인) | +| oracle_mean_net | **+5.089%/trade** | 완전예지 최선 청산(상한) | +| regret 평균 | **+4.182%** | trade당 남긴 청산 가치(상한 대비) | +| regret 중앙값 | +2.842% | | +| regret p90 | +10.384% | | +| regret 최대 | +24.467% | | +| **capture_ratio** | **17.8%** | 룰이 완전예지의 17.8%만 포착 | +| frac_rule_optimal | 1.7% | 룰이 진짜 고점에 청산한 비율(거의 없음 — 당연) | + +### 1.1 청산 사유별 regret (가장 중요한 단서) + +| 룰 청산사유 | 비중 | 평균 regret | +|---|---:|---:| +| TP (익절) | 31% | +4.296% | +| **SL (손절)** | **55%** | **+4.678%** | +| TIME (시간청산) | 14% | +1.982% | + +→ **trade의 55%가 손절로 끝나고, 그 손절들이 평균 +4.68%/trade의 가장 큰 regret을 남긴다.** "−1% 손절이 이후 회복할 종목을 잘라낸다"는 가설과 정합. 이것이 다음 실험의 1순위 타깃이다. + +--- + +## 2. 정직한 해석 — 왜 "RL 즉시 착수"가 아닌가 + +1. **완전예지 상한은 약한 신호다.** 20분 창에서 가격 경로의 최대값은 인과적으로 도달 불가능한 hindsight다. 변동성 있는 어떤 자산도 완전예지 regret은 크게 나온다. 따라서 capture 17.8%는 "여지 있음"의 *필요조건*만 충족한다(만약 완전예지조차 룰을 못 이겼다면 RL은 확실히 불가 → 그 경우만 즉시 기각). +2. **R0 딥리서치(105 에이전트 검증)와 합치면**: 문헌상 청산 RL조차 비용차감 OOS 우위 입증이 0건이다. 즉 "여지(R1)"는 있으나 "인과적 포착 가능성"은 미입증. +3. 그러므로 R1의 출력은 **RL greenlight가 아니라, 더 값싼 인과 baseline(R1b) greenlight**다. + +--- + +## 3. 다음 단계 권고 — R1b: 인과적 청산 baseline (RL 아님) + +**목적**: 완전예지가 아니라 *인과적으로 실행 가능한* 단순 규칙이 고정 TP5/SL1/09:25를 OOS에서 이기는지 검정. 이기면 → 여지가 인과적으로 포착 가능 → R3(청산 RL) 정당화. 못 이기면 → 여지는 순수 hindsight → RL 만들지 않음. + +| 실험 | 내용 | +|---|---| +| SL 폭 sweep | −1% → −1.5/−2/−3% (손절이 regret의 55%이므로 1순위) | +| 트레일링 스톱 | 고점 대비 −X% 추적청산 | +| 시간청산 sweep | 09:25 → 09:20/09:30 등 | +| 부분청산 | TP 도달 시 50% 청산 + 잔량 트레일 | + +**검증 하드닝 필수**(R0 결론): purged walk-forward + **Deflated Sharpe**(시도 횟수 입력) + PBO/CSCV + 다중시드. 단일 best-run 샤프 금지. + +산출(예정): `stom_rl/exit_baselines.py`(순수함수) + 테스트 + `docs/stom_rl_exit_baseline_.md`. + +> R1b가 OOS에서 고정 룰을 **유의하게** 못 이기면, 청산 RL(R3)은 폐기하고 운영 트랙(B/C/D)으로 복귀한다. + +--- + +## 4. 재현 명령 + +```powershell +# 천장 테스트(realized, ts_imb, bounded). UTF-8 강제(cp949 콘솔 대비). +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.exit_oracle --max-symbols 120 --cost-bps 23 --filter ts_imb +# 결과 -> .omx/artifacts/oracle_exit/summary.json +``` + +```powershell +# 테스트(15개) +py -3.11 -m pytest tests/test_stom_rl_exit_oracle.py -q +``` + +핵심 출력(2026-05-29): +```text +N=235 rule_mean=+0.906% oracle_mean=+5.089% +regret: mean=+4.182% median=+2.842% p90=+10.384% max=+24.467% +capture_ratio=17.8% frac_rule_optimal=1.7% + tp n=72 share=31% regret_mean=+4.296% + sl n=130 share=55% regret_mean=+4.678% + time n=33 share=14% regret_mean=+1.982% +``` + +--- + +## 5. 페이지 트랙 갱신 + +| 페이지 | 상태 | +|---|---| +| R0 RL 타당성 딥리서치 | ✅ 완료 | +| **R1 oracle-exit 천장 테스트** | ✅ **완료(이 문서)** — 여지 있음(필요조건 충족), 단 RL 직행 아님 | +| **R1b 인과적 청산 baseline** | ⬜ **다음 권고** (SL/트레일링/시간 sweep + walk-forward·Deflated Sharpe) | +| R2 메타라벨링(진입 필터) | ⬜ 대안 | +| R3 offline RL(CQL) 청산 | 🔒 R1b가 인과 우위 입증 시에만 | + +검증: 전체 게이트 테스트 116 passed, R1 코드리뷰 APPROVE(regret≥0를 2M 랜덤경로로 증명). diff --git a/docs/stom_rl_page100_completion_report_2026-05-24.md b/docs/stom_rl_page100_completion_report_2026-05-24.md new file mode 100644 index 000000000..21249bfc7 --- /dev/null +++ b/docs/stom_rl_page100_completion_report_2026-05-24.md @@ -0,0 +1,90 @@ +# STOM RL ?꾩껜/?섏씠吏€蹂?100% ?꾨즺 蹂닿퀬 + +?묒꽦?? 2026-05-24 KST +釉뚮옖移? `feature/stom-rl-lab` +湲곗? 而ㅻ컠 ???곹깭: `5bbb3a8 STOM 媛뺥솕?숈뒿 ?대깽?몃? ?ㅼ떆媛??붾㈃?쇰줈 ?뉖떎` + +## ?꾨즺 ?붿빟 + +?ㅼ떆媛?STOM 媛뺥솕?숈뒿 ?쒓컖??MVP瑜??ㅼ젣 DQN/PPO mini/short ?숈뒿 ?곗텧臾쇨퉴吏€ ?뺤옣?덈떎. `stom_rl.sb3_smoke`濡?CUDA 湲곕컲 DQN/PPO ?숈뒿??5k?€ 50k ?④퀎濡??ㅽ뻾?덇퀬, ?앹꽦??live event/model/summary artifact瑜?RL API, RL Lab 吏꾪뻾瑜? performance leaderboard???곌껐?덈떎. + +> ?덉쟾 寃쎄퀎: ??寃곌낵??historical replay / smoke-short training 寃€利앹씠硫??ㅼ<臾? ?먮룞留ㅻℓ, paper/live execution ?곌껐???꾨땲?? + +## ?꾩껜 吏꾪뻾瑜? +| ?곸뿭 | 吏꾪뻾瑜?| ?꾨즺 利앷굅 | +|---|---:|---| +| ?꾩껜 RL ?섏씠吏€ ?꾨즺??| 100% | `/api/rl/progress` 湲곗? 紐⑤뱺 ?섏씠吏€ criteria ?듦낵 | +| ?ㅼ젣 ?λ윭???숈뒿 吏꾪뻾 | 100% | DQN/PPO 5k mini + 50k short CUDA ?숈뒿 ?꾨즺 | +| ?ㅼ떆媛??대깽???쒓컖??| 100% | `rl_live_events.jsonl`, `/api/rl/runs//events`, RL Lab ?ㅼ떆媛?RL ??| +| Leaderboard ?곌껐 | 100% | `dqn_50k`, `ppo_50k`, `dqn_5k`, `ppo_5k`, smoke 紐⑤뜽 ?먮룞 吏묎퀎 | +| 寃€利?臾몄꽌??| 100% | pytest/ruff/mypy/npm build/API smoke + 蹂?臾몄꽌 | + +## ?섏씠吏€蹂?吏꾪뻾瑜? +| ?섏씠吏€/?곸뿭 | 吏꾪뻾瑜?| ?꾨즺 湲곗? | +|---|---:|---| +| RL Lab 媛쒖슂 | 100% | run 紐⑸줉, artifact ?좏삎, ?곸꽭 artifact 議고쉶 媛€??| +| ?ㅼ떆媛?RL | 100% | live event log, DQN/PPO event, 50k short run ?뺤씤 | +| ?ㅼ젣 ?λ윭???숈뒿 | 100% | `check_env`, CUDA, DQN/PPO model zip ?뺤씤 | +| Performance Leaderboard | 100% | 13 rows, `dqn_50k`/`ppo_50k` short model 諛섏쁺 | +| Artifacts / Models | 100% | summary/csv/jsonl 諛?DQN/PPO zip ?앹꽦 | +| Docs / ?댁쁺 寃쎄퀎 | 100% | 援ы쁽 臾몄꽌 + ?꾨즺 蹂닿퀬 + read-only ?덉쟾 寃쎄퀎 | + +## ?ㅼ젣 ?숈뒿 ?곗텧臾? +| run | timesteps | event count | 紐⑤뜽 | ?듭떖 寃곌낵 | +|---|---:|---:|---|---| +| `stom_1s_2025_sb3_5k` | 5,000 | 5,083 | DQN/PPO | DQN avg episode net 0.5494%, PPO 0.4040% | +| `stom_1s_2025_sb3_50k` | 50,000 | 10,000 tail summary | DQN/PPO | DQN avg episode net 1.6142%, PPO 1.5717% | + +?앹꽦 ?뚯씪 ?덉떆: + +```text +webui/rl_runs/stom_1s_2025_sb3_50k/dqn_model.zip +webui/rl_runs/stom_1s_2025_sb3_50k/ppo_model.zip +webui/rl_runs/stom_1s_2025_sb3_50k/rl_live_events.jsonl +webui/rl_runs/stom_1s_2025_sb3_50k/rl_live_summary.json +webui/rl_runs/stom_1s_2025_sb3_50k/sb3_smoke_summary.json +``` + +## Leaderboard 寃곌낵 + +`stom_rl.performance_leaderboard`???댁젣 湲곕낯媛믪쑝濡?`webui/rl_runs/stom_1s_2025_sb3*/sb3_smoke_summary.json`瑜??먮룞 ?먯깋?쒕떎. + +| ?쒖쐞沅?| 紐⑤뜽 | run | timesteps | ?곹깭 | +|---:|---|---|---:|---| +| 1 | `dqn_50k` | `stom_1s_2025_sb3_50k` | 50,000 | candidate | +| 2 | `ppo_50k` | `stom_1s_2025_sb3_50k` | 50,000 | candidate | +| 3 | `dqn_5k` | `stom_1s_2025_sb3_5k` | 5,000 | candidate | +| 5 | `ppo_5k` | `stom_1s_2025_sb3_5k` | 5,000 | watch | + +## 異붽???API + +```text +GET /api/rl/progress +``` + +諛섑솚 紐⑹쟻: + +- ?꾩껜 吏꾪뻾瑜?`overall_progress_pct` +- ?섏씠吏€蹂?吏꾪뻾瑜?`pages[].progress_pct` +- ?섏씠吏€蹂??꾨즺 criteria/evidence +- 理쒖떊 SB3 run, 理쒕? timesteps, leaderboard 紐⑤뜽 利앷굅 + +## 寃€利?紐낅졊 + +```powershell +py -3.11 -m stom_rl.sb3_smoke --output-dir webui\rl_runs\stom_1s_2025_sb3_5k --total-timesteps 5000 --max-eval-episodes 3 --max-eval-steps-per-episode 512 --device cuda --live-event-sample-interval 5 +py -3.11 -m stom_rl.sb3_smoke --output-dir webui\rl_runs\stom_1s_2025_sb3_50k --total-timesteps 50000 --max-eval-episodes 5 --max-eval-steps-per-episode 1024 --device cuda --live-event-sample-interval 50 +py -3.11 -m stom_rl.performance_leaderboard +py -3.11 -m pytest tests -q +py -3.11 -m ruff check stom_rl\performance_leaderboard.py webui\rl_dashboard.py webui\app.py tests\test_stom_rl_performance_leaderboard.py +py -3.11 -m mypy stom_rl\performance_leaderboard.py webui\rl_dashboard.py --ignore-missing-imports +cd webui\v2_src; npm run build +``` + +## ?⑥? 由ъ뒪?? +| 由ъ뒪??| ?곹깭 | ?ㅼ쓬 ?④퀎 | +|---|---|---| +| ?μ떆媛?200k~1M ?숈뒿 | 蹂대쪟 | 50k ?덉젙????蹂꾨룄 long-run goal | +| ?ㅼ떆媛?push streaming | 蹂대쪟 | JSONL polling ?덉젙????SSE/WebSocket 寃€??| +| ?ㅼ<臾?paper trading | ?쒖쇅 | 蹂꾨룄 ?덉쟾 ?뱀씤/由ъ뒪??寃뚯씠???꾩슂 | +| browser-use ?쒓컖 罹≪쿂 | ?꾧뎄 媛€?⑹꽦 ?섏〈 | in-app browser tool ?몄텧 ??異붽? 罹≪쿂 | \ No newline at end of file diff --git a/docs/stom_rl_page10_5_earlyread_2026-05-26.md b/docs/stom_rl_page10_5_earlyread_2026-05-26.md new file mode 100644 index 000000000..b016321ec --- /dev/null +++ b/docs/stom_rl_page10_5_earlyread_2026-05-26.md @@ -0,0 +1,152 @@ +# Page 10.5 — Thin-slice 조기 성능 read (비용 반영 알파 go/no-go) + +- 작성일: 2026-05-26 +- 계획 근거: `.omx/plans/ralplan-stom-rl-portfolio-page7-15.md` §5 (Page 10.5), §6 Page 10.5 +- 베이스라인 커밋: `7107d24` (Page 10, env T+1 체결 고정) +- 브랜치: `feature/stom-rl-lab` +- 실행: Python 3.11.9 (Windows), 기존 툴링 재사용 (**프로덕션 코드 변경 없음**) + +> **결론 먼저**: 이 thin-slice 구간에서 **RL(현재 결정론 대역 정책)은 비용 반영 후 equal-weight 를 이기지 못한다.** 3개 fold 전부에서 RL 대역은 equal-weight 대비 크게 밑돈다(-3.9% ~ -4.6% vs +0.77% ~ -0.19%). 손실의 대부분은 과도한 거래 회전(턴오버 ~17.9×)에서 나오는 거래비용이다. 이는 **예상 가능한 정상 결과**이며 실패가 아니다. **권고: Page 11(holdout) 으로 진행하되, full 투입 전에 비용 인지형 reward/액션 설계(거래 빈도 억제)를 우선 보강**한다. + +--- + +## 0. 이 read 의 성격과 caveat (읽기 전 필수) + +1. **IN-SAMPLE caveat (낙관적/방향성 전용)**: `stom_rl/portfolio_walk_forward.py:61-138` 는 현재 timestamp 를 bin 으로 나눈 뒤 **같은 bin 안에서 평가**한다. 즉 train/test holdout 이 없고 전부 in-sample 이다. holdout 은 Page 11 의 executor 과제(P1-2)다. 따라서 여기서 보이는 알파는 **낙관적이며 방향성(directional) 신호로만** 해석해야 한다. + +2. **"RL" 은 아직 학습된 정책이 아니다 (이중 caveat)**: 현 단계 코드베이스에는 학습된 RL 정책이 없다. walk-forward 의 5번째 baseline "RL" 은 `rule_baseline` 정책(`portfolio_walk_forward.py:86-95`)으로 대표되며, 이는 `portfolio_train.py:52-62` 의 결정론 스모크 정책("an RL agent will consume" 한다고 docstring 에 명시된 고정 액션 레이아웃)과 동일한 로직이다. 즉 매 스텝 매수 슬롯이 비면 매수하고 `step % 4 == 3` 마다 매도하는 **고빈도 결정론 대역**이다. 실제 학습 정책은 이보다 나을 수도, 나쁠 수도 있다. + +3. **비용 가정**: `cost_bps=25.0`, `slippage_bps=0.0`, `initial_cash=1,000,000 KRW`. 비용은 env 가 체결마다 적용하며 NAV 에 반영된다. 체결가는 T+1(`fill_price`). + +4. **결과**: 위 두 caveat 하에서도 비용 반영 신호는 명확하다 — RL 대역은 비용 때문에 진다. 이는 "알파를 짜내려고 RL 을 좋게 보이게 한 것이 아니라" 있는 그대로의 숫자다. + +--- + +## 1. 사용한 candidate set + +`python -m stom_rl.candidate_gen` 로 DB 윈도우에서 생성 (풀스캔 아님, 소수 종목·짧은 윈도우만): + +| 항목 | 값 | +|---|---| +| DB | `_database/stock_tick_back.db` | +| 종목(tables) | `000100, 000150, 000250` | +| 세션 | `20250709` | +| 윈도우 요청 | 09:00:00 – 10:00:00 | +| 룰 | `stom_rl/rules/buy_demand_pressure.json` | +| **candidate 수** | **227** (전부 fillable, unfillable 0) | +| distinct timestamp | 219 | +| 종목별 분포 | `000250`: 168, `000100`: 59, `000150`: 0 (이 윈도우에서 룰 미발화) | +| 실제 candidate 시간 범위 | 09:00:05 – 09:29:58 (룰이 첫 30분에만 발화) | + +스키마 확인: `price`(close@T) + `fill_price`(T+1) 컬럼 모두 존재, T+1 체결 계약 충족. Page 10 의 227 candidate 결과와 일치. + +--- + +## 2. 비용 반영 5-baseline vs RL 비교 (per-fold) + +`python -m stom_rl.portfolio_walk_forward --candidate-csv --output-dir .omx/artifacts/page10_5_earlyread/ --n-folds 3 --max-steps-per-fold 200` + +Fold 구간 (in-sample, 같은 세션 내 시간 bin): + +| fold | 구간 | candidate 수 | +|---|---|---| +| 0 | 09:00:05 – 09:13:54 | 76 | +| 1 | 09:13:55 – 09:21:44 | 78 | +| 2 | 09:21:55 – 09:29:58 | 73 | + +각 fold 73 step 실행. **비용(KRW)·턴오버는 env 의 trade log 에서 직접 합산**한 값이다(report JSON 은 비용 컬럼을 노출하지 않으므로 trade log 로 명시 산출). + +### Fold 0 (09:00:05 – 09:13:54) + +| 정책 | 거래수 | 턴오버(×) | 비용(KRW) | MDD% | **수익률%(비용 후)** | +|---|---:|---:|---:|---:|---:| +| no_trade (무거래 바닥) | 0 | 0.00 | 0 | 0.00 | **0.0000** | +| equal_weight_candidate | 2 | 0.50 | 1,250 | -0.13 | **+0.7710** | +| buy_and_hold | 2 | 0.50 | 1,250 | -0.13 | **+0.7710** | +| rule_baseline | 73 | 17.91 | 44,763 | -3.91 | **-3.9125** | +| **RL (= 결정론 대역 = rule_baseline)** | 73 | 17.91 | 44,763 | -3.91 | **-3.9125** | + +### Fold 1 (09:13:55 – 09:21:44) + +| 정책 | 거래수 | 턴오버(×) | 비용(KRW) | MDD% | **수익률%(비용 후)** | +|---|---:|---:|---:|---:|---:| +| no_trade | 0 | 0.00 | 0 | 0.00 | **0.0000** | +| equal_weight_candidate | 2 | 0.50 | 1,249 | -0.25 | **-0.1861** | +| buy_and_hold | 2 | 0.50 | 1,249 | -0.25 | **-0.1861** | +| rule_baseline | 73 | 17.85 | 44,621 | -4.58 | **-4.5826** | +| **RL (= 결정론 대역)** | 73 | 17.85 | 44,621 | -4.58 | **-4.5826** | + +### Fold 2 (09:21:55 – 09:29:58) + +| 정책 | 거래수 | 턴오버(×) | 비용(KRW) | MDD% | **수익률%(비용 후)** | +|---|---:|---:|---:|---:|---:| +| no_trade | 0 | 0.00 | 0 | 0.00 | **0.0000** | +| equal_weight_candidate | 2 | 0.50 | 1,249 | -0.19 | **-0.1479** | +| buy_and_hold | 2 | 0.50 | 1,249 | -0.19 | **-0.1479** | +| rule_baseline | 73 | 17.84 | 44,591 | -4.57 | **-4.5651** | +| **RL (= 결정론 대역)** | 73 | 17.84 | 44,591 | -4.57 | **-4.5651** | + +### 핵심 관측 + +- `equal_weight_candidate` 와 `buy_and_hold` 는 이 데이터에서 동일한 거래·결과(둘 다 첫 매수 가능 슬롯 1개를 잡고 보유). best policy by return = `equal_weight_candidate`. +- RL 대역의 손실은 **거의 전부 비용 발생**: 73회 거래 × ~17.9× 턴오버 → fold 당 ~44,600 KRW 비용. 같은 구간 equal-weight 의 비용은 ~1,250 KRW (35배 차이). 즉 알파 부재라기보다 **비용 누수**가 RL 대역을 침몰시킨다. +- `no_trade` 가 모든 RL 대역 fold 를 이긴다(0% > -3.9%~-4.6%). 거래할수록 손해라는 신호. + +--- + +## 3. go/no-go 판정 + +**질문: RL 이 비용 후 equal-weight 를 이기는가? → 아니오 (명확한 No).** + +- 3개 fold 전부에서 RL 대역(-3.9% / -4.6% / -4.6%)이 equal-weight(+0.77% / -0.19% / -0.15%)에 크게 밑돈다. +- 이는 §0 의 두 caveat(① in-sample 낙관, ② 학습 정책 아님 = 고빈도 결정론 대역) 때문에 **방향성 신호**로만 해석한다. "학습된 RL 이 알파가 없다"는 결론이 아니라, **현재 대역 정책의 거래 빈도/비용 설계가 비용을 못 이긴다**는 신호다. + +이 페이지는 **게이트가 아니라 의식적 체크포인트**다(계획 §5 명시). 따라서 판정은 "중단"이 아니라 **조건부 진행**이다. + +--- + +## 4. Page 11 권고 + +**권고: Page 11(holdout walk-forward) 으로 진행하되, full 투입 전에 아래를 우선 반영한다.** + +1. **비용 인지형 설계 우선** (가장 큰 레버): + - reward 에 거래비용/턴오버 패널티를 강화하거나, 액션 공간에서 매 스텝 매도(`step % 4 == 3` 강제 매도) 같은 고빈도 churn 을 억제. 현재 RL 대역이 진 원인은 알파 부재가 아니라 ~17.9× 턴오버 비용이다. + - equal-weight 가 0.5× 턴오버로 비슷하거나 더 나은 성과를 내므로, **낮은 회전이 이 데이터의 강한 baseline** 임을 학습 목표에 반영. + +2. **holdout 필수 (P1-2)**: Page 11 은 in-sample 격하가 아니라 expanding-window holdout(fold N 학습 → disjoint·later fold N+1 평가)로 가야 한다. 그래야 여기서 본 낙관적 알파가 과적합인지 검증된다. holdout 제거는 §1 User Sign-off 또는 architect 승인 시에만 허용. + +3. **누수 canary**: Page 11 의 미래 컬럼 주입 시 성능 붕괴 단언 테스트를 함께 켠다. + +4. **데이터 폭 확대 검토**: 이 read 는 단일 세션·2종목·30분(룰 발화 구간)에 불과하다. Page 11 에서는 더 많은 fold/세션으로 신호 안정성을 본다. 단, full-universe 는 Page 16 게이트. + +**대안 시나리오(재고)**: 만약 비용 인지형 reward 보강 후에도 holdout 에서 RL 이 equal-weight 를 못 이기면, 계획 §1-2(Page 14 = 격차 문서화로 트랙 B 완료) 경로로 "성능 격차 + 다음 연구 방향" 을 문서화하는 것이 정당한 종착점이다. + +--- + +## 5. 재현 커맨드 + +```bash +# 1) candidate 생성 +py -3.11 -m stom_rl.candidate_gen \ + --db _database/stock_tick_back.db \ + --tables 000100,000150,000250 --session 20250709 \ + --time-start 090000 --time-end 100000 \ + --rules stom_rl/rules/buy_demand_pressure.json \ + --output .omx/artifacts/page10_5_earlyread/candidates.csv \ + --topk-report .omx/artifacts/page10_5_earlyread/topk.json --top-k 3 + +# 2) walk-forward (4 결정론 baseline; RL = rule_baseline 대역) +py -3.11 -m stom_rl.portfolio_walk_forward \ + --candidate-csv .omx/artifacts/page10_5_earlyread/candidates.csv \ + --output-dir .omx/artifacts/page10_5_earlyread/ \ + --n-folds 3 --max-steps-per-fold 200 +``` + +아티팩트(커밋 안 함, `.omx/`): `candidates.csv`, `topk.json`, `portfolio_walk_forward_report.json`, `portfolio_walk_forward_folds.csv`. + +--- + +## 6. 메모 (정직성) + +- **프로덕션 코드 변경 없음** — 기존 CLI 만 실행. 비용/턴오버/MDD 는 report JSON 이 노출하지 않아 env trade log/NAV path 로 직접 산출(read-only 분석, 코드 수정 아님). +- 숫자를 RL 에 유리하게 가공하지 않았다. 비용은 명시적으로 보고했고(거래 빈도가 손실의 주원인), in-sample 및 "학습 정책 아님" caveat 를 전면에 명시했다. diff --git a/docs/stom_rl_page14_perf_optimization_2026-05-26.md b/docs/stom_rl_page14_perf_optimization_2026-05-26.md new file mode 100644 index 000000000..fc8cd018f --- /dev/null +++ b/docs/stom_rl_page14_perf_optimization_2026-05-26.md @@ -0,0 +1,204 @@ +# Page 14 — 성능 최적화 (TRACK B, 결과 문서화로 완료) + +- 작성일: 2026-05-26 +- 계획 근거: `.omx/plans/ralplan-stom-rl-portfolio-page7-15.md` §5 (Page 14), §0 트랙 B +- 베이스라인 커밋: `95ff7f8` (Page 13, 대시보드 연결까지 완료) +- 브랜치: `feature/stom-rl-lab` +- 실행: Python 3.11.9 (Windows), **기존 CLI 플래그만 사용 — 프로덕션 코드 변경 없음** +- 비용 가정(명시): **`cost_bps = 25.0`**, `slippage_bps = 0.0`, `initial_cash = 1,000,000 KRW`, 체결 = **T+1** (출처 `stom_rl/accounting.py:70`, `stom_rl/baselines.py:55`; walk-forward config 에 노출) + +--- + +## 0. 결론 먼저 + +**판정: 어떤 변형도 비용(`cost_bps=25.0`) 반영 후 equal-weight 를 이기지 못한다 (No). → 트랙 B = "문서화된 성능 격차 + 다음 연구 방향"으로 완료.** + +핵심 원인은 다시 한 번 확인되었다: **알파 부재가 아니라 거래 회전(turnover) 비용**이다. 비용을 0 으로 두면 현재 결정론 대역 정책("RL")이 equal-weight 를 아주 근소하게(+0.07%p) 이기지만, **break-even 비용은 약 0.54 bps**에 불과하다. 즉 현실적 비용(5~25 bps) 어디에서도 정책이 가진 미세한 raw edge 는 회전 비용에 전부 잡아먹힌다. + +가장 효과적인 레버는 **거래 빈도를 직접 줄이는 것**이었다: +- **포지션 슬롯 축소(top_k/max_positions↓)는 효과 없음** — 대역 정책이 슬롯 수와 무관하게 매 사이클 사고팔기 때문에 회전이 그대로다. +- **더 엄격한 룰(`buy_widev1`, candidate 227→25)** 은 거래 기회 자체를 줄여 격차를 **-3.17%p → -0.29%p 로 10배 이상 축소**했다(여전히 패배지만 방향성 입증). + +**다음 연구 방향: 턴오버 패널티를 reward 에 명시적으로 넣은 비용 인지형 RL 정책을 학습**한다. 평가 인프라(holdout walk-forward + leakage canary + 대시보드)는 이미 갖춰져 있으므로, 학습 정책을 `_fit_policy` seam 에 끼우면 즉시 같은 비교표로 검증 가능하다. + +--- + +## 1. 이 페이지의 caveat (읽기 전 필수) + +1. **"RL"은 아직 학습된 정책이 아니다.** 코드베이스에 학습된 RL 모델이 없다. walk-forward 의 `rl_baseline` 은 `_fit_policy` 가 no-op 인 결정론 대역(`portfolio_walk_forward.py:181-191` / `portfolio_train.py` 스모크 정책)으로, 매 스텝 매수 슬롯이 비면 매수하고 `step % 4 == 3` 마다 매도하는 **고빈도 정책**이다. 본 문서의 "RL" 수치는 이 대역의 거동이며 학습 정책의 상한/하한이 아니다. 이는 계획 §5 Page 10.5 caveat 와 동일하다. + +2. **holdout(out-of-sample) 평가.** Page 11 의 expanding-window holdout 을 사용한다 — fold N 은 segment `0..N` 으로 fit(대역은 no-op), **disjoint 하고 더 나중인** segment `N+1` 에서 평가. 따라서 Page 10.5 의 in-sample read 보다 보수적이다(같은 데이터에서 V0 가 -3.28% 로 Page 10.5 의 -4.0% 대비 fold 구간이 다름). + +3. **비용은 정직하게 명시.** 모든 비용/턴오버/거래수는 walk-forward fold report(`portfolio_walk_forward_folds.csv`)의 `total_cost`, `turnover`, `trade_count`, `cost_bps` 컬럼에서 직접 읽었다(Page 11 이 이 컬럼들을 노출). 정책을 좋게 보이게 가공하지 않았다. + +--- + +## 2. 사용한 candidate set (풀스캔 아님) + +`python -m stom_rl.candidate_gen` 로 동일 DB 윈도우(소수 종목·30분 발화 구간)에서 두 룰로 생성: + +| set | 룰 | candidate 수 | distinct ts | 종목 분포 | +|---|---|---:|---:|---| +| demand_pressure (V0/V1/V3) | `buy_demand_pressure.json` | **227** | 219 | 000250:168, 000100:59, 000150:0 | +| widev1 (V2) | `buy_widev1.json` | **25** | 25 | 000100:13, 000250:12 | + +공통 윈도우: DB `_database/stock_tick_back.db`, tables `000100,000150,000250`, session `20250709`, 09:00:00–10:00:00. (demand_pressure 227 set 은 Page 10.5 와 동일 — 비교 가능성 확보.) + +생성 커맨드: +```bash +# demand_pressure set (V0/V1/V3 입력) +py -3.11 -m stom_rl.candidate_gen \ + --db _database/stock_tick_back.db \ + --tables 000100,000150,000250 --session 20250709 \ + --time-start 090000 --time-end 100000 \ + --rules stom_rl/rules/buy_demand_pressure.json \ + --output .omx/artifacts/page14_opt/cand_demand_pressure.csv \ + --topk-report .omx/artifacts/page14_opt/topk_demand_pressure.json --top-k 3 + +# widev1 set (V2 입력, 더 엄격한 룰) +py -3.11 -m stom_rl.candidate_gen \ + --db _database/stock_tick_back.db \ + --tables 000100,000150,000250 --session 20250709 \ + --time-start 090000 --time-end 100000 \ + --rules stom_rl/rules/buy_widev1.json \ + --output .omx/artifacts/page14_opt/cand_widev1.csv \ + --topk-report .omx/artifacts/page14_opt/topk_widev1.json --top-k 3 +``` + +--- + +## 3. 변형(variation)과 정확한 커맨드 + +모든 변형은 **기존 walk-forward CLI 플래그만** 사용한다(새 학습 알고리즘·새 코드 없음). 식별된 회전 비용 문제를 직접 겨냥한다. + +| 변형 | 레버 | 커맨드 플래그 | +|---|---|---| +| **V0 baseline** | 없음(Page 10.5 재현, holdout) | `--top-k-candidates 3 --max-positions 2 --cost-bps 25.0` | +| **V1 fewer_pos** | 포지션 슬롯 축소(churn 억제 시도) | `--top-k-candidates 1 --max-positions 1 --cost-bps 25.0` | +| **V2 widev1** | 더 엄격한 룰(candidate 227→25) | (입력 CSV = widev1) `--top-k-candidates 3 --max-positions 2 --cost-bps 25.0` | +| **V3 cost sweep** | 비용 민감도(break-even 탐색) | `--cost-bps {0, 5, 10}` (+ V0 의 25) | + +공통: `--n-folds 3 --max-steps-per-fold 200 --seed 100`. 예: + +```bash +# V0 baseline (canonical, holdout) +py -3.11 -m stom_rl.portfolio_walk_forward \ + --candidate-csv .omx/artifacts/page14_opt/cand_demand_pressure.csv \ + --output-dir .omx/artifacts/page14_opt/v0_baseline \ + --n-folds 3 --max-steps-per-fold 200 --top-k-candidates 3 --max-positions 2 --cost-bps 25.0 --seed 100 + +# V1 fewer positions +py -3.11 -m stom_rl.portfolio_walk_forward \ + --candidate-csv .omx/artifacts/page14_opt/cand_demand_pressure.csv \ + --output-dir .omx/artifacts/page14_opt/v1_fewer_pos \ + --n-folds 3 --max-steps-per-fold 200 --top-k-candidates 1 --max-positions 1 --cost-bps 25.0 --seed 100 + +# V2 stricter rule (widev1 candidates) +py -3.11 -m stom_rl.portfolio_walk_forward \ + --candidate-csv .omx/artifacts/page14_opt/cand_widev1.csv \ + --output-dir .omx/artifacts/page14_opt/v2_widev1 \ + --n-folds 3 --max-steps-per-fold 200 --top-k-candidates 3 --max-positions 2 --cost-bps 25.0 --seed 100 + +# V3 cost sweep (break-even) +for C in 0.0 5.0 10.0; do + py -3.11 -m stom_rl.portfolio_walk_forward \ + --candidate-csv .omx/artifacts/page14_opt/cand_demand_pressure.csv \ + --output-dir .omx/artifacts/page14_opt/v3_cost${C%%.*} \ + --n-folds 3 --max-steps-per-fold 200 --top-k-candidates 3 --max-positions 2 --cost-bps $C --seed 100 +done +``` + +모든 6개 run 은 exit 0 으로 완주하고 실 fold 메트릭을 산출했다(`portfolio_walk_forward_folds.csv` per variation). + +--- + +## 4. 비용 반영 비교표 (fold 평균, `cost_bps=25.0` 기본) + +각 변형마다 `rl_baseline`(=대역 정책) 을 두 핵심 baseline(`equal_weight_candidate`, `no_trade`)과 비교. 수치는 3개 holdout fold 평균. + +### V0 — baseline (demand_pressure, top_k=3/max_pos=2, **cost=25 bps**) + +| 정책 | 평균 수익률%(비용 후) | 평균 턴오버 | 평균 거래수 | 평균 비용(KRW) | 평균 MDD% | +|---|---:|---:|---:|---:|---:| +| no_trade | **0.0000** | 0 | 0 | 0 | 0.00 | +| equal_weight_candidate | **-0.1132** | 499,895 | 2.0 | 1,250 | -0.17 | +| **rl_baseline (대역)** | **-3.2816** | 13,453,032 | 54.7 | 33,633 | -3.28 | + +→ rl 이 equal-weight 대비 **-3.17%p** 열세. 턴오버가 **27배**, 비용이 **27배**. + +### V1 — fewer positions (demand_pressure, top_k=1/max_pos=1, cost=25 bps) + +| 정책 | 평균 수익률%(비용 후) | 평균 턴오버 | 평균 거래수 | 평균 비용(KRW) | +|---|---:|---:|---:|---:| +| no_trade | **0.0000** | 0 | 0 | 0 | +| equal_weight_candidate | **-0.0266** | 250,000 | 1.0 | 625 | +| **rl_baseline (대역)** | **-3.2816** | 13,453,885 | 54.7 | 33,635 | + +→ **포지션 슬롯 축소는 효과 없음**. rl 거래수가 54.7 로 V0 와 동일 — 대역 정책은 슬롯 수와 무관하게 매 사이클 매수·매도하므로 회전이 줄지 않는다. **회전 문제는 포지션 한도가 아니라 정책의 거래 빈도에서 온다**는 음성(negative) 결과. (gap **-3.26%p**.) + +### V2 — 더 엄격한 룰 (widev1, candidate 227→25, cost=25 bps) — 가장 효과적 레버 + +| 정책 | 평균 수익률%(비용 후) | 평균 턴오버 | 평균 거래수 | 평균 비용(KRW) | +|---|---:|---:|---:|---:| +| no_trade | **0.0000** | 0 | 0 | 0 | +| equal_weight_candidate | **+0.0758** | 499,895 | 2.0 | 1,250 | +| **rl_baseline (대역)** | **-0.2103** | 1,500,405 | 6.0 | 3,751 | + +→ candidate 가 227→25 로 줄자 rl 거래수가 54.7→**6.0**, 비용이 33,633→**3,751 KRW**. gap 이 **-3.17%p → -0.29%p 로 10배 이상 축소**. 여전히 equal-weight 에 미달하지만 **거래 기회를 줄이는 것이 가장 직접적인 레버**임을 입증. + +### V3 — 비용 민감도 sweep (demand_pressure, top_k=3/max_pos=2) — break-even 분석 + +| cost_bps | rl_baseline 수익률% | equal_weight 수익률% | **gap (rl − ew)** | rl이 ew를 이기나 | +|---:|---:|---:|---:|:--:| +| **0.0** | +0.0824 | +0.0117 | **+0.0706** | ✅ Yes | +| 5.0 | -0.5993 | -0.0133 | -0.5860 | No | +| 10.0 | -1.2765 | -0.0383 | -1.2382 | No | +| **25.0** (canonical) | -3.2816 | -0.1132 | -3.1683 | No | + +→ **break-even 비용 ≈ 0.54 bps** (0 과 5 bps 사이 선형보간). 비용 0 에서만 대역 정책의 미세한 raw edge(+0.07%p)가 보이고, 현실적 비용(5 bps 이상) 전 구간에서 회전 비용이 그 edge 를 전부 소진한다. **알파가 없는 게 아니라(0 cost 에선 근소 우위) 그 알파가 회전 비용을 못 견딘다**는 결정적 증거. + +--- + +## 5. 판정 (게이트 = 루프 실행 + 문서화) + +| 질문 | 답 | +|---|---| +| 변형 중 하나라도 `cost_bps=25.0` 후 equal-weight 를 이기는가? | **아니오** (V0/V1/V2/V3-cost5/10/25 전부 패배) | +| 비용을 0 으로 두면? | rl 이 equal-weight 를 +0.07%p 로 근소하게 이김 (V3-cost0) | +| break-even 비용은? | **≈ 0.54 bps** — 현실적 비용 대비 매우 낮음 | +| 손실의 원인은? | **회전 비용** (alpha 부재 아님). 턴오버 27×, 비용 27× | +| 가장 효과적인 레버는? | **거래 기회 축소**(더 엄격한 룰). 포지션 슬롯 축소는 무효 | + +**트랙 B 완료 형태 = (b) 문서화된 성능 격차 + 다음 연구 방향** (계획 §0 트랙 B, §1 User Sign-off #2 에 부합). 알파를 짜내거나 정책을 좋게 보이게 가공하지 않았다. + +--- + +## 6. 다음 연구 방향 (구체적) + +1. **비용 인지형 RL 정책 학습 (최우선)**: reward 에 **턴오버/거래비용 패널티**를 명시적으로 추가해 정책이 "거래할 가치가 있을 때만" 거래하도록 학습시킨다. 현재 대역 정책의 break-even 0.54 bps 는 "매 사이클 churn" 때문이며, 패널티 항이 이를 직접 억제하는 학습 신호다. 평가 인프라(holdout walk-forward, leakage canary, 대시보드)는 이미 자리잡았으므로, 학습 정책을 `portfolio_walk_forward.py:_fit_policy` seam 에 끼우면 **같은 비교표로 즉시 재평가** 가능하다(holdout 기계는 그대로). + +2. **룰 단계 게이팅 결합**: V2 가 보여주듯 candidate 발화를 엄격하게 하면 회전이 급감한다. 학습 정책 + 엄격한 룰(또는 rank_score 임계값)을 결합하면 비용 인지형 reward 와 상호 보완. + +3. **데이터 폭 확대(Page 16 게이트)**: 본 read 는 단일 세션·2~3종목·30분 발화 구간이다. 신호 안정성은 더 많은 fold/세션에서 봐야 하며, full-universe 는 Page 16 별도 게이트로 추적한다(open-ended background 금지). + +4. **(보류) MaskablePPO**: 계획 §5 Page 10 의 invalid-action 비율 수치 게이트(>20% / 50k) 통과 전에는 도입하지 않는다(의존성 creep 회피). + +**대안 종착점(정직)**: 비용 인지형 reward 보강 후에도 holdout 에서 학습 정책이 equal-weight 를 못 이기면, 그 자체가 정당한 트랙 B 종착점("이 데이터/비용에서 이 candidate 셋은 저회전 equal-weight 가 강한 baseline")이며 추가 격차 문서화로 닫는다. + +--- + +## 7. 산출물 (커밋 안 함, `.omx/` gitignore) + +`.omx/artifacts/page14_opt/`: +- `cand_demand_pressure.csv`, `topk_demand_pressure.json` (227 candidate set) +- `cand_widev1.csv`, `topk_widev1.json` (25 candidate set) +- `v0_baseline/`, `v1_fewer_pos/`, `v2_widev1/`, `v3_cost00/`, `v3_cost05/`, `v3_cost10/` — 각 `portfolio_walk_forward_report.json` + `portfolio_walk_forward_folds.csv` +- `comparison.csv`, `comparison.json` — 전체 변형 fold 평균 + 변형별 verdict (집계는 fold CSV read-only 분석) + +--- + +## 8. 메모 (정직성) + +- **프로덕션 코드 변경 없음** — 기존 `candidate_gen` / `portfolio_walk_forward` CLI 플래그만 실행. 집계 스크립트는 fold CSV 를 읽어 평균/판정만 계산하는 read-only 분석(코드 수정 아님). +- 비용 가정(`cost_bps=25.0`)을 전면에 명시했고, 0 cost 에서의 근소 우위를 숨기지 않고 break-even(0.54 bps)으로 정량화했다. +- DB 풀스캔 없음(소수 종목·30분 윈도우). `eval/exec/__` 미사용. 숫자를 RL 에 유리하게 가공하지 않았다. diff --git a/docs/stom_rl_page16_full_universe_2026-05-26.md b/docs/stom_rl_page16_full_universe_2026-05-26.md new file mode 100644 index 000000000..afd87fd17 --- /dev/null +++ b/docs/stom_rl_page16_full_universe_2026-05-26.md @@ -0,0 +1,128 @@ +# Page 16 — Full-universe execution gate (checkpoint/resume runner) + +2026-05-26 · branch `feature/stom-rl-lab` · `stom_rl/full_universe.py` + +## What this is + +The **named finish line** for running the whole STOM tick DB end-to-end. For +every recording **session date** it builds the co-dated panel (Page 7.5) → +screens candidates with one rule (Page 9) → runs the expanding-window holdout +walk-forward (Page 11), writing per-session artifacts with checkpoint/resume so a +long run can be interrupted and resumed without redoing completed work. + +The full 2427-symbol / all-session run is **intentionally a separate long +background job** — this module is validated on a bounded slice in-session and the +real full run is launched as documented below. The runner itself never performs a +full DB scan: session enumeration is a per-table `SELECT DISTINCT +substr(index,1,8)` (the `index` column is `YYYYMMDDHHMMSS`), and each session's +panel read is bounded by a time window. + +## Why per session date + +Symbols in the DB have **disjoint recording dates** — arbitrary symbols cannot be +mixed into one panel. The only sound unit of work is a single **session date** +grouping the symbols that actually have data on that date (e.g. `000100`, +`000150`, `000250` share `20250709`). DB table names are the 6-digit zero-padded +codes and match candidate `symbol` after `stom_rl/symbol_norm` normalization. + +## Artifact layout (gitignored `.omx/`) + +``` +/ + _session_index.json # cached {session_date -> [symbols]} enumeration + _manifest.json # checkpoint state: per-session status + timestamps + _progress.log # append-only progress / stuck / failure log + _run_summary.json # last run's processed/skipped/failed lists + / + candidates.csv # Page 9 candidates (T+1 fill contract) + topk_report.json # per-timestamp top-K distribution + session_summary.json # counts + artifact paths + walk_forward/ + portfolio_walk_forward_report.json # holdout fold report + portfolio_walk_forward_folds.csv +``` + +### Manifest schema (`_manifest.json`) + +Top level: `rule`, `output_dir`, `created_at`, `updated_at`, `entries`. Each +entry (keyed by session date) carries `status` +(`pending`/`running`/`done`/`failed`), `symbol_count`, `candidate_count`, +`fold_count`, `panel_rows`, `started_at`, `finished_at`, `elapsed_seconds`, +`error`. On `--resume`, sessions with `status == "done"` are skipped. + +## Resume usage + +`--resume` skips any session already `done` in the manifest. A failed session is +recorded `failed` with its error (never silently lost) and the run continues with +the next session; rerun (with or without `--resume`) re-attempts non-`done` +sessions. Stuck detection: `flag_stuck_sessions()` returns any `running` entry +whose `started_at` is older than `--stuck-seconds` (default 1800s); a monitor can +poll the manifest and alert without touching the DB. + +## Bounded validation (already run) + +```bash +py -3.11 -m stom_rl.full_universe \ + --db _database/stock_tick_back.db \ + --rule stom_rl/rules/buy_demand_pressure.json \ + --output-dir .omx/artifacts/page16_full \ + --sessions 20250709 --max-symbols-per-session 3 --enum-max-tables 60 +# -> session 20250709: 3 symbols, panel 5397 rows, 227 candidates, 2 folds. + +# Resume demo (20250709 done, add 20251217): +py -3.11 -m stom_rl.full_universe \ + --db _database/stock_tick_back.db \ + --rule stom_rl/rules/buy_demand_pressure.json \ + --output-dir .omx/artifacts/page16_full \ + --sessions 20250709,20251217 --max-symbols-per-session 3 --enum-max-tables 60 --resume +# -> skipped: [20250709], processed: [20251217] +# _progress.log: "SKIP session=20250709 status=done" +``` + +## Launching the REAL full run (long background job) + +The full run enumerates all 2427 tables (~100s one-time, cached to +`_session_index.json`) and then processes every session date. This is **hours+** +and must run as a detached background job, not in an interactive session. + +```bash +# From repo root (D:\Chanil_Park\Project\Programming\Kronos), bash: +nohup py -3.11 -m stom_rl.full_universe \ + --db _database/stock_tick_back.db \ + --rule stom_rl/rules/buy_demand_pressure.json \ + --output-dir .omx/artifacts/page16_full \ + --resume \ + > .omx/artifacts/page16_full/_run.out 2>&1 & +``` + +Notes / caveats: + +- **Runtime**: session enumeration ~100s (cached after first run); then one + pipeline pass per session date. Wall-clock is hours+ for the full universe — + treat it as a long background job and monitor `_progress.log`. +- **Resume**: always launch with `--resume`. If interrupted (Ctrl-C, crash, + reboot), rerun the identical command — `done` sessions are skipped and the run + picks up where it stopped. +- **Bounding for a partial run**: `--max-sessions N` (first N dates), + `--max-symbols-per-session M` (cap symbols per session), + `--max-rows-per-group R` (cap rows per symbol window), + `--time-start/--time-end` (intraday window). Use these to gate scale before the + unbounded full run. +- **Memory**: the per-session panel read asserts the Page 7.5 memory budget when + `--max-rows-per-group` is set; supply a positive bound before very wide + sessions. +- **Monitoring stuck sessions**: poll `_manifest.json` and call + `flag_stuck_sessions()` (or watch `_progress.log` for `SESSION_START` without a + matching `SESSION_DONE`); `--stuck-seconds` sets the budget. +- **Artifacts land under** `--output-dir` (here `.omx/artifacts/page16_full`, + gitignored). + +## Reuse (no duplicated pipeline logic) + +| Stage | Reused module | +|---|---| +| session panel (as-of join) | `stom_rl.panel_join.build_panel_from_db` | +| candidates (T+1 fill) | `stom_rl.candidate_gen.generate_candidates` | +| holdout walk-forward | `stom_rl.portfolio_walk_forward.run_portfolio_walk_forward` | +| symbol/table key | `stom_rl.symbol_norm` (DB table name == padded code) | +| DB access | `finetune_csv.stom_tick_dataset.connect_readonly` (read-only) | diff --git a/docs/stom_rl_page_c0_feature_probe_2026-05-27.md b/docs/stom_rl_page_c0_feature_probe_2026-05-27.md new file mode 100644 index 000000000..6ece118bc --- /dev/null +++ b/docs/stom_rl_page_c0_feature_probe_2026-05-27.md @@ -0,0 +1,147 @@ +# Stage C-0 — DB Feature Feasibility Probe (2026-05-27) + +Plan: `.omx/plans/ralplan-stom-rl-deep-rl-2026-05-27.md` (§ Stage C / Stage C-0, V10, P1-5a) +Repo: `Kronos` · Branch: `feature/stom-rl-lab` · Baseline: `fca9e85` · Python 3.11 · Windows +DB: `_database/stock_tick_back.db` (29.7 GB, 2427 symbol tables) +Probe artifact (NOT committed): `.omx/artifacts/deep_rl/stageC_export/c0_probe.json` +Probe script: `finetune/stom_rl_c0_feature_probe.py` (read-only, bounded sampling) + +--- + +## Feasibility Verdict (conclusion first) + +**Stage C CAN meaningfully expand signal. All four candidate high-signal source +columns EXIST and are densely populated (≥99.8% non-null in every sampled +session). Stage B should NOT train on the current 3-symbol / current-feature +data — it should WAIT for the minimal feature expansion**, because the columns +the rules currently drop (`등락율`, `시가총액`, `고저평균대비등락율`) are real, +direct, and cheap to add. The cost-aware experiment found alpha to be +signal-limited; this probe shows richer point-in-time signal is genuinely +available in the DB, so the expansion is worth doing before any alpha claim. + +**Add now (no fallback needed):** `change_rate`, `market_cap`, +`high_low_mid_change_rate`, `trade_strength_avg_n`. +**Already canonical, keep as-is:** `turnover_rate` (fully present; see nuance below). + +--- + +## Method (bounded, no full scan) + +- Reused read-only helpers: `connect_readonly` (`mode=ro`, `query_only=ON`), + `list_stock_tables`, `_decode_table_columns`. +- **Sample:** 8 symbol tables × up to 3 distinct sessions each = **20 sessions**, + each bounded to ≤3000 rows in the morning window (`ORDER BY index LIMIT 3000`). +- Symbol discovery bounded to a 30-table scan; all 30 carried the new columns, + so the first 8 (`000020, 000040, 000050, 000060, 000070, 000080, 000100, 000120`) + were used. Sessions span 2022–2025 (disjoint per symbol, grouped by + `substr(CAST("index" AS TEXT),1,8)`). +- Coverage basis: **non-null %** for value columns; **non-zero %** additionally + reported for columns where a literal 0 could mean "not recorded" + (`시가총액`, `회전율`, `체결강도`). +- Threshold (plan V10): a feature with **<80% effective coverage in ANY session + ⇒ "fallback-or-drop"**; ≥80% everywhere ⇒ "add". + +### Encoding note (resolves the "mojibake" flag) +Column names are **valid UTF-8** in the DB. Raw bytes e.g. +`b'\xeb\x93\xb1\xeb\x9d\xbd\xec\x9c\xa8'` decode via UTF-8 to `등락율` (and FAIL +under cp949). The `�` seen in a Windows console is a **display-only** codepage +artifact; `_decode_table_columns` returns correct Korean Unicode strings and +Python string matching against the target names works. Confirmed empirically. + +Full decoded columns of `000020` (54 cols) include, in order: +`index, 현재가, 시가, 고가, 저가, 등락율, 당일거래대금, 체결강도, 초당매수수량, +초당매도수량, 거래대금증감, 전일비, 회전율, 전일동시간비, 시가총액, …, +고저평균대비등락율, 저가대비고가등락율, …`. + +--- + +## Per-feature results + +Coverage = worst (minimum) across all 20 sampled sessions. "basis" is the +metric the threshold was applied to. + +| Target feature | DB source column | Exists? | Worst-session coverage | basis | Value range (sampled) | Verdict | +|---|---|---|---|---|---|---| +| `change_rate` | `등락율` | YES | **100.0%** | non-null | −8.30 … 29.97 (%) | **add** | +| `market_cap` | `시가총액` | YES | **100.0%** | non-zero | 417 … 62,353 (units, all >0) | **add** | +| `high_low_mid_change_rate` | `고저평균대비등락율` | YES | **100.0%** | non-null | −12.72 … 7.27 (%) | **add** | +| `trade_strength_avg_n` | `체결강도` | YES | **99.84%** | non-zero | 0.0 … 500.0 | **add** | +| `turnover_rate` | `회전율` | YES | 100.0% non-null / 63.5% non-zero | non-zero | 0.0 … 19.02 (%) | **keep (already canonical)** | +| `high` (fallback source) | `고가` | YES | 100.0% | non-null | 456 … 127,500 | n/a (already source) | +| `low` (fallback source) | `저가` | YES | 100.0% | non-null | 433 … 121,000 | n/a (already source) | +| `close` (fallback source) | `현재가` | YES | 100.0% | non-null | 434 … 127,500 | n/a (already source) | + +Worst sessions, for the record: +- `등락율`, `시가총액`, `고저평균대비등락율`, OHLC: 100% in all 20 sessions. +- `체결강도`: worst = `000060 / 20221108` at 99.84% non-zero (1 zero-strength bar + in a 625-row thin session). +- `회전율`: worst = `000120 / 20220504` at **63.5% non-zero but 100% non-null**. + +--- + +## `turnover_rate` (회전율) — the only sub-80% number, and why it is NOT a gap + +`회전율` is **100% non-null in EVERY sampled session** — the column is fully +recorded. The 63.5% figure is *non-zero* coverage in one early, low-liquidity +592-row morning session (`000120 / 20220504`): the zeros there are **genuine +low/zero-turnover bars, not missing data**. Across the other 19 sessions +non-zero coverage is 0.988–1.000. So under the plan's literal rule +("non-zero where 0 means missing") turnover *trips* the flag, but the honest +data reading is that the column is fully populated and the zeros carry real +information. `turnover_rate` is **already canonical** (`STOM_RL_CANONICAL_FEATURES`, +`finetune/qlib_stom_pipeline.py:68`) and should be kept as-is — no fallback, +no drop. No new column needs to be added for it. + +--- + +## Value sanity + +- `등락율` (change_rate): −8.30% … +29.97% — plausible intraday %; +29.97 ≈ the + +30% daily upper limit. PASS. +- `시가총액` (market_cap): 417 … 62,353, all strictly positive. PASS. +- `고저평균대비등락율` (high_low_mid): −12.72% … +7.27%, centered near 0 — matches + a `(close − (high+low)/2) / ((high+low)/2)` deviation measure. PASS. (Direct DB + column exists, so the computed-from-OHLC fallback in the plan is unnecessary.) +- `체결강도` (trade_strength): 0.0 … 500.0 — exactly the documented [0, 500] band. PASS. +- `회전율` (turnover): 0.0 … 19.02% — plausible. PASS. +- OHLC `고가`/`저가`/`현재가`: positive, high ≥ low ordering consistent. PASS. + +--- + +## Stage-C wiring implication (for the implementer, not done here) + +The candidates map cleanly onto direct DB columns — **no fallback formulas are +needed for any "add" feature** (the plan's trailing-return fallback for `등락율` +and the OHLC-computed fallback for `고저평균대비등락율` are confirmed +unnecessary; direct columns exist and are dense): + +| New canonical feature | Direct DB source | Causality | +|---|---|---| +| `change_rate` | `등락율` | point-in-time (same-bar % change), no forward | +| `market_cap` | `시가총액` | slowly-varying, numeric-fill-0 acceptable, no forward | +| `high_low_mid_change_rate` | `고저평균대비등락율` | same-bar derived, no forward | +| `trade_strength_avg_n` | `체결강도` (already a source) | **trailing N-bar mean, window ≤ T only (no look-ahead)** | + +Only `trade_strength_avg_n` requires a derived computation; it MUST be a trailing +(causal) rolling mean over bars ≤ current bar, per the plan's leakage guard +(V11 / per-feature causality test in Stage C). The three direct columns +(`등락율`, `시가총액`, `고저평균대비등락율`) need to be added to +`STOM_RL_SOURCE_COLUMNS` (`finetune/qlib_stom_pipeline.py:208-223`), then mapped +in `STOM_RL_FEATURE_MAPPING` and appended to `STOM_RL_CANONICAL_FEATURES`. + +--- + +## Gate decision + +- **V10 (C-0 probe) — PASS.** Every candidate has a verified source with + per-session coverage reported; no feature relies on an unverified assumption. +- **P1-5a (Stage B data choice) — DECISION: WAIT for minimal expansion.** Add the + three direct columns + the trailing `trade_strength_avg_n` to `export-stom-rl`, + then train Stage B on the expanded feature set. The signal the rules currently + drop is real and cheap; training on current features would knowingly leave + available signal on the table given the established signal-limited alpha. + +## Constraints honored +- Read-only (`mode=ro`, `query_only=ON`); bounded sampling only (8 symbols × ≤3 + sessions × ≤3000 rows); no full-DB scan; no `eval/exec/__`; causal-only framing + for the one derived feature. diff --git a/docs/stom_rl_paper_replay_2026-05-29.md b/docs/stom_rl_paper_replay_2026-05-29.md new file mode 100644 index 000000000..f987713d3 --- /dev/null +++ b/docs/stom_rl_paper_replay_2026-05-29.md @@ -0,0 +1,92 @@ +# Page D — 동결정책 paper replay (forward proxy) + +- 작성일: **2026-05-29 KST** +- 브랜치: `feature/stom-rl-lab` +- 상위 앵커: `docs/stom_rl_session_progress_2026-05-29.md`, `docs/stom_rl_liquidity_slippage_2026-05-29.md`(Page C) +- 구현: `stom_rl/paper_sim.py`(순수함수+CLI) / 테스트 `tests/test_stom_rl_paper_sim.py`(9개) / 산출물 `.omx/artifacts/paper_sim/summary*.json` +- 대상: **시초 갭상승 `ts_imb` 룰 + Page A 사이징 — RULE strategy, NOT reinforcement learning.** + +> 이 환경엔 **실시간 피드가 없어 진짜 forward/paper는 불가능**하다. 본 페이지는 동결된 전체 정책(룰+사이징)을 과거 DB의 최근 구간에 **그대로 재생(replay)**한 *근사*다. Page A 사이징(지금까지 정적 공식)이 실제 거래 *시퀀스*에 처음 적용된다. + +--- + +## 0. 한 줄 결론 + +**동결 정책이 끝까지 정상 작동하고, full·최근holdout 모두·낙관/비관 체결 모두에서 계좌가 양수이며 낙폭이 Page A 봉투(약 −2~−3%) 안에 머문다. 단 헤드라인 수익률(+396~612% full)은 복리×idealized의 낙관적 replay 수치이지 미래 기대치가 아니다.** 가장 큰 실질 성과는 **이 replay가 정책의 deadlock 결함을 드러내고 고쳤다는 것**이다. + +--- + +## 1. ⚠️ replay가 드러낸 정책 결함 (Page D의 진짜 가치) + +처음 replay에서 FULL이 5175 신호 중 **73개만 taken** + halt로 2610개 skip → 비정상. 원인: +- Page A의 "**7연패 → f=0 (진입 중단)**" 룰을 문자 그대로 적용하면, **중단 중엔 거래가 없어 승리로 streak를 리셋할 수 없다 → 계좌가 영구 동결(deadlock).** +- 수정: 7연패 도달을 **서킷브레이커**로 — 그날 쉬고 **streak 리셋(쿨다운)** → 다음날 재개. (표준 circuit-breaker 설계; `paper_sim`에 국한, Page A 티어/테스트 불변.) +- 수정 후: 정상(아래). **테스트로 deadlock 없음을 고정**(`test_streak_halt_is_a_recoverable_circuit_breaker_not_a_deadlock`). + +이것이 "사이징을 실제 시퀀스에 적용"하는 Page D의 핵심 효용 — 정적 공식만으론 안 보이던 운영 결함 발견. + +--- + +## 2. replay 결과 (체결 양 끝, 복리, 초기 1억) + +| 구간 | 체결 | 수익률 | maxDD | taken/signals | +|---|---|---:|---:|---:| +| FULL (2022-03~2026-02) | idealized(낙관) | +612.0% | −1.8% | 2606/5175 | +| FULL | **sl_gap_stress(최악)** | **+396.4%** | **−3.1%** | 2606/5175 | +| HOLDOUT (2025-09~2026-02) | idealized | +45.4% | −0.7% | 325/760 | +| HOLDOUT | **sl_gap_stress(최악)** | **+39.3%** | **−0.9%** | 325/760 | + +운영 통계: skip(cap/halt) full 2492/77, holdout 429/6. **일손실한도 발동 0일**(예상대로 −3%는 거의 안 닿음). taken은 신호의 ~50%(주로 K=3 동시보유 cap 때문, halt는 드묾). + +해석(정직): +- **두 체결 가정 모두, 두 구간 모두 양수 + 한 자릿수% 낙폭** → 정책이 안전하게 작동하고 fill 가정에 robust. +- 최악체결도 full +396%/holdout +39% → 양수 유지. + +--- + +## 3. ⚠️ 헤드라인 수익률을 곧이곧대로 읽으면 안 되는 이유 + +1. **복리 × 수천 거래**: 2606 거래 복리는 작은 per-trade 엣지도 큰 헤드라인으로 부풀린다. +612%를 "6배 번다"로 읽으면 안 됨. +2. **replay ≠ live**: 같은 과거 DB 재생이지 미래/실시간 검증이 아니다. **과거 곡선 ≠ 미래.** +3. **triggered-subset 편향**: DB는 특정 조건 걸린 세션만 기록 → 전체 기회집합 아님. 실제 신호 가용성/체결은 다를 수 있음. +4. **체결 frictions 미반영**: idealized/stress는 TP/SL 체결 모델일 뿐, 지연·부분체결·실주문 거부 등은 미모델(데이터 한계). Page C 슬리피지(<3bp)는 작지만 별도. +5. **순서/동시성 근사**: 동시 진입(09:00)을 신호강도순 처리로 근사. + +→ 따라서 본 결과의 의미는 **"정책이 안전하게·일관되게 굴러간다"는 질적 확인**이지, **수익률 예측이 아니다.** + +--- + +## 4. 재현 명령 + +```powershell +$env:PYTHONIOENCODING='utf-8' +# 낙관(idealized) replay +py -3.11 -X utf8 -m stom_rl.paper_sim --holdout-start 20250901 +# 최악(stress) replay +py -3.11 -X utf8 -m stom_rl.paper_sim --instances .omx/artifacts/gap_up_full_stress/instances.json --holdout-start 20250901 --json-out .omx/artifacts/paper_sim/summary_stress.json +# 테스트 +py -3.11 -m pytest tests/test_stom_rl_paper_sim.py -q # 9 passed +``` + +--- + +## 5. 정직성 캐비엇 (유지) + +- 실시간 피드 없음 → 진짜 forward 아님. 본 페이지는 **replay 근사**. +- 여전히 triggered-subset DB, idealized/stress 체결모델, 복리 헤드라인. +- 서킷브레이커 리셋은 합리적 표준 설계지만 "쉬고 즉시 풀사이즈 복귀"라 다소 관대 — 더 보수적으로 "복귀 시 probe 사이즈" 변형 가능(후속). + +--- + +## 6. 페이지 트랙 갱신 + 다음 + +| 페이지 | 상태 | +|---|---| +| A 사이징 / R0·R1·R1b / B full universe / C 유동성·꼬리 | ✅ 완료 | +| **D 동결정책 replay (forward proxy)** | ✅ **완료** — 정책 정상·저낙폭, deadlock 결함 발견·수정 | +| **진짜 forward/paper (실시간 피드)** | ⬜ **다음 — 환경 밖**(실시간 데이터 연동 필요) | +| E broker/order 연동 | 🔒 명시 승인 전 금지 | + +**위치**: 백테스트·검증·운영정책 replay까지 완료. 남은 건 **실시간 피드를 붙인 진짜 forward 관찰**(이 환경 밖) → 그 후에만 E(실주문) 검토. + +검증: paper_sim 테스트 9 passed(복구/deadlock 방지 포함). code-reviewer 검증 예정. diff --git a/docs/stom_rl_performance_goal_pages_2026-05-23.md b/docs/stom_rl_performance_goal_pages_2026-05-23.md new file mode 100644 index 000000000..c793c72d3 --- /dev/null +++ b/docs/stom_rl_performance_goal_pages_2026-05-23.md @@ -0,0 +1,547 @@ +# STOM 강화학습 성과 모델 고도화 페이지 계획 + +작성일: 2026-05-23 KST +브랜치: `feature/stom-rl-lab` +OMX 계획: `.omx/ultragoal/goals.json` +목표: **STOM tick/back data로 smoke가 아닌 full test split 기준 강화학습 성과를 검증하고, 실제 사용 가능한 모델 후보와 대시보드 비교 체계를 만든다.** + +--- + +## 1. 이번 단계의 핵심 목적 + +이전 9페이지 작업은 “강화학습 실험실이 가능한가?”를 증명했다. 이번 단계는 한 단계 더 나아가 “실제로 쓸 만한 모델인가?”를 검증한다. + +| 구분 | 이전 단계 | 이번 단계 | +|---|---|---| +| 목적 | RL 실험실 구축 | 수익성 검증과 모델 고도화 | +| 데이터 | STOM 2025 1초봉 episode manifest | 동일 데이터의 full test split 중심 | +| 모델 | contextual bandit smoke | contextual bandit full 평가 + DQN/PPO 확장 준비 | +| 검증 | 동작 확인, smoke cost gate | 전체 test split, baseline 대비, 비용 반영, drawdown | +| 대시보드 | run 단위 결과 확인 | 모델별 leaderboard와 smoke/full 구분 | +| 자동매매 판단 | 보류 | 지표 기반 사용/보류 판단 | + +--- + +## 2. 오픈소스 참고 원칙 + +새 dependency는 바로 추가하지 않는다. 먼저 현재 STOM 전용 구현을 full 검증으로 확장하고, 필요한 경우에만 별도 단계에서 dependency를 검토한다. + +| 참고 자료 | 활용 방식 | 현재 적용 방향 | +|---|---|---| +| Gymnasium | `reset/step` 환경 인터페이스 표준 | 기존 `StomTickTradingEnv` 계약 유지 | +| Stable-Baselines3 | DQN/PPO 등 표준 RL 알고리즘 후보 | P006에서 dependency 필요성 판단 | +| FinRL | 금융 RL에서 data split, transaction cost, risk metric 중요 | baseline/cost gate/rolling validation 강화 | +| RLTrader | 주식 매매 action, reward, portfolio 평가 아이디어 | action/reward 확장 후보로 참고 | +| 기존 STOM/Kronos 결과 | 이전 예측·파인튜닝 실험의 실패/한계 | 비교 baseline으로만 사용, Kronos 의존은 제거 | + +참고 링크: + +- Gymnasium: https://gymnasium.farama.org/ +- Stable-Baselines3: https://stable-baselines3.readthedocs.io/ +- FinRL: https://github.com/AI4Finance-Foundation/FinRL +- RLTrader: https://github.com/quantylab/rltrader + +--- + +## 3. 현재 데이터 기준 + +현재 강화학습의 기준 데이터는 이미 생성된 STOM 2025 1초봉 episode manifest다. + +| 항목 | 값 | +|---|---:| +| 전체 episode | 18,750 | +| 종목 수 | 1,638 | +| 거래일/session | 240 | +| train episode | 13,256 | +| val episode | 2,764 | +| test episode | 2,730 | +| 원본 export row | 33,360,325 | +| 시간 범위 | 09:00~09:30 | +| lookback | 300초 | +| reward horizon | 300초 | +| split overlap | 0 | +| DB 접근 | read-only | + +--- + +## 4. 성공/실패 판정 기준 + +이번 단계는 “모델이 좋아 보인다”가 아니라, 아래 기준을 통과해야 성공으로 본다. + +| 판정 항목 | 성공 기준 | +|---|---| +| baseline 대비 | no-trade/random/buy-and-hold/momentum 중 핵심 baseline보다 비용 후 우위 | +| 비용 반영 | 25bp 이상 비용에서 평균 episode net return 양수 | +| 거래 안정성 | trade count가 0에 가깝거나 과도하지 않음 | +| 리스크 | max drawdown이 허용 범위 이내 | +| 일반화 | train이 아니라 test split에서 성과 확인 | +| 반복성 | rolling fold에서 positive fold rate 기준 충족 | +| 대시보드 | smoke/full run이 명확히 구분되고 leaderboard에 표시 | + +실패 기준도 명확히 둔다. + +| 실패 유형 | 해석 | +|---|---| +| buy-and-hold보다 낮음 | 모델 사용 보류 | +| 비용 전에는 양수, 비용 후 음수 | 실거래 부적합 | +| 특정 일자/종목에만 편중 | 과최적화 의심 | +| 거래가 너무 많음 | 수수료/슬리피지에 취약 | +| 거래가 거의 없음 | 모델이 실질적으로 no-trade와 유사 | + +--- + +## 5. OMX 페이지 계획 + +| 페이지 | 이름 | 목표 | 완료 기준 | 상태 | +|---:|---|---|---|---| +| 1 | 성과 기준 재정의 | smoke/full 구분, 성공 기준, 오픈소스 참고 원칙 문서화 | 본 문서 작성, 검증, 커밋 | 완료 | +| 2 | full baseline/cost gate | test split 전체 baseline과 비용 관문 실행 | full artifact 생성, 요약 수치 확보 | 완료 | +| 3 | contextual bandit full eval | train 기반 모델을 test split 대규모로 평가 | baseline 대비 성과 산출 | 완료 | +| 4 | leaderboard artifact | baseline/RL/cost gate 결과 통합 | JSON/CSV leaderboard 생성 | 완료 | +| 5 | dashboard leaderboard | 웹에서 smoke/full 및 모델별 성과 비교 | API/프론트 build 검증 통과 | 완료 | +| 6 | DQN/PPO 확장 설계 | SB3/Gymnasium 확장 여부 판단 | dependency/리스크/구현안 문서화 | 완료 | +| 7 | 최종 리뷰 | QA, code review, 사용/보류 판단 | 최종 보고서와 QA 증거 확보 | 완료 | + +현재 진행률: **7 / 7 = 100%** + +`███████ 100%` + +--- + +## 6. 바로 다음 실행 후보 + +페이지 7까지 완료되어 전체 QA와 최종 사용/보류 판단이 문서화되었다. 이번 7페이지 목표는 종료한다. + +권장 명령: + +```powershell +omx ultragoal complete-goals +``` + +현재 Codex thread에는 이전 완료 goal이 남아 있어 OMX ultragoal ledger의 `G001-p001` 완료 reconciliation은 blocked 상태로 기록된다. 실제 작업은 이 브랜치의 한국어 Lore 커밋으로 계속 진행한다. + +완료 증거: + +1. Python 전체 회귀 테스트: `94 passed, 2 skipped` +2. 프론트 빌드: `svelte-check 0 errors`, `vite build completed` +3. Flask API smoke: `/`, `/api/rl/runs`, performance leaderboard detail/table, `/api/training/status` 모두 200 +4. 최종 보고서: `docs/stom_rl_final_review_2026-05-23.md` + +주의: 현재 플랫폼은 연구/검증용으로 사용할 수 있지만, contextual bandit 모델은 buy-and-hold와 25bp cost gate를 이기지 못해 실거래 후보가 아니다. + +--- + +## 7. 현재 판단 + +현재 강화학습은 **가능**하다. 그러나 현재 성과 모델은 **아직 실거래 사용 가능 모델로 확정되지 않았다.** + +이번 새 goal의 핵심은 다음이다. + +1. 전체 test split에서 기준선을 먼저 고정한다. +2. RL 모델이 그 기준선을 이기는지 본다. +3. 비용과 drawdown을 반영한다. +4. 웹 대시보드에서 모델별로 비교한다. +5. 성과가 부족하면 “왜 부족한지”를 지표로 문서화한다. + +--- + +## 8. 페이지 2 완료 기록: full baseline/cost gate + +### 8.1 진행 내용 + +처음에는 기존 dense baseline runner를 full test split에 직접 실행했다. + +```powershell +C:\Python\64\Python3119\python.exe -m stom_rl.baselines ` + --manifest webui\rl_runs\stom_1s_2025_episode_manifest\episode_manifest.json ` + --output-dir webui\rl_runs\stom_1s_2025_baselines_full_test ` + --split test ` + --max-episodes 0 ` + --cost-bps 25 ` + --slippage-bps 0 +``` + +하지만 이 runner는 action/equity row를 전부 저장하는 구조라 full test split에서는 30분 이상 지나도 완료되지 않았다. 이는 모델 문제가 아니라 **대규모 검증용 runner 구조 문제**다. + +따라서 새 의존성 없이 `stom_rl.leaderboard`를 추가했다. 이 runner는 같은 long-only policy 의미를 유지하되, full test split에서는 summary artifact를 우선 생성한다. + +| 파일 | 목적 | +|---|---| +| `stom_rl/leaderboard.py` | full test split 요약 전용 baseline/cost leaderboard | +| `tests/test_stom_rl_leaderboard.py` | compact leaderboard 회귀 테스트 | +| `webui/rl_runs/stom_1s_2025_baseline_leaderboard_full_test/*` | 실행 결과 artifact, gitignore 대상 | + +### 8.2 full test 실행 명령 + +```powershell +C:\Python\64\Python3119\python.exe -m stom_rl.leaderboard ` + --manifest webui\rl_runs\stom_1s_2025_episode_manifest\episode_manifest.json ` + --output-dir webui\rl_runs\stom_1s_2025_baseline_leaderboard_full_test ` + --split test ` + --max-episodes 0 ` + --cost-bps-values 5,10,15,25 ` + --slippage-bps-values 0 ` + --target-cost-bps 25 ` + --sample-trade-limit 1000 +``` + +실행 시간: **약 9분 18초** +대상: **test split 전체 2,730 episodes** +scenario: **6 policies × 4 cost levels = 24 rows** + +### 8.3 25bp 기준 결과 + +| 순위 | policy | 평균 episode net % | 거래 수 | 거래/episode | hit rate | MDD % | positive session rate | +|---:|---|---:|---:|---:|---:|---:|---:| +| 1 | buy_and_hold | 0.5126 | 2,730 | 1.00 | 0.4934 | -50.7280 | 0.8611 | +| 2 | no_trade | 0.0000 | 0 | 0.00 | 0.0000 | 0.0000 | 0.0000 | +| 3 | mean_reversion | -23.2925 | 170,868 | 62.59 | 0.0287 | -47.7542 | 0.0000 | +| 4 | volume_filter | -26.1600 | 167,923 | 61.51 | 0.0178 | -49.7977 | 0.0000 | +| 5 | momentum | -27.9136 | 164,944 | 60.42 | 0.0337 | -62.5398 | 0.0000 | +| 6 | random | -77.0568 | 806,047 | 295.26 | 0.0107 | -81.8887 | 0.0000 | + +### 8.4 해석 + +현재 full test split 기준으로는 **buy-and-hold가 가장 강한 baseline**이다. +momentum, mean_reversion, volume_filter는 거래 횟수가 너무 많고 25bp 비용에서 크게 무너졌다. +따라서 다음 RL 모델은 단순히 양수 수익을 내는 것이 아니라, **25bp 비용 후 buy-and-hold를 이겨야 한다.** + +주의할 점: + +- buy-and-hold의 평균 episode net은 양수지만 MDD가 크다. +- no-trade는 수익은 없지만 MDD가 0이라 리스크 기준 baseline으로 중요하다. +- 다음 contextual bandit full eval은 최소한 no-trade보다 낫고, 가능하면 buy-and-hold 대비 우위를 보여야 한다. + +### 8.5 검증 + +```powershell +C:\Python\64\Python3119\python.exe -m pytest ` + tests\test_stom_rl_leaderboard.py ` + tests\test_stom_rl_baselines.py ` + tests\test_stom_rl_cost_gate.py -q +``` + +결과: + +```text +6 passed +``` + +다음 페이지는 **페이지 3: contextual bandit full eval**이다. + +--- + +## 9. 페이지 3 완료 기록: contextual bandit full eval + +### 9.1 진행 내용 + +페이지 3에서는 1차 강화학습 모델인 contextual bandit을 smoke가 아닌 full test split으로 평가했다. + +실행 명령: + +```powershell +C:\Python\64\Python3119\python.exe -m stom_rl.contextual_bandit ` + --manifest webui\rl_runs\stom_1s_2025_episode_manifest\episode_manifest.json ` + --output-dir webui\rl_runs\stom_1s_2025_contextual_bandit_full_test ` + --train-split train ` + --eval-split test ` + --max-train-episodes 0 ` + --max-eval-episodes 0 ` + --train-sample-stride 10 ` + --eval-sample-stride 5 ` + --cost-bps 25 ` + --slippage-bps 0 +``` + +실행 시간: **약 35분 7초** + +### 9.2 학습 데이터 규모 + +| 항목 | 값 | +|---|---:| +| train episode | 13,256 | +| train sample | 1,568,450 | +| skipped episode | 0 | +| target mean % | -0.3280 | +| target median % | -0.4994 | +| target positive rate | 30.84% | +| ridge alpha | 1.0 | +| train RMSE % | 1.6746 | +| predicted positive rate | 1.45% | + +해석: + +- train target 자체가 평균/중앙값 모두 음수다. +- 25bp 비용 기준에서 양수 target 비율이 30.84%에 불과하다. +- 모델은 보수적으로 1.45% 구간만 매수 후보로 판단했다. + +### 9.3 full test 평가 결과 + +| 항목 | contextual bandit | +|---|---:| +| eval split | test | +| episode | 2,730 | +| action count | 591,472 | +| trade count | 971 | +| trades/episode | 0.356 | +| avg episode net % | 0.1254 | +| median episode net % | 0.0000 | +| compounded return % | 1,410.0169 | +| avg trade net % | 0.3539 | +| hit rate | 47.99% | +| max drawdown % | -51.5892 | +| 25bp cost gate | false | + +### 9.4 baseline 대비 + +25bp 비용 기준 full test split에서 주요 결과는 다음과 같다. + +| 모델/정책 | 평균 episode net % | 거래 수 | 거래/episode | hit rate | MDD % | 판단 | +|---|---:|---:|---:|---:|---:|---| +| buy_and_hold | 0.5126 | 2,730 | 1.000 | 49.34% | -50.7280 | 현재 최강 baseline | +| contextual_bandit | 0.1254 | 971 | 0.356 | 47.99% | -51.5892 | no-trade보다 좋지만 buy-and-hold 미달 | +| no_trade | 0.0000 | 0 | 0.000 | 0.00% | 0.0000 | 리스크 기준선 | +| mean_reversion | -23.2925 | 170,868 | 62.589 | 2.87% | -47.7542 | 부적합 | +| volume_filter | -26.1600 | 167,923 | 61.510 | 1.78% | -49.7977 | 부적합 | +| momentum | -27.9136 | 164,944 | 60.419 | 3.37% | -62.5398 | 부적합 | +| random | -77.0568 | 806,047 | 295.255 | 1.07% | -81.8887 | 부적합 | + +### 9.5 결론 + +contextual bandit full eval은 **학습과 평가가 정상 완료**되었다. 그러나 실사용 후보로는 아직 부족하다. + +| 질문 | 답 | +|---|---| +| no-trade보다 좋은가? | 예 | +| 과도한 단순 매매 전략보다 좋은가? | 예 | +| buy-and-hold보다 좋은가? | 아니오 | +| 25bp cost gate를 통과했는가? | 아니오 | +| 바로 실거래 후보인가? | 아니오, 보류 | + +핵심 원인: + +1. train target 분포가 비용 후 음수로 치우쳐 있다. +2. 단순 ridge contextual bandit은 sequence/position/리스크 상태를 충분히 학습하지 못한다. +3. 평균 수익은 양수지만 MDD가 buy-and-hold보다 더 나쁘다. +4. 25bp 비용 후 buy-and-hold 대비 우위를 만들지 못했다. + +### 9.6 다음 단계 + +다음 페이지는 **페이지 4: leaderboard artifact**다. + +목표: + +- baseline leaderboard와 contextual bandit 결과를 하나의 JSON/CSV로 통합한다. +- 웹 대시보드에서 smoke/full, baseline/RL, cost gate 통과 여부를 한눈에 비교할 수 있도록 준비한다. + +--- + +## 10. 페이지 4 완료 기록: performance leaderboard artifact + +### 10.1 진행 내용 + +페이지 4에서는 P002의 baseline full leaderboard와 P003의 contextual bandit full eval 결과를 하나의 성과 leaderboard로 통합했다. + +| 파일 | 역할 | +|---|---| +| `stom_rl/performance_leaderboard.py` | baseline/RL 결과를 통합한 performance leaderboard 생성 | +| `tests/test_stom_rl_performance_leaderboard.py` | 통합 로직 회귀 테스트 | +| `webui/rl_runs/stom_1s_2025_performance_leaderboard_full_test/performance_leaderboard.json` | 실제 full test 통합 JSON artifact | +| `webui/rl_runs/stom_1s_2025_performance_leaderboard_full_test/performance_leaderboard.csv` | 실제 full test 통합 CSV artifact | + +실행 명령: + +```powershell +C:\Python\64\Python3119\python.exe -m stom_rl.performance_leaderboard ` + --baseline-report webui\rl_runs\stom_1s_2025_baseline_leaderboard_full_test\leaderboard_report.json ` + --contextual-bandit-report webui\rl_runs\stom_1s_2025_contextual_bandit_full_test\eval_summary.json ` + --output-dir webui\rl_runs\stom_1s_2025_performance_leaderboard_full_test ` + --target-cost-bps 25 ` + --target-slippage-bps 0 +``` + +### 10.2 통합 leaderboard 결과 + +| rank | source | model/policy | avg episode net % | trade count | MDD % | cost gate | 사용 판단 | +|---:|---|---|---:|---:|---:|---|---| +| 1 | baseline | buy_and_hold | 0.5126 | 2,730 | -50.7280 | false | baseline | +| 2 | rl_model | contextual_bandit | 0.1254 | 971 | -51.5892 | false | watch | +| 3 | baseline | no_trade | 0.0000 | 0 | 0.0000 | false | baseline | +| 4 | baseline | mean_reversion | -23.2925 | 170,868 | -47.7542 | false | baseline | +| 5 | baseline | volume_filter | -26.1600 | 167,923 | -49.7977 | false | baseline | +| 6 | baseline | momentum | -27.9136 | 164,944 | -62.5398 | false | baseline | +| 7 | baseline | random | -77.0568 | 806,047 | -81.8887 | false | baseline | + +요약: + +| 항목 | 값 | +|---|---| +| best policy | buy_and_hold | +| best RL model | contextual_bandit | +| best RL usability | watch | +| RL models beating buy-and-hold | 없음 | +| RL models passing cost gate | 없음 | + +### 10.3 해석 + +통합 leaderboard 기준으로 contextual bandit은 no-trade보다 낫지만 buy-and-hold보다 낮다. 또한 25bp cost gate를 통과하지 못했으므로 실거래 후보가 아니라 **관찰 대상(watch)** 으로 분류했다. + +### 10.4 검증 + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_rl_performance_leaderboard.py -q +C:\Python\64\Python3119\python.exe -m py_compile stom_rl\performance_leaderboard.py +``` + +결과: + +```text +1 passed +py_compile 통과 +``` + +다음 페이지는 **페이지 5: dashboard leaderboard**다. 이제 `performance_leaderboard.json/csv`를 웹 강화학습 실험실에서 보여주면 사용자가 모델별 성과와 실사용 판단을 한눈에 볼 수 있다. + +--- + +## 11. 페이지 5 완료 기록: dashboard leaderboard + +### 11.1 진행 내용 + +페이지 5에서는 페이지 4에서 생성한 `performance_leaderboard.json/csv`를 웹 강화학습 실험실에서 직접 비교할 수 있도록 연결했다. + +| 파일 | 변경 내용 | +|---|---| +| `webui/rl_dashboard.py` | `performance_leaderboard` artifact 감지, summary/detail 로딩, `leaderboard` CSV table alias 추가 | +| `webui/v2_src/src/lib/api.ts` | `/api/rl/runs/{run}/table/{table}` generic table helper 추가 | +| `webui/v2_src/src/tabs/RLLabTab.svelte` | 성과 리더보드 우선 선택, 리더보드 탭/차트/표 추가, performance run의 trades/equity 미존재 상황 안전 처리 | +| `tests/test_stom_rl_dashboard_api.py` | performance leaderboard fixture와 Flask API smoke 추가 | +| `tests/test_stom_rl_dashboard_tab.py` | 프론트 API/리더보드 마커 회귀 테스트 추가 | + +### 11.2 웹에서 보이는 핵심 정보 + +| 화면 요소 | 목적 | +|---|---| +| 성과 리더보드 run 우선 선택 | 사용자가 가장 중요한 전체 비교 결과를 먼저 보도록 함 | +| 리더보드 차트 | 평균 episode net, MDD, 거래/episode를 동시에 비교 | +| 리더보드 표 | rank, source, model/policy, buy-and-hold 우위 여부, cost gate 통과 여부, usability, 판단 근거 표시 | +| 선택 모델 KPI | performance run에서는 best policy와 RL usability를 함께 표시 | + +### 11.3 현재 full test leaderboard 결론 + +| 항목 | 결과 | +|---|---| +| best policy | buy_and_hold | +| best RL model | contextual_bandit | +| best RL usability | watch | +| RL이 buy-and-hold를 이겼는가 | 아니오 | +| RL이 25bp cost gate를 통과했는가 | 아니오 | + +따라서 현재 모델은 “학습/평가/대시보드 비교는 정상 작동”하지만, “실거래 후보”가 아니라 **추가 연구/개선 대상**이다. + +### 11.4 검증 + +```powershell +C:\Python\64\Python3119\python.exe -m pytest tests\test_stom_rl_dashboard_api.py tests\test_stom_rl_dashboard_tab.py tests\test_stom_rl_performance_leaderboard.py -q +npm run build +``` + +결과: + +```text +7 passed +svelte-check 0 errors, 4 existing warnings +vite build completed +``` + +추가 API smoke: + +```text +GET /api/rl/runs/stom_1s_2025_performance_leaderboard_full_test -> 200 +GET /api/rl/runs/stom_1s_2025_performance_leaderboard_full_test/table/leaderboard?limit=3 -> 200 +``` + +### 11.5 다음 단계 + +다음 페이지는 **페이지 6: DQN/PPO 확장 설계**다. 지금까지의 지표상 contextual bandit은 no-trade보다 낫지만 buy-and-hold를 이기지 못했다. 그러므로 다음 단계에서는 단순 회귀형 bandit보다 position 상태, sequence, 리스크 제어, 거래비용을 더 잘 다룰 수 있는 DQN/PPO 계열이 필요한지 판단한다. + +--- + +## 12. 페이지 6 완료 기록: DQN/PPO 확장 설계 + +### 12.1 진행 내용 + +페이지 6에서는 현재 STOM 강화학습 환경이 DQN/PPO로 확장 가능한지 검토하고, 새 dependency 추가 전 필요한 계약과 리스크를 문서화했다. + +상세 문서: + +- `docs/stom_rl_dqn_ppo_extension_design_2026-05-23.md` + +### 12.2 공식 가이드 기준 + +| 자료 | 반영 내용 | +|---|---| +| Gymnasium Env API | `reset`, `step`, `terminated`, `truncated`, `observation_space`, `action_space` 계약 확인 | +| Stable-Baselines3 custom env | custom env는 Gymnasium interface와 `check_env` 검증 필요 | +| Stable-Baselines3 DQN/PPO 정책 문서 | DQN/PPO smoke에는 우선 `MlpPolicy`와 flatten observation을 검토 | + +### 12.3 판단 결과 + +| 항목 | 판단 | +|---|---| +| DQN 적용 | 가능. action이 `hold/buy/sell` discrete 구조다. | +| PPO 적용 | 가능. 다만 sample 비용이 커서 DQN 이후 후순위다. | +| 현재 env 그대로 SB3 사용 | 불완전. 실제 `gymnasium.Env` 상속과 `gymnasium.spaces` adapter가 필요하다. | +| 새 dependency 추가 | 사용자 명시 승인 전까지 보류한다. | +| 다음 구현 후보 | `stom_rl/gym_adapter.py` + `check_env` + DQN smoke | + +### 12.4 핵심 결론 + +현재 contextual bandit은 full test split에서 no-trade보다 낫지만 buy-and-hold를 이기지 못했다. DQN/PPO는 이 한계를 개선할 수 있는 후보지만, 실거래 성과를 보장하지 않는다. 다음 구현 단계는 무작정 장시간 학습이 아니라 **Gymnasium adapter와 DQN smoke 검증**이어야 한다. + +### 12.5 검증 + +```powershell +C:\Python\64\Python3119\python.exe -m py_compile stom_rl\trading_env.py stom_rl\contextual_bandit.py stom_rl\performance_leaderboard.py +``` + +결과: py_compile 통과. + +다음 페이지는 **페이지 7: 최종 리뷰/QA/사용 판단**이다. + +--- + +## 13. 페이지 7 완료 기록: 최종 리뷰/QA/사용 판단 + +### 13.1 진행 내용 + +페이지 7에서는 지금까지 구축한 강화학습 성과 모델 고도화 흐름을 전체 검증하고, 현재 모델을 실제 사용할 수 있는지 최종 판단했다. + +상세 문서: + +- `docs/stom_rl_final_review_2026-05-23.md` + +### 13.2 검증 결과 + +| 검증 | 결과 | +|---|---| +| Python 전체 테스트 | `94 passed, 2 skipped, 2 warnings` | +| 프론트 빌드 | `svelte-check 0 errors`, `vite build completed` | +| Flask API smoke | 핵심 endpoint 모두 200 | +| 최종 성과 판단 | contextual bandit은 watch, 실거래 보류 | + +### 13.3 최종 판단 + +| 용도 | 판단 | +|---|---| +| 연구/검증 플랫폼 | 사용 가능 | +| STOM 2025 1초봉 RL 실험 | 사용 가능 | +| 웹 대시보드 성과 비교 | 사용 가능 | +| 현재 contextual bandit 실거래 | 보류 | +| 다음 모델 개발 기반 | 사용 가능 | + +최종 결론: + +**플랫폼 구축 목표는 달성했다. 모델 수익성 목표는 아직 달성하지 못했다.** + +다음 목표를 새로 잡는다면 `Gymnasium adapter + check_env + DQN smoke`부터 시작한다. diff --git a/docs/stom_rl_portfolio_design_handoff_2026-05-25.md b/docs/stom_rl_portfolio_design_handoff_2026-05-25.md new file mode 100644 index 000000000..86eb352bf --- /dev/null +++ b/docs/stom_rl_portfolio_design_handoff_2026-05-25.md @@ -0,0 +1,458 @@ +# STOM 강화학습 — 조건식 기반 포트폴리오 RL 설계 & 핸드오프 + +작성일: 2026-05-25 +브랜치: `feature/stom-rl-lab` +대상 저장소: `D:\Chanil_Park\Project\Programming\Kronos` +성격: **새 대화에서 그대로 이어받을 수 있는 설계·핸드오프 문서** + +이 문서는 (1) 현재 강화학습(RL) 실험실의 실제 상태, (2) 사용자가 원하는 목표(자본금으로 여러 종목을 사고팔며 총자산을 키우는 포트폴리오 RL), (3) 그 가능성, (4) 목표 달성을 위한 단계별 방법, (5) 새 대화에서 이어갈 때 필요한 파일·명령·프롬프트를 한 곳에 모은 것이다. + +> ⚠️ 중요 전제: 이 RL 실험실은 **Kronos 예측 모델과 독립(Kronos 비의존)** 이다. 다만 매매 변수·조건식 문법은 원본 STOM 조건식 생성기에서 가져온 레퍼런스(`docs/reference/stom_ai_agent/`)를 단일 진실 공급원으로 참고한다. + +--- + +## 0. 한 줄 요약 + +현재 RL은 **한 종목의 시장 원천 feature 6개(open/high/low/close/volume/amount)와 포지션 파생 3개만 보고 사고/팔고/기다리는 단일 종목 에이전트**다. 사용자가 원하는 것은 **초기 자본금으로 조건식에 걸린 여러 종목을 동시에 사고팔며 총자산(NAV)을 키우는 포트폴리오 RL**이다. 이는 올바른 방향이지만 현재 구조의 **큰 재설계**이며, 본 문서의 단계 로드맵(0→6)을 따른다. + +--- + +## 1. 현재 상황 (실제 코드 기준) + +### 1.1 RL 실험실 구조 + +| 영역 | 파일 | 역할 | +|---|---|---| +| 환경(MDP) | `stom_rl/trading_env.py` | 단일 종목 1초봉 트레이딩 env | +| 회계 | `stom_rl/baselines.py` (`AccountState`) | 비용·포지션·equity 계산 | +| Gym 어댑터 | `stom_rl/sb3_adapter.py` | Gymnasium/SB3 호환 | +| 학습 | `stom_rl/sb3_smoke.py` | DQN/PPO 학습·저장·평가·live event | +| 저장모델 평가 | `stom_rl/sb3_eval.py` | 재학습 없이 N episode 재평가 (eval-only) | +| 기간검증 | `stom_rl/walk_forward.py` | 시간순 fold별 재평가(과적합 탐지) | +| 성과표 | `stom_rl/performance_leaderboard.py` | baseline/bandit/SB3 통합 leaderboard | +| 웹 | `webui/rl_dashboard.py`, `webui/v2_src/src/tabs/RLLabTab.svelte` | `http://127.0.0.1:5070/rl` | +| 공부자료 | `docs/stom_rl_study_guide.html`, `docs/wiki/11-reinforcement-learning.md` | RL 원리 설명 | + +### 1.2 현재 RL의 한계 (검증으로 드러남) + +| 항목 | 현재 | +|---|---| +| 종목 | **한 번에 1종목** (episode = 1종목의 하루 세션) | +| 포지션 | **0/1** (전액 매수 / 전액 청산), 사이징 없음 | +| 자본금 | **없음** — `equity=1.0`에서 시작하는 정규화 지수(돈 아님) | +| 동시 보유 | 없음 (episode 순차 평가 후 곱셈) | +| 사용 변수 | **9개** (시장 6: open/high/low/close/volume/amount + 파생 3: position/unrealized_return/time_in_position) | +| 보상 | `reward_mode="horizon"`, 300초 forward 수익률 − 25bp 비용 | +| 성능 | 50k 모델이 100 episode에서 +1.34%(dqn) — buy&hold(+0.51%) 초과하나 **regime_sensitive** | +| 학습량 확대 | 50k→100k **무효**(오히려 약간 하락) | +| 기간 안정성 | walk-forward 6 fold 중 dqn 5/6·ppo 4/6 양수, **최근 구간 손실** | + +### 1.3 데이터 흐름 (병목) + +``` +STOM DB(_database/stock_tick_back.db, 29.7GB, 종목별 ~50변수) + └─(export, 여기서 OHLCV+amount만 추림)→ finetune/qlib_exports/.../qlib_csv/*.csv (10컬럼) + └→ episode_manifest(종목-세션 분할) → trading_env(9 feature) → SB3 +``` +→ **변수를 늘리려면 export 단계부터 다시** 내보내야 한다. + +--- + +## 2. 사용자가 원하는 목표 + +1. **초기 자본금**에서 시작한다(정규화 지수가 아니라 실제 ₩ 개념). +2. **조건식에 걸린 여러 종목**을 후보로 받아 **동시에 여러 개 사고판다**. +3. 매매를 반복하며 **총자산(NAV)이 늘어나는 방향**으로 강화학습한다. +4. **시간 조절**(언제 사고 언제 파는지, 보유시간)을 RL이 학습/제어한다. +5. 조건식 생성은 원본 STOM 프로그램(`docs/reference/stom_ai_agent/`)의 문법·변수를 따른다. + +--- + +## 3. STOM 조건식 시스템 (레퍼런스 요약) + +출처: `docs/reference/stom_ai_agent/` (원본 `C:\System_Trading\STOM\STOM_V.wt-dev\utility\ai_agent` 복사본) + +### 3.1 사용 가능한 변수 (DB가 실제 제공) + +| 그룹 | 대표 변수 | +|---|---| +| 가격 | 현재가, 시가, 고가, 저가, 등락율, 고저평균대비등락율, 저가대비고가등락율 | +| 거래/수급 | 당일거래대금, 초당거래대금, **체결강도(0~500)**, 초당매수수량, 초당매도수량, 초당매수금액, 초당매도금액, 최고매수/매도금액·가격 | +| 호가창 | 매도호가1~5, 매수호가1~5, 매도잔량1~5, 매수잔량1~5, 매도총잔량, 매수총잔량, 매도수5호가잔량합 | +| 국내전용 | 거래대금증감, 전일비, 회전율, 전일동시간비, 시가총액, 라운드피겨위5호가이내, VI해제시간/VI가격/VI호가단위 | +| 구간연산 | 이동평균, 변동성, 최고/최저현재가, 체결강도평균, 거래대금각도, 등락율각도 … | +| 복합조건 | 가격급등, 거래대금급증, 체결강도급등, 호가상승압력, 횡보후가격급등 … (181개 함수형) | +| 보조지표(1분봉) | RSI, MACD, ATR, BB, CCI, OBV, STOCH, WILLR … | +| **매도전용 잔고** | **수익률, 수익금, 매수가, 보유수량, 보유시간, 최고수익률, 최저수익률, 분할매수/매도횟수** | + +### 3.2 조건식(전략) 문법 + +- **매수전략**: `매수 = True`로 시작 → 통과 못할 조건은 `매수 = False`로 차단 → `if 매수: self.Buy()`. +- **매도전략**: 청산 조건을 나열해 하나라도 참이면 `매도 = True` → `if 매도: self.Sell()`. +- 이전값: `현재가N(1)`(1틱 전), 구간연산: `이동평균(60)` / `체결강도평균(30)`. +- 매수전략에서 매도전용 변수(수익률·보유시간 등) 사용 금지. + +매수 예시: +```python +매수 = True +if 관심종목 != 1: 매수 = False +elif not (0 < 등락율 <= 25): 매수 = False +elif not (당일거래대금 > 100): 매수 = False +elif not (체결강도 >= 체결강도평균(30) + 5): 매수 = False +if 매수: self.Buy() +``` +매도(동적청산) 예시: +```python +if 수익률 >= 3: 매도 = True +elif 수익률 <= -2: 매도 = True +elif 보유시간 >= 600 and 변동성(30) <= 0.5: 매도 = True +if 매도: self.Sell() +``` + +### 3.3 RL 관점의 핵심 통찰 + +- **조건식 = 종목 universe 필터**: 매수 조건식이 "지금 살 만한 후보"를 골라준다 → RL이 수천 종목을 다 평가할 필요 없이 **후보 중에서만** 선택하면 된다(행동 공간 축소). +- **매도전용 변수(보유시간·수익률)** 가 이미 정의돼 있다 → "시간 조절/동적청산"을 RL 보상·상태에 그대로 녹일 수 있다. + +--- + +## 4. 가능성 검토 (3가지 질문에 대한 답) + +### Q1. 강화학습으로 "시간 조절"이 가능한가? → ✅ 가능 (이미 일부 됨) +- 현재도 에이전트가 `sell` 시점을 스스로 정하므로 **보유시간은 가변**이다. `time_in_position`이 상태에 들어간다. +- `reward_horizon_seconds`(현재 300)는 설정 가능하고, `reward_mode="mark_to_market"`(보상은 1-step 손익)도 이미 구현돼 있다. 단, 현재 env의 episode 안전 구간(`max_action_idx`)과 최소 row 계산은 여전히 `reward_horizon_seconds`에 의존하므로, mark-to-market 전환 시 이 의존성을 함께 점검해야 한다. +- 보유시간을 **단일 고정값으로 둘 필요 없다** — 다중 horizon·동적청산(수익률/변동성 기반)으로 확장 가능. + +### Q2. 지금 모델이 "여러 종목 동시 매수"를 고려하나? → ❌ 아니다 +- 현재는 단일 종목·0/1 포지션·자본금 없음. 포트폴리오가 아니다. + +### Q3. 자본금으로 다종목 사고팔며 총자산을 키우는 RL이 좋은가? → ✅ 방향은 옳음, 단 큰 재설계 +- 실거래에 훨씬 가깝고 STOM 백테스트 DB의 성격(조건식 매매)과도 맞다. +- 그러나 행동공간·자본제약·동시 포지션 회계·신용할당(credit assignment)·검증 난이도가 모두 커진다. +- 현재 단일 종목조차 정보부족(9변수)·기간민감이므로, **토대(변수·신호)부터 강화한 뒤 포트폴리오로 확장**해야 안전하다. + +--- + +## 5. 목표 달성 방법 — 단계 로드맵 + +> 원칙: 한 번에 포트폴리오로 점프하지 말고, 검증 가능한 작은 단계로 쌓는다. + +### 단계 0 — 실행 문서/명령 보정 (개발 전 정리) +- PowerShell 기준으로 테스트/DB 확인 명령을 실제 동작하는 형태로 고친다. +- 현재 `ruff` 실패 항목은 known issue로 기록하거나 별도 정리 후 다음 단계에 들어간다. +- 완료기준: 새 대화의 작업자가 7.2 명령을 그대로 복사해 최소 검증을 재현할 수 있다. + +### 단계 1 — Feature 확장 (가장 먼저, 적은 변경/큰 효과) +- export 파이프라인에 **호가불균형(매수잔량합/(매수+매도잔량)), 체결강도, 초당매수/매도수량, 거래대금각도** 등을 추가. +- `trading_env.BASE_MARKET_COLUMNS` / `feature_columns` 확장, 정규화 처리. +- 완료기준: 단일 종목 RL이 새 feature로 walk-forward 일관성이 개선되는지 비교. + +### 단계 2 — 자본금 + 포지션 사이징 +- `AccountState`를 정규화 1.0 → **실제 자본금(현금잔고 + 보유평가액)** 으로 일반화. +- 현재 `StomTickTradingEnv`도 내부에 `position/entry_price/realized_return` 회계를 별도로 갖고 있으므로, 단순히 `AccountState`만 고치지 말고 **공용 accounting 모듈**로 통합하거나 env가 같은 회계 로직을 호출하도록 정리한다. +- 행동을 0/1 → **비중(예: 0/25/50/100% 또는 연속)** 으로 확장. +- 완료기준: 단일 종목에서 사이징이 동작하고 NAV가 추적되며, baseline/env/향후 portfolio env가 같은 회계 규칙을 공유함. + +### 단계 3 — 조건식 후보 생성기 연결 +- `docs/reference/stom_ai_agent/`의 매수 조건식을 STOM DB(`_database/stock_tick_back.db`)에 적용해 **시점별 매수 후보 종목 목록**을 생성하는 모듈(`stom_rl/condition_screener.py`(가칭)) 추가. +- 조건식 평가기는 화이트리스트 변수만 허용(forbidden.md 준수, `eval` 금지 → 안전한 파서/AST). +- 산출물 schema는 최소 `timestamp`, `symbol`, `condition_id`, `passed`, `rank_score`, `feature...`를 포함하고, 포트폴리오 env가 바로 읽을 수 있도록 parquet/csv/jsonl 중 하나로 고정한다. +- 완료기준: 특정 일자/시점에 "조건 통과 종목 리스트 + 그 시점 feature"가 재현 가능. + +### 단계 4 — 포트폴리오 환경(PortfolioEnv) +- 상태: 현금잔고 + 보유종목들(종목별 수익률·보유시간) + **조건식 후보들의 feature**. +- SB3 호환을 위해 관측 shape는 고정해야 한다. 후보는 `top_k_candidates` 슬롯으로 자르고 부족분은 padding하며, `candidate_mask`로 실제 후보/패딩을 구분한다. 보유 종목도 `max_positions` 슬롯으로 고정한다. +- 행동: 후보 중 매수 선택·비중 + 보유종목 매도(동적청산 변수 활용). 초기 버전은 `Discrete` action으로 단순화한다(예: hold, 후보 슬롯 i를 25/50% 매수, 보유 슬롯 j 전량/부분 매도). 이후 안정화되면 MultiDiscrete/Box로 확장한다. +- 보상: 매 step **총자산(NAV) 변화 − 비용/슬리피지 − 선택적 risk penalty**. +- 제약: 현금 한도, 동시 보유 종목 수 상한(예 ≤5~10), 종목당 비중 상한, action mask 또는 invalid-action penalty. +- 완료기준: 소수 후보/소수 종목(예 top-K≤10, 보유≤5) 동시 보유로 학습/평가가 돌고 NAV 곡선·거래 로그·action mask 통계가 나온다. + +### 단계 5 — 검증 (walk-forward + 기준선) +- 포트폴리오 NAV를 기간 분할 walk-forward로 검증, buy&hold·동일가중 등과 비교. +- 완료기준: 여러 기간에서 일관되게 기준선 초과 + 비용·MDD 게이트 통과. + +### 단계 6 — 위험관리 & paper replay +- Max DD·연속손실·일일거래한도·종목당 비중상한 등 risk gate. +- read-only paper replay(실주문 없음)로 실거래 직전 점검. + +| 단계 | 핵심 산출물 | 난이도 | 권장 순서 | +|:--:|---|:--:|:--:| +| 0 | 실행 문서/명령 보정 | 낮음 | 완료 | +| 1 | feature 확장 + 비교 | 낮음 | **지금** | +| 2 | 자본금/사이징 | 중 | 다음 | +| 3 | 조건식 스크리너 | 중 | 3번째 | +| 4 | PortfolioEnv + RL | **높음** | 4번째 | +| 5 | walk-forward 검증 | 중 | 5번째 | +| 6 | risk gate/paper | 중 | 마지막 | + +--- + +## 6. 주요 도전 과제와 완화책 + +| 도전 | 설명 | 완화책 | +|---|---|---| +| 행동공간 폭발 | 수천 종목 × 비중 | **조건식 후보 필터**로 universe 축소 | +| 신용 할당 | 어느 매수가 수익에 기여했는지 모호 | NAV 기반 보상 + 충분한 데이터 + 단순 사이징부터 | +| 데이터 동기화 | 다종목 1초 동기 정렬 | DB에 종목별 테이블 존재(가능) — 시점 인덱스 정렬 유틸 필요 | +| 회계 정확성 | 현금/평가액/비용/동시포지션 | `AccountState`와 env 내부 회계를 공용 accounting 모듈로 통합 + 단위 테스트 | +| 과적합/기간민감 | 단일 종목도 이미 발생 | 단계마다 walk-forward, 보수적 비중상한 | +| 안전(조건식 eval) | 임의 코드 실행 위험 | forbidden.md 토큰 차단, AST 화이트리스트 파서 | + +--- + +## 7. 새 대화에서 이어가기 (핸드오프) + +### 7.1 먼저 읽을 것 +1. 이 문서 (`docs/stom_rl_portfolio_design_handoff_2026-05-25.md`) +2. `docs/reference/stom_ai_agent/README.md` 및 `strategy.txt`, `variables_reference.md` +3. RL 원리: `docs/wiki/11-reinforcement-learning.md` 또는 `docs/stom_rl_study_guide.html` +4. 직전 RL 핸드오프: `docs/stom_rl_current_branch_handoff_2026-05-24.md` + +### 7.2 환경/명령 +```powershell +# 서버 (5070, 리로더 off가 기본) +$env:KRONOS_WEBUI_PORT="5070"; $env:KRONOS_V2_DIST="1" +py -3.11 webui\run.py # http://127.0.0.1:5070/rl + +# 단일 종목 RL 학습/평가 (현재 구조) +py -3.11 -m stom_rl.sb3_smoke --algorithms dqn,ppo --total-timesteps 50000 --device auto +py -3.11 -m stom_rl.sb3_eval --model-dir webui/rl_runs/stom_1s_2025_sb3_50k --eval-episodes 100 +py -3.11 -m stom_rl.walk_forward --model-dir webui/rl_runs/stom_1s_2025_sb3_50k --n-folds 6 +py -3.11 -m stom_rl.performance_leaderboard --sb3-smoke-reports auto + +# DB 스키마 확인 (PowerShell quoting 안전 버전) +py -3.11 -c "import sqlite3; c=sqlite3.connect('_database/stock_tick_back.db'); print([r[1] for r in c.execute('PRAGMA table_info([247540])')])" + +# 테스트 (PowerShell은 pytest glob을 자동 확장하지 않으므로 파일 목록을 먼저 만든다) +$stomRlTests = (Get-ChildItem .\tests -Filter 'test_stom_rl_*.py').FullName +py -3.11 -m pytest $stomRlTests -q + +# lint (2026-05-25 현재 known issue: stom_rl 일부 unused import 및 비-STOM 테스트 E402가 남아 실패할 수 있음) +py -3.11 -m ruff check stom_rl $stomRlTests +``` + +### 7.3 제약/관례 (반드시 준수) +- 마무리는 **일반 git commit**(ultragoal checkpoint 금지), 커밋 메시지는 한글로 자세히. +- v2 SPA 수정 시 `webui/v2_src/src/`만 수정 → `npm run build`로 dist 갱신, SSR marker `kronos-v2-shell` 보존, `/api/*` 신규 금지. +- `webui/rl_runs/*`는 gitignore(런타임 산출물). 커밋 대상 아님. +- 조건식 평가는 `eval` 금지 — AST/화이트리스트 파서로 안전하게. +- 운영/확인 포트는 5070, `/rl` 기준. + +### 7.4 새 대화 시작 프롬프트(붙여넣기용) +```text +D:\Chanil_Park\Project\Programming\Kronos 에서 이어서 진행합니다. 브랜치 feature/stom-rl-lab. +먼저 docs/stom_rl_portfolio_design_handoff_2026-05-25.md 와 docs/reference/stom_ai_agent/ 를 읽으세요. + +목표: 단일 종목 OHLCV RL을, 조건식(STOM 변수)으로 후보를 거른 뒤 자본금으로 여러 종목을 +동시에 사고팔며 총자산(NAV)을 키우는 포트폴리오 RL로 단계적으로 확장합니다. + +단계 로드맵(문서 5장): 0)실행 명령 보정(완료) 1)feature 확장 2)자본금/사이징 +3)조건식 스크리너 4)PortfolioEnv+RL 5)walk-forward 검증 6)risk gate/paper. + +지금은 [단계 N]부터 진행해주세요. 제약: 일반 git commit(한글), webui /api 신규 금지, +rl_runs는 gitignore, 조건식은 eval 금지·AST 화이트리스트 파서. +``` + +--- + +## 8. 진행 현황 (2026-05-25 기준) + +> 아래 표가 **단일 기준(single source of truth)** 이다. 이전 버전에 있던 "feature 확장 0% / 전체 약 2%" 표는 stale이라 삭제했다. +> 검증 근거: 신규 테스트 14개 통과(`14 passed in 7.37s`), smoke 산출물 3종 생성(`.omx/artifacts/*`), 아키텍트 재검토 APPROVE. + +| 페이지 | 상태 | 핵심 산출물 | 검증 근거 | +|:--:|:--:|---|---| +| 0. 단일 종목 RL 플랫폼 | ✅ 100% | env/SB3/eval-only/walk-forward/대시보드 | `stom_rl/trading_env.py`, `tests/test_stom_rl_*.py` | +| 0a. Hygiene/명령 보정 | ✅ 100% | ruff unused import 정리, PowerShell pytest/DB 확인 명령 보정 | `baselines.py`, `episode_manifest.py`, `leaderboard.py` | +| 1. feature 확장 | ✅ 100% | canonical feature mapping/helper, env configurable feature columns | `finetune/qlib_stom_pipeline.py`, `stom_rl/trading_env.py`, `tests/test_stom_rl_feature_expansion.py` | +| 2. 자본금/사이징 | ✅ 100% | cash/holding/NAV 회계 일원화, NAV 보존 invariant 테스트 | `stom_rl/accounting.py`, `tests/test_stom_rl_accounting.py` | +| 2.5. PortfolioEnv 계약 spike | ✅ 100% | fixed top-K, max_positions, candidate/holding mask, discrete action layout | `stom_rl/portfolio_env.py`, `tests/test_stom_rl_portfolio_env.py` | +| 3. 조건식 스크리너 | ✅ 100% | AST whitelist evaluator, 금지 토큰/unknown variable/sell-only guard, candidate schema | `stom_rl/condition_screener.py`, `tests/test_stom_rl_condition_screener.py` | +| 4. PortfolioEnv + RL smoke | ✅ 100% | synthetic candidate 기반 deterministic smoke runner | `stom_rl/portfolio_train.py`, `tests/test_stom_rl_portfolio_train.py` | +| 5. portfolio walk-forward | ✅ 100% | contiguous fold split, no-trade/equal-weight/buy&hold/rule baseline report | `stom_rl/portfolio_walk_forward.py`, `tests/test_stom_rl_portfolio_walk_forward.py` | +| 6. risk gate / paper replay | ✅ 100% | max DD/연속손실/일일거래/비중 cap reason code, read-only replay | `stom_rl/risk_gate.py`, `stom_rl/paper_replay.py`, `tests/test_stom_rl_risk_gate.py`, `tests/test_stom_rl_paper_replay.py` | + +**진행률 요약** + +- 포트폴리오 전환 엔지니어링(페이지 1~6) 기준: **100%** — 구현·테스트·smoke 산출물 완료. +- 최종 목적(실제 DB 기반 수익성 RL) 기준: **약 55~60%** — 실제 DB 실행·알고리즘/보상 설계·full walk-forward·성능 최적화가 남음 (상세는 12장 및 `docs/stom_rl_rl_execution_research_plan_2026-05-25.md`). +- 다음 시작점은 **페이지 7(실제 STOM DB feature export dry-run)** 이다. 페이지 1~6은 재구현 대상이 아니다. + +--- + +## 9. 한 줄 결론 + +목표(자본금 → 조건식 후보 → 다종목 동시매매 → 총자산 성장)는 **타당하고 현실적**이다. +다만 현재 단일 종목·9변수·기간민감 상태에서 바로 포트폴리오로 점프하면 검증이 불가능하므로, +**단계 1(feature 확장)부터 쌓아 4(PortfolioEnv)로 확장**하는 경로를 권장한다. +조건식 문법·변수는 `docs/reference/stom_ai_agent/`를 단일 진실 공급원으로 삼는다. + +--- + +## 10. 부록 A — 단계(페이지)별 상세 실행 계획 + +> 각 단계를 독립된 "페이지"로 보고, 목표 · 입력/출력 · 신규/수정 파일 · 작업 체크리스트 · 데이터/스키마 · 완료 기준 · 테스트 · 예상 규모 · 위험을 정의한다. 페이지마다 관련 `pytest`를 통과시키고, `ruff`는 현재 known issue를 먼저 정리하거나 해당 페이지 범위의 신규 오류가 없음을 확인한 뒤 한글 git commit으로 닫는다. + +### 페이지 0 — 핸드오프/실행 명령 보정 (완료) + +- **목표**: 새 대화의 작업자가 문서 명령을 그대로 복사해 최소 검증을 재현할 수 있게 한다. +- **반영 내용**: PowerShell용 pytest 파일 목록 생성, DB schema 확인 quoting 보정, 현재 `ruff` 실패를 known issue로 명시. +- **검증 근거**: 보정된 pytest 방식으로 `tests/test_stom_rl_*.py` 32개 테스트 통과 확인. +- **남은 위험**: `ruff`는 아직 코드 미사용 import/일부 테스트 E402로 실패 가능하므로, 페이지 1 착수 전 정리하면 이후 검증 비용이 낮아진다. + +### 페이지 1 — Feature 확장 (단일 종목 신호 강화) + +- **목표**: 현재 9개 feature(시장 원천 6 + 포지션 파생 3)에 수급·호가 변수를 더해 단일 종목 RL의 정보량을 늘린다. +- **입력 → 출력**: STOM DB/qlib export → 확장 feature가 포함된 episode CSV → `trading_env` 관측. +- **추가할 feature (목표 +8, 총 ~17개)**: + + | feature | 정의 | 그룹 | + |---|---|---| + | `호가불균형` | 매수총잔량 / (매수총잔량+매도총잔량) | 호가 | + | `호가스프레드` | (매도호가1−매수호가1) / 호가단위 | 호가 | + | `체결강도` | 매수수량/매도수량×100 (0~500) | 수급 | + | `체결강도평균대비` | 체결강도 / 체결강도평균(N) | 수급 | + | `초당매수수량`, `초당매도수량` | 1초 누적 매수/매도 수량 | 수급 | + | `순매수수량` | 초당매수수량 − 초당매도수량 | 수급 | + | `거래대금각도` | 당일거래대금 기울기(0~90) | 모멘텀 | + +- **신규/수정 파일**: + - 수정: export 파이프라인(`finetune/`의 qlib export 코드) — DB의 추가 컬럼을 CSV로 내보내기. + - 수정: `stom_rl/trading_env.py` — `BASE_MARKET_COLUMNS`/`feature_columns`에 신규 컬럼 추가 + 정규화 처리(스케일이 큰 잔량/금액은 비율·로그·z-score). + - 수정: `stom_rl/episode_manifest.py` — 신규 컬럼 존재 검증. +- **작업 체크리스트**: ① export에 컬럼 추가 → ② env feature 확장 + 정규화 → ③ 결측/이상치 처리 → ④ check_env 통과 → ⑤ 50k 재학습 → ⑥ 100ep eval-only + walk-forward로 기존 9-feature 모델과 비교. +- **완료 기준**: 확장 feature 모델이 walk-forward folds_positive·평균 net에서 기존 대비 **개선**(또는 개선 없음을 데이터로 확인). +- **테스트**: `tests/test_stom_rl_trading_env.py`에 신규 feature 차원·정규화 단위 테스트 추가. +- **예상 규모**: 중소 (export 재실행 시간 포함). **위험**: 잔량/금액 스케일 정규화 실패 시 학습 불안정 → 비율화 우선. + +### 페이지 2 — 자본금 + 포지션 사이징 + +- **목표**: 정규화 equity(1.0)를 **실제 자본금(현금+평가액)** 으로 일반화하고, 0/1 포지션을 비중으로 확장. +- **신규/수정 파일**: + - 신규/수정: `stom_rl/accounting.py`(가칭) — `cash`, `position_qty`, `avg_entry_price`, `holdings_value`, `nav`, `realized_pnl`, `cost_paid`를 한 곳에서 계산. + - 수정: `stom_rl/baselines.py` `AccountState` — 공용 accounting 모듈을 사용하도록 축소/위임. `apply_action(action, weight)` 로 비중 매수/부분 매도 지원. + - 수정: `stom_rl/trading_env.py` — 내부 `position/entry_price/realized_return` 회계를 공용 accounting 모듈로 대체. action_space `Discrete(3)` → `Discrete(N)`(예: 0=hold, 1=25%, 2=50%, 3=100% 매수, 4=전량매도) 또는 비중 Box. 상태에 `cash_ratio`, `position_weight` 추가. +- **데이터/스키마**: `nav = cash + Σ(보유수량 × 현재가)`, 거래 시 `cost = 거래금액 × (cost_bps/10000)`. +- **완료 기준**: 단일 종목에서 초기자본 ₩N → NAV 곡선이 정상 추적되고, 비중 행동이 회계와 일치(단위 테스트). +- **테스트**: `tests/test_stom_rl_account_sizing.py` — 매수/부분매도/전량매도 후 cash·nav·position_weight 일치 검증. +- **예상 규모**: 중. **위험**: 부분 매도 평단가·실현손익 회계 버그 → 단위 테스트로 고정. + +### 페이지 3 — 조건식 스크리너 (후보 종목 생성) + +- **목표**: STOM 매수 조건식을 DB에 적용해 **시점별 매수 후보 종목 리스트**를 산출한다. +- **신규 파일**: `stom_rl/condition_screener.py` + - `SafeExpr` — `ast` 기반 화이트리스트 평가기(`forbidden.md` 준수: `import/exec/eval/open/compile/__` 금지, 화이트리스트 변수·연산자만 허용). + - `load_strategy(path)` — `docs/reference/stom_ai_agent/` 형식의 매수전략 텍스트 파싱. + - `screen(db, strategy, at_time)` → 조건 통과 종목코드 리스트 + 그 시점 feature. +- **데이터/스키마**: 입력=STOM DB 종목별 1초 테이블, 시점(시분초). 출력은 포트폴리오 env가 그대로 읽을 수 있게 아래 형태로 고정한다. + + | 컬럼 | 설명 | + |---|---| + | `timestamp` | 조건 평가 시각 | + | `symbol` | 종목코드/테이블명 | + | `condition_id` | 조건식 버전/파일 식별자 | + | `passed` | 조건 통과 여부 | + | `rank_score` | 후보 top-K 정렬 점수(초기엔 거래대금/체결강도 등 rule score) | + | `feature...` | PortfolioEnv 후보 슬롯에 들어갈 수급·호가·가격 feature | + + 금지: 미래 시점 feature/수익률 누수, 임의 `eval`, whitelist 외 변수. +- **완료 기준**: WideV1/V2 조건식을 특정 일자에 적용 → 후보 리스트가 재현 가능하고, 금지 토큰은 거부됨. +- **테스트**: `tests/test_stom_rl_condition_screener.py` — ① 화이트리스트 외 이름/금지 토큰 거부 ② 예제 조건식이 알려진 종목을 통과/차단. +- **예상 규모**: 중. **위험**: `eval` 유혹 → 반드시 AST 파서. DB 29.7GB 풀스캔 비용 → 시점/종목 인덱스로 제한. + +### 페이지 4 — PortfolioEnv + RL (핵심 재설계) + +- **목표**: 자본금으로 조건식 후보 중 여러 종목을 동시 매매하며 NAV를 키우는 환경. +- **신규 파일**: `stom_rl/portfolio_env.py`, 학습 러너 `stom_rl/portfolio_train.py`. + - **상태**: `[현금비율, NAV 변화율, 보유 슬롯(max_positions)별(수익률·보유시간·비중·feature), 후보 슬롯(top_k_candidates)별 feature, candidate_mask, holding_mask]`. SB3 호환을 위해 항상 고정 shape로 padding한다. + - **행동**: 초기 버전은 `Discrete`로 제한한다. 예: `0=hold`, `1..K=후보 i 25% 매수`, `K+1..2K=후보 i 50% 매수`, 그 이후 `보유 j 전량/부분 매도`. invalid action은 mask로 차단하거나 penalty로 기록한다. + - **보상**: `Δnav − 비용 − 슬리피지 − risk_penalty`. 종료 시 final NAV, MDD, 거래비용을 summary artifact에 남긴다. + - **제약**: 현금 한도, 동시 보유 ≤5~10, 종목당 비중 상한, 일일 거래횟수 상한. +- **완료 기준**: 소수 종목(≤5) 동시 보유 학습/평가가 돌고, NAV 곡선·거래 로그·live event가 생성됨. +- **테스트**: `tests/test_stom_rl_portfolio_env.py` — 회계 보존(현금+평가액=nav), 제약 위반 방지, check_env(가능 시). +- **예상 규모**: **대**. **위험**: 행동공간·신용할당 → 조건식으로 후보 축소 + 단순 사이징부터, 점진 확대. + +### 페이지 5 — 포트폴리오 walk-forward 검증 + +- **목표**: 포트폴리오 NAV를 기간 분할로 검증해 과적합/레짐 의존을 판별. +- **신규/수정 파일**: `stom_rl/portfolio_walk_forward.py`(기존 `walk_forward.py` 패턴 재사용) + 기준선(buy&hold, 동일가중, 무거래). +- **완료 기준**: 여러 기간에서 일관되게 기준선 초과 + 비용·MDD 게이트 통과(또는 미달을 데이터로 확인). +- **테스트**: fold 분할·기준선 계산 단위 테스트. +- **예상 규모**: 중. + +### 페이지 6 — Risk gate + Paper replay + +- **목표**: 실전 직전 안전장치와 read-only 시뮬레이션. +- **신규 파일**: `stom_rl/risk_gate.py`(Max DD·연속손실·일일거래한도·종목당 비중상한), `stom_rl/paper_replay.py`(실주문 없는 시점별 재생). +- **완료 기준**: 위험 한도 내에서만 매매, paper replay가 과거 데이터로 NAV를 재현. +- **테스트**: gate 트리거 단위 테스트. +- **예상 규모**: 중. + +### 대시보드 반영 (각 페이지 공통, 선택) + +- 포트폴리오 run도 `webui/rl_runs/`에 sb3 형식 또는 신규 artifact로 남기면 RL Lab 화면 run 목록에 노출된다. +- 전용 뷰(포트폴리오 NAV·보유종목·후보)는 `webui/v2_src/src/`만 수정 → `npm run build`, `/api/*` 신규 금지, SSR marker 보존 원칙 준수. + +### 의존 관계 요약 + +``` +페이지1(feature) ─┐ +페이지2(자본/사이징) ─┼─→ 페이지4(PortfolioEnv) ─→ 페이지5(검증) ─→ 페이지6(risk/paper) +페이지3(조건식 스크리너) ─┘ +``` +페이지 1·2·3은 비교적 독립적이라 병행 가능하고, 4는 1·2·3을 모두 입력으로 받는다. + +--- + +## 11. Ralph 실행 완료 기록 (2026-05-25) + +### 페이지별 완료 산출물 + +| 페이지 | 상태 | 산출물 | 검증 | +|---:|:--:|---|---| +| 0b | ✅ 완료 | ruff unused import 정리 | `py -3.11 -m ruff check ...` green | +| 1 | ✅ 완료 | `STOM_RL_CANONICAL_FEATURES`, `build_stom_rl_feature_frame`, env configurable features | `tests/test_stom_rl_feature_expansion.py` | +| 2 | ✅ 완료 | `PortfolioAccount`, `PositionLot`, `TradeFill` | `tests/test_stom_rl_accounting.py` | +| 2.5 | ✅ 완료 | fixed-slot PortfolioEnv contract, masks, action layout | `tests/test_stom_rl_portfolio_env.py` | +| 3 | ✅ 완료 | safe AST condition screener, CSV/JSONL candidate writer | `tests/test_stom_rl_condition_screener.py` | +| 4 | ✅ 완료 | `portfolio_train --smoke` artifact runner | `.omx/artifacts/portfolio_train_smoke/*` | +| 5 | ✅ 완료 | portfolio walk-forward fold/baseline report | `.omx/artifacts/portfolio_walk_forward_smoke/*` | +| 6 | ✅ 완료 | read-only paper replay + risk trigger logs | `.omx/artifacts/paper_replay_smoke/*` | + +### smoke 명령 (PowerShell, 복사 실행용) + +> 출력 경로는 `.omx\artifacts\...` 이다(이전 문서의 `.omx\artifacts` 오타 수정). 모든 플래그는 실제 argparse와 일치한다. + +```powershell +py -3.11 -m stom_rl.portfolio_train --smoke --output-dir .omx\artifacts\portfolio_train_smoke --max-steps 6 --top-k-candidates 2 --max-positions 1 +py -3.11 -m stom_rl.portfolio_walk_forward --output-dir .omx\artifacts\portfolio_walk_forward_smoke --n-folds 2 --max-steps-per-fold 4 +py -3.11 -m stom_rl.paper_replay --read-only --output-dir .omx\artifacts\paper_replay_smoke --max-steps 6 --max-daily-trades 1 +``` + +### 산출물 파일 (생성 확인됨) + +- `portfolio_train_smoke/`: `portfolio_train_summary.json`, `nav.csv`, `trades.csv`, `actions.csv`, `blocked_actions.json` +- `portfolio_walk_forward_smoke/`: `portfolio_walk_forward_report.json`, `portfolio_walk_forward_folds.csv` +- `paper_replay_smoke/`: `paper_replay_summary.json`, `nav.csv`, `decisions.csv`, `risk_triggers.json` + +### 다음 단계 주의 + +1. 실제 29.7GB STOM DB/조건식 적용은 별도 feature export·condition screener full-scale 실행이 필요하다(연구계획 Page 7~9). +2. 페이지 5의 performance target(baseline 초과)은 별도 성능 목표다: fold별 수익률, MDD, turnover, 거래횟수 리포트로 판단한다(엔지니어링 완료 ≠ alpha 달성). +3. dashboard 연결은 core artifact schema가 안정화된 뒤 별도 UI 작업으로 진행한다. + +--- + +## 12. 강화학습 실행 연구 판단 문서 + +상세 문서: `docs/stom_rl_rl_execution_research_plan_2026-05-25.md` + +핵심 결론: + +- 조건식을 강화학습이 직접 생성하는 구조는 초기 단계에서 비추천한다. +- STOM DB 변수와 파생 feature를 RL state로 넣는 것이 더 정석적이다. +- 조건식은 후보 종목 universe/top-K를 줄이는 screener/filter로 사용한다. +- RL은 후보 중 매수/매도/보유/사이징과 청산 타이밍을 학습한다. +- 조건식 자동 생성은 Page 7~14 안정화 이후 별도 Page 15 연구로 분리한다. + +현재 연구는 실제 DB 기반 강화학습 실행 준비에는 충분하지만, 최종 수익성 있는 RL 전략 완성에는 실제 DB 실행, 후보 CSV 생성, 알고리즘/보상 연구, full walk-forward, paper replay, 성능 최적화가 추가로 필요하다. diff --git a/docs/stom_rl_predictability_gate_2026-05-30.md b/docs/stom_rl_predictability_gate_2026-05-30.md new file mode 100644 index 000000000..4a74bb1f6 --- /dev/null +++ b/docs/stom_rl_predictability_gate_2026-05-30.md @@ -0,0 +1,82 @@ +# P0+P1 예측 게이트 결과 — 딥RL go/no-go (조건부) + +- 작성일: **2026-05-30 KST** / 브랜치: `feature/stom-rl-lab` +- 상위: `docs/stom_rl_deeprl_opening20min_design_2026-05-29.md`(설계), `docs/stom_rl_session_progress_2026-05-29.md` +- 구현: `stom_rl/microstructure_features.py`(순수 인과피처) + `stom_rl/predictability_probe.py`(P0 MinTRL + P1 walk-forward 프로브) + 테스트 16개 +- 대상: 시초 갭상승 `ts_imb` — **RULE strategy, NOT reinforcement learning.** 수익 주장 아님. + +--- + +## 0. 한 줄 결론 (정직) + +**대표본(full universe)에서 마이크로구조가 60초 forward return을 OOS·미학습종목·전 기간에서 일관되게 예측하는 *modest하지만 robust한 ranking 신호(IC≈0.10)*가 처음으로 드러났다. 그러나 "GO" 판정은 (1) 정책 net이 baseline-relative가 아니고 (2) idealized 체결을 쓰며 (3) DSR이 하드 파라미터에 따라 뒤집히는 세 가지 이유로 과대평가돼 있다. 정직한 상태는 "신호는 실재, 룰 대비 증분·현실 체결 후 수익성은 미입증".** + +--- + +## 1. 결과: bounded vs full + +| 지표 | bounded(120종목, N=235) | **full universe(N=5,173, 569k 샘플)** | +|---|---:|---:| +| rank-IC ridge / gbm | +0.027 / +0.060 | **+0.075 / +0.104** | +| IC 95% CI 0 제외 | ridge✗ / gbm✓ | **둘 다 ✓** | +| per-boundary IC 안정성 | gbm +0.04~0.07 | ridge +0.074~0.079 / **gbm +0.099~0.105 (5경계 안정)** | +| **symbol-disjoint IC(미학습 종목)** | — | **ridge 0.067 / gbm 0.106** | +| 임계정책 net/trade | +0.18% (Sharpe 0.06) | **+0.44%/+0.38% (Sharpe ~0.15, N≈1,700)** | +| DSR | ≈0 → NO-GO | 1.000 → GO | + +→ bounded NO-GO는 **검정력 부족**(N=235)이었다. 대표본에서 ranking 신호가 실재함이 분명해졌고, **미학습 종목에서도 유지**(0.067~0.106)되어 ticker 암기가 아니다. per-boundary 안정성으로 단일기간 우연도 아니다. **이 랩 최초의 hard-to-dismiss 양성 신호.** + +--- + +## 2. ⚠️ "GO"가 과대평가된 3가지 이유 + +### (1) 정책 net이 baseline-relative가 아님 — 가장 중요 +대상 종목은 전부 갭상승 모멘텀(등락율≥2·체결강도≥100·호가 imbalance≥0.5)이라, forward 60s return은 **무조건부로 양(+) 드리프트**를 가진다. 임계정책 +0.4%는 모델의 *선택력*이 아니라 **룰이 이미 harvest하는 그 드리프트**를 대부분 반영한다. 설계가 명시한 **baseline-relative(룰/항상진입 대비)** 를 프로브가 생략 → **룰 대비 *증분* 가치는 미입증.** IC(랭킹)는 실재하지만, 랭킹이 *증분 net*으로 이어지는지는 별개다. + +### (2) idealized 체결 +라벨 = 현재가 forward return − 평탄 23bp. **마켓에이블 체결(매수=매도호가1, 매도=매수호가1) + 스프레드·시장충격 미반영.** 60초 스캘프는 스프레드를 두 번 건너고 회전이 높아, 실제 net은 +0.4%보다 **상당히 낮을 가능성**이 크다. 설계는 idealized 의존 결과를 폐기 대상으로 명시. + +### (3) DSR=1.000은 파라미터 artifact +DSR의 `sharpe_variance`를 프로브 내 10개 config(같은 전략의 중첩 경계, 상호 상관)에서 유도 → 0.0002로 과소 → expected-max-Sharpe 바가 trivially 낮아 어떤 양수 Sharpe도 통과. 반대로 하드코딩 0.25는 바를 0.79로 만들어 NO-GO. **즉 verdict가 하드 파라미터로 뒤집히므로 DSR은 여기서 신뢰 불가.** 믿을 것은 raw 유의성: 정책 Sharpe 0.15·N≈1,700 → t≈6.7(다중검정 보정 후에도 raw로는 유의), IC CI 매우 타이트. **단 (1)·(2) 때문에 이 유의성이 "증분·현실 net"의 유의성은 아니다.** + +--- + +## 3. 정직한 판정 + +**조건부 — "신호 실재, 증분·현실 수익성 미입증".** bounded NO-GO도 full GO도 부분 artifact이고 진실은 그 사이다: +- ✅ 마이크로구조의 60초 ranking 예측력은 **실재·robust**(IC≈0.10, OOS·미학습종목·전기간). 딥(gbm 0.10) > 선형(ridge 0.075) → 깊이가 약간 기여. +- ❓ 그 신호가 **룰의 고정진입을 마켓에이블 체결 후에 이기는지**(baseline-relative + de-idealized)는 미검정. + +--- + +## 4. 결정적 다음 게이트 (이게 통과해야 RL로 감) + +**baseline-relative + de-idealized 재검정**: +1. 라벨/정책을 **마켓에이블 체결**로: 진입=매도호가1, 청산=매수호가1(또는 보수적 슬리피지), 23bp 위에 스프레드·충격 반영. +2. **baseline-relative**: 모델 타이밍/선택의 net을 **룰의 고정 09:00 진입 net과 paired-difference**로 비교. 증분이 양수인지. +3. **proper deflation**: 외부 합리적 sharpe_variance(per-trade 스케일 SD~0.05~0.1) + 랩 전체 trial 원장 기반 Harvey-Liu haircut. +4. linear-vs-deep 사전등록(설계 P2). +- **통과 시에만** RL 형식(A2 메타라벨링 → A3 표현학습 → A4 offline IQL)으로. 미통과면 신호는 룰이 이미 먹는 드리프트로 귀결. + +--- + +## 5. 재현 +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.predictability_probe --max-symbols 0 # full +py -3.11 -X utf8 -m stom_rl.predictability_probe --max-symbols 120 # bounded +py -3.11 -m pytest tests/test_stom_rl_microstructure_features.py tests/test_stom_rl_predictability_probe.py -q # 16 passed +``` +산출: `.omx/artifacts/predictability/summary_full.json`. 코드리뷰 APPROVE(누설 없음 구조적 확인; 본 문서의 3개 한계는 프로브 설계상의 알려진 caveat). + +--- + +## 6. 페이지 트랙 +| | 상태 | +|---|---| +| R0 딥리서치 / R1·R1b(청산RL 폐기) / B / C / D | ✅ | +| **P0+P1 예측 게이트** | ✅ **조건부 — robust ranking 신호 발견, 증분·현실 수익성 미입증** | +| **P1b baseline-relative + de-idealized 게이트** | ⬜ **다음 — 결정적** | +| A2/A3/A4 RL | 🔒 P1b 통과 시에만 | + +> 정직성: RULE NOT RL. 이 결과는 사전확률을 **상향**(실재 신호)하되 "RL이 돈 번다"는 아니다. 증분·현실 체결 게이트가 진짜 시험이다. diff --git a/docs/stom_rl_realtime_learning_dashboard_implementation_2026-05-23.md b/docs/stom_rl_realtime_learning_dashboard_implementation_2026-05-23.md new file mode 100644 index 000000000..27b1b2a32 --- /dev/null +++ b/docs/stom_rl_realtime_learning_dashboard_implementation_2026-05-23.md @@ -0,0 +1,75 @@ +# STOM ??? ???? ??? ?? ??? + +???: 2026-05-23 KST +???: `feature/stom-rl-lab` +?? ??: `.omx/plans/ralplan-stom-rl-realtime-goal-2026-05-23.md` + +## ?? ?? + +??? STOM ???? ???? 1? ??? JSONL event log + Flask API + Svelte polling ??? ????. ? ??? historical replay / SB3 smoke / short training ???? ???? ?? ?? ??? ???? ???. + +## ?? ?? + +| ?? | ?? | +|---|---| +| RL event schema | `stom_rl/rl_events.py` ??. `RlLiveEvent`, JSONL writer/reader, summary helper ?? | +| SB3 smoke | `stom_rl/sb3_smoke.py`? train/eval event writer ??, `rl_live_events.jsonl`, `rl_live_summary.json` ?? | +| Flask API | `/api/rl/runs//events` ??, dashboard helper?? JSONL tail ?? ?? | +| Svelte API | `api.rlEvents()`? live event response ?? ?? | +| RL Lab UI | `??? RL` ? ??, reward stream, action distribution, event log, ??? ?? safety banner ?? | +| ??? | live event unit test, dashboard API event test, SB3 smoke event artifact test ??/?? | +| ?? ?? | `webui/static/v2/dist` ??? | + +## ?? artifact + +SB3 smoke ?? ? ?? ??? ????. + +```text +webui/rl_runs/stom_1s_2025_sb3_smoke/rl_live_events.jsonl +webui/rl_runs/stom_1s_2025_sb3_smoke/rl_live_summary.json +webui/rl_runs/stom_1s_2025_sb3_smoke/sb3_smoke_summary.json +``` + +?? ?? ?? 256 timestep smoke ?? live event? 1,536? ?????. + +| phase | count | +|---|---:| +| train | 512 | +| eval | 1,024 | + +## ?? ??? + +```powershell +py -3.11 -m stom_rl.sb3_smoke --total-timesteps 256 --max-eval-episodes 2 --max-eval-steps-per-episode 256 --device auto +py -3.11 -m stom_rl.performance_leaderboard +py -3.11 -m pytest tests -q +Set-Location "D:\Chanil_Park\Project\Programming\Kronos\webui\v2_src" +npm run build +``` + +## ?? ?? + +- ? ??? ????? ??? ?? ??? ????. +- 1? ??? historical replay / smoke / short training only?. +- ?? ??, ????, live execution? ???? ???. + +## ?? ?? + +| ?? | ?? | +|---|---| +| targeted pytest | `7 passed` | +| full tests | `99 passed, 2 skipped, 2 warnings` | +| ruff changed files | passed | +| mypy core changed files | passed: `stom_rl/rl_events.py`, `stom_rl/sb3_smoke.py`, `webui/rl_dashboard.py` | +| SB3 smoke | passed, CUDA available, RTX 4080 SUPER, live_event_count 1536 | +| performance leaderboard | passed, row_count 9 | +| Svelte build | passed, ?? DocsTab/ForecastWorkbench warnings 4? ?? | +| local HTTP API smoke | passed: `webui_api_smoke_ok events=2 runs=3` | +| browser-use | Node REPL browser tool? ???? ?? in-app browser ??? ???? Flask test_client/API smoke + Svelte build + ?? HTTP API smoke? ?? | + +## ?? ?? ?? + +1. browser-use Node REPL? ???? ???? ?? ?? ?? ?? +2. YouTube ?? ?? ? `$visual-ralph`? UI polish ?? +3. JSONL polling? ???? SSE endpoint? 2?? ?? +4. longer run?? event volume/tail pagination ?? ?? diff --git a/docs/stom_rl_realtime_learning_dashboard_plan_2026-05-23.md b/docs/stom_rl_realtime_learning_dashboard_plan_2026-05-23.md new file mode 100644 index 000000000..0da33840c --- /dev/null +++ b/docs/stom_rl_realtime_learning_dashboard_plan_2026-05-23.md @@ -0,0 +1,186 @@ +# STOM ??? ???? ???? ?? + +???: 2026-05-23 KST +???: `feature/stom-rl-lab` +?? ??: `58fbb9e STOM SB3 smoke ?? ??? ?? ?? ??? ??` +?? ??: `goal`/`ultragoal`? ???? ??, ?? git commit ???? ????. + +--- + +## 1. ?? + +?? STOM RL ???? ?? 7? ??? Gymnasium/Stable-Baselines3 ?? ??? ????. ?? ??? DQN/PPO ?? ??? ?? ?? ????? ???? ?? ???, ?? ? ???? ???? ???? ? ?????? ????? ???? ??? ???? ???. + +???? ??? ??(`https://www.youtube.com/watch?v=nqXIgv_AYuQ`)?? ??? ???? ??? ????, ?? ?? ????? ????. ????? ??? ??. + +1. ?? ??? ?? replay/smoke ??? ????? ?????. +2. step, action, reward, equity, position, loss, exploration ??? ???? ????. +3. performance leaderboard? ? ????? ?? ??? ??? ????. +4. ?? paper trading ?? live inference? ??? ? ?? ??? ??? ???. + +--- + +## 2. ?? ??? + +| ?? | ?? ?? | ??? | ?? | +|---|---:|---:|---| +| ?? RL ??? | 7/7 ?? | 100% | contextual bandit, cost gate, leaderboard, ? ?? ?? | +| Gymnasium/SB3 ?? ?? | 8/8 ?? | 100% | Gymnasium/SB3 ?? ??, `check_env`, DQN/PPO smoke ?? | +| ??: ??? ???? ??? | 0/6 ?? ?? | 0% | ? ?? ?? ?? ?? ?? | +| ?? ??? ?? | 15/21 ?? ?? | ? 71% | ?? 15?? ?? + ?? 6?? ?? | +| ???/?? ?? | 8/9 ?? ?? | ? 89% | ?? ??? RL ???/??? ?? | + +--- + +## 3. ? ???? ???/?? ?? + +| ???/? | ?? ?? | ???? ?? ?? | ?? ??? | ?? ?? ?? | +|---|---|---|---:|---| +| `RLLabTab.svelte` | RL ?? ?? ?? ??? | contextual bandit, DQN smoke, PPO smoke, performance leaderboard? ????. | 100% | ??? ?? ?? ??, episode reward, action ??, equity curve? ????. | +| `LiveTrainingTab.svelte` | ?? ?? ?? ?? | ?? ??/?? ???? ??? ???, RL ??? ???? ??? ?? ???. | RL ?? ?? 30% | ??? ???? ?? ??? ??? ?? ???? ????. | +| `ArtifactsModelsTab.svelte` | ??? ??/??? ?? | SB3 `.zip`, summary JSON, leaderboard artifact? ??? ? ??. | 70% | DQN/PPO ?? ??, smoke report, best model ??? ????. | +| `HistoryRunsTab.svelte` | ?? ?? ?? ?? | RL run history, ?? ?? ??, ??? ?? ??? ????. | 70% | episode/run ?? ????? RL metric ??? ????. | +| `ForecastWorkbenchTab.svelte` | ??/?? ???? | RL ?? ??? ??/?/?? ?? ??? ?? ? ? ??. | 60% | ?? action overlay, reward replay, buy/sell marker? ?? ??? ??. | +| `StomDiagnosticsTab.svelte` | STOM ?? | ??? ??, env readiness, ?? ??? ??? ????. | 80% | `check_env`, SB3 ??, CUDA ?? ??? ????. | +| `SystemHealthTab.svelte` | ??? ?? ?? | torch/CUDA/GPU/API ??? ????. | 80% | RTX 4080 SUPER, torch CUDA, SB3/Gymnasium ??? ????? ????. | +| `SettingsTab.svelte` | ?? | timestep, device, eval episode, seed ?? ?? ??? ?? ? ??. | 50% | RL smoke/live training ?? UI? ????. | +| `DocsTab.svelte` | ?? | ???? ????? ????. | 60% | ???? RL ???, ?DQN/PPO ?? ???, ???? ?? ?? ??? ??? ????. | + +--- + +## 4. ???? ???? + +| ?? | ?? | ?? ?? | ??? | ?? ??/??? | +|---|---|---|---:|---| +| 1. ??? ?? | STOM tick/episode manifest? ????. | ?? | 100% | `stom_rl/episode_manifest.py` | +| 2. Trading Env | ??, ??, ??, ?? ??? ????. | ?? | 100% | `stom_rl/trading_env.py` | +| 3. ?? ??? | ?? ??? ?? ? ??? ????. | ?? | 100% | `stom_rl/cost_gate.py` | +| 4. ??? ?? | no-trade, buy-and-hold ?? ????. | ?? | 100% | `stom_rl/baselines.py`, `stom_rl/leaderboard.py` | +| 5. Contextual Bandit | ?? ??? ??/????. | ?? | 100% | `stom_rl/contextual_bandit.py` | +| 6. Gymnasium adapter | SB3 ?? Gymnasium wrapper? ????. | ?? | 100% | `stom_rl/sb3_adapter.py` | +| 7. `check_env` | SB3 custom env ??? ????. | ?? | 100% | `stom_rl/sb3_smoke.py` | +| 8. DQN smoke | DQN? ?? timestep ?? ?? ????. | ?? | 100% | `webui/rl_runs/.../summary.json` | +| 9. PPO smoke | PPO? ?? timestep ?? ?? ????. | ?? | 100% | `webui/rl_runs/.../summary.json` | +| 10. Performance leaderboard | RL ??? ?? ?? ???? ???. | ?? | 100% | `stom_rl/performance_leaderboard.py` | +| 11. ? ???? ?? | SB3 smoke ??? RL Lab? ????. | ?? | 100% | `webui/rl_dashboard.py`, `RLLabTab.svelte` | +| 12. ??? ?? ??? | step/reward/action/loss? ????? ????. | ?? | 0% | ??: trainer event writer | +| 13. ??? UI | ???? ?? ??? ???? ???? ????. | ?? | 0% | ??: live RL panel | +| 14. ?? ?? ??? ?? | ?? ?? ?? ???? ????. | ?? | 0% | ??: visual verdict ?? | +| 15. ?? ??/?? | smoke? ?? longer run ??? ????. | ?? | 0% | ??: validation/full run artifacts | + +--- + +## 5. ?? ?? ??: ??? ???? ??? + +| ?? ?? | ?? | ??? | ?? ?? | +|---|---|---|---| +| N1. ??/???? ?? | YouTube ???? ?? ??? ???? ????. | ?? ?? UI ????, ?? ??? | ?? 3~10? ?? timecode ??? ????. ??? ??? ??? ??? RL ????? ????. | +| N2. RL event schema | ?? ? step ?? ???? ????. | `reward`, `action`, `position`, `equity`, `loss`, `epsilon/entropy`? ?? JSONL/SSE schema | ?? ???? ???? schema validation ???? ????. | +| N3. Trainer emitter | DQN/PPO smoke/?? ???? ???? ????. | JSONL ?? SSE stream writer | smoke ?? ? ??? ??? ???? ??? summary? ????. | +| N4. Flask API | ??? ??? ???? ????. | `/api/rl/live/...` ?? API | API ?? ???? ????. | +| N5. Svelte Live RL page | ??? ??/??/??/???? ????. | RL live panel ?? `LiveTrainingTab` ?? | `npm run build`? ???? smoke ??? ????. | +| N6. QA + commit | ???, ??, ??, ??? ???. | ??? Lore commit | `pytest`, `ruff`, `mypy`, `npm run build`, `$code-review` ??? ????. | + +--- + +## 6. ?? ?? + +| ?? | ?? ?? | +|---|---| +| ??? | ??? ??? ?? historical replay/smoke ??? ????, ?? ?? ??? ???? ???. | +| ?? ??? | DQN/PPO ?? ? ?? `step`, `episode`, `action`, `reward`, `net`, `position`, `equity`? ????. | +| ??? | ?? action, reward curve, equity curve, trade marker, ?? ??? ? ?? 4?? ????. | +| ???? ?? | RL Lab ?? Live Training?? ?? SB3 smoke/live run? ?? ? ??. | +| leaderboard ?? | performance leaderboard? smoke/live summary? ?? ?? ??? ???. | +| ?? | Python tests, changed-file ruff/mypy, Svelte build? ????. | +| ?? | ?? git commit?? ??? Lore commit ???? ???. | + +--- + +## 7. ?? OMX ?? ?? + +`goal`/`ultragoal` ??? ????. + +| ?? | ?? | ?? | ?? ???? | +|---:|---|---|---| +| 1 | `$ralplan` | ??? RL ?? ??? ????? ?? | `$ralplan --consensus ??? STOM ???? ??? ???? ???? ?? ??? ?????. goal/ultragoal? ???? ?? ?? git commit ???? ?????.` | +| 2 | `$analyze` | ?? ?? ??? ?? ? ??? | `$analyze RLLabTab, LiveTrainingTab, rl_dashboard, sb3_smoke, performance_leaderboard? ??? ???? ??? ??????.` | +| 3 | `$team` | API, trainer, Svelte, test? ?? ?? | `$team ??? ???? ??? ??? ?????. trainer event emitter, Flask API, Svelte live panel, tests/build ???? ?? ??????. goal/ultragoal? ?????.` | +| 4 | `$visual-ralph` | ??? ?? ? UI? ?? ?? | `$visual-ralph ??? ?? ???? ??? ??? RL ?? ???? ??? ??????. visual verdict? ?? ??????.` | +| 5 | `$ultraqa` | ??? QA? e2e ?? | `$ultraqa RL live dashboard, SB3 smoke ?? ??, leaderboard ??? ??? ??? ??????.` | +| 6 | `$code-review` | ?? ?? ?? | `$code-review ??? ???? ??? ????? ??????. ???, ?????, ??? ??, ??? ???? ???? ?? ??????.` | + +--- + +## 8. ?? PowerShell ??? + +?? ??: + +```powershell +Set-Location "D:\Chanil_Park\Project\Programming\Kronos" +git status --short +git log --oneline -5 +``` + +???/??? ??: + +```powershell +py -3.11 -m pip install -r requirements.txt +py -3.11 -c "import torch, gymnasium, stable_baselines3 as sb3; print(torch.__version__, torch.cuda.is_available(), gymnasium.__version__, sb3.__version__)" +``` + +SB3 smoke? leaderboard ???: + +```powershell +py -3.11 -m stom_rl.sb3_smoke --total-timesteps 256 --max-eval-episodes 2 --max-eval-steps-per-episode 256 --device auto +py -3.11 -m stom_rl.performance_leaderboard +``` + +??: + +```powershell +py -3.11 -m pytest tests -q +py -3.11 -m ruff check stom_rl webui tests +``` + +? ??: + +```powershell +Set-Location "D:\Chanil_Park\Project\Programming\Kronos\webui 2_src" +npm run build +``` + +? ??: + +```powershell +Set-Location "D:\Chanil_Park\Project\Programming\Kronos" +$env:KRONOS_WEBUI_OPEN_BROWSER="0" +$env:KRONOS_WEBUI_PORT="7070" +py -3.11 webui +un.py +``` + +?? ??: + +```powershell +git add docs/stom_rl_realtime_learning_dashboard_plan_2026-05-23.md +git commit -m "STOM ??? ???? ?? ??? ????" ` + -m "DQN/PPO smoke ??? ?? ??? ??? ?? ???? ? ???? ????? ???? ???, ???? ????, ?? ?? ??, OMX ?? ??? ?????." ` + -m "Constraint: goal/ultragoal ?? ?? git commit ???? ??" ` + -m "Confidence: high" ` + -m "Scope-risk: narrow" ` + -m "Tested: git diff --check" ` + -m "Not-tested: ?? ??? ???? ??? ???? ??" +``` + +--- + +## 9. ?? ?? ?? + +1. ? ??? ???? `$ralplan --consensus`? ??? ?? ??? ????? ???. +2. `$analyze`? ?? ?? ?? ??? ?? ??? ?? ????. +3. `$team`?? trainer event emitter, Flask API, Svelte live panel, tests/build lane? ?? ????. +4. ?? ??? ??? `$visual-ralph`? UI? ?? ????. +5. `pytest`, `ruff`, `mypy`, `npm run build`? ????. +6. `$ultraqa`? `$code-review`? QA/??? ????. +7. ??? Lore commit?? ?? git commit? ???. diff --git a/docs/stom_rl_research_report_2026-05-27.md b/docs/stom_rl_research_report_2026-05-27.md new file mode 100644 index 000000000..ab4da233a --- /dev/null +++ b/docs/stom_rl_research_report_2026-05-27.md @@ -0,0 +1,230 @@ +# STOM 강화학습 포트폴리오 — 종합 연구보고서 + +- 작성일: 2026-05-27 +- 브랜치: `feature/stom-rl-lab` +- 성격: **전체 여정 종합 + 노하우 + 검증된 인프라 인벤토리 + 다음 연구 재시작 준비 문서** +- 전제: 이 RL 실험실은 **Kronos 예측 모델과 독립**. 조건식 문법/변수만 `docs/reference/stom_ai_agent/`를 단일 진실 공급원으로 참고. + +--- + +## 0. Executive Summary (결론 먼저) + +1. **엔지니어링은 완성됐다.** 실데이터(STOM 1초봉 29.7GB DB) → feature export → 다종목 시간동기화 panel → 조건식 후보 → 포트폴리오 env(T+1 체결·비용인지 reward) → **실제 SB3 deep-RL 학습** → **train/test holdout(n_folds≥5)** → shuffle/누수 가드 → supervised-ranker 비교 → read-only paper replay → 실시간 대시보드까지, **검증된 전 과정 파이프라인**이 존재한다. (테스트 150 passed, Architect THOROUGH APPROVE) + +2. **그러나 알파(수익)는 없다 — 비자문(non-advisory)으로 엄정히 증명됐다.** 111개 co-dated 종목·n_folds=5·비용 25bps·shuffle 테스트 하에서, 학습된 PPO도, 단순 supervised ranker도 **"아무것도 안 하기(no_trade)"를 이기지 못한다.** 명목상의 fold 우세는 shuffle에서 붕괴한다. **병목은 알고리즘이 아니라 신호/데이터다.** + +3. **그래서 deep-RL을 이 데이터로 더 파는 것은 가치가 없다(STOP).** 알파를 계속 추구하려면 **알고리즘이 아니라 신호를 바꿔야** 한다(보유 horizon 확대, feature/데이터 확장). 자세한 로드맵은 §7. + +4. **인프라 충분성: 충분하다.** 재시작에 필요한 모든 코어 모듈이 구축·검증돼 있어, 다음 연구는 "처음부터"가 아니라 "신호/데이터 레이어 교체 + 기존 평가 하니스 재사용"으로 시작할 수 있다. 상세 §6. + +> 한 줄: **"수익 모델 완성"이 아니라 "이 데이터에 거래 가능한 알파가 없음을, 거짓양성 없이 증명하는 신뢰성 있는 연구 인프라 완성"** 이 이번 성과다. + +--- + +## 1. 프로젝트 목표 + +초기 자본금에서 시작해, 조건식에 걸린 여러 종목을 후보로 받아 동시에 사고팔며 총자산(NAV)을 키우는 **포트폴리오 강화학습**. 시간 조절(매수/매도/보유시간)을 RL이 학습한다. 조건식은 후보 universe를 좁히는 screener로 쓰고(RL이 직접 생성하지 않음), RL은 후보 중 선택·비중·청산을 학습한다. + +--- + +## 2. 전체 여정 (단계별, 모두 커밋·검증) + +### 2.1 Stage 1 — 단일 종목 RL 플랫폼 (기반) +- 단일 종목 1초봉 trading env, SB3 DQN/PPO 학습, eval-only, walk-forward, baseline/bandit/cost-gate, 대시보드. +- **결과**: 50k DQN이 100 episode에서 +1.34% vs buy&hold +0.51% — 약간 초과하나 **regime-sensitive**, 50k→100k 개선 없음. (안정적 수익 아님) +- 베이스라인 커밋 `06e1038` (전체 정리 + 문서 mojibake/진행률 모순 수정 포함). + +### 2.2 Stage 2 — 포트폴리오 RL 엔지니어링 (Page 7~16) +| Page | 내용 | 커밋 | +|---|---|---| +| 7 | 실데이터 `export-stom-rl` 경로 (14 canonical feature) | `c18e0e6` | +| 7.5 | 다종목 1초 시간동기화 panel (`merge_asof` backward, 누수 가드) | `843a0f5` | +| 8 | 조건식 → AST whitelist rule JSON (WideV1/V2/호가압력) | `f791e99` | +| 9 | candidate CSV + **T+1 체결 계약**(price@T / fill_price@T+1) + rank per-symbol | `21405d3` | +| 10 | env 체결 T+1 수정 + 결정론 학습 smoke | `7107d24` | +| 10.5 | thin-slice 조기 성능 read (go/no-go) | `2464d55` | +| 11 | walk-forward **expanding-window holdout** + leakage canary | `2709c7c` | +| 12 | paper replay (read-only, blocked-action reason codes) | `709d106` | +| 13 | 포트폴리오 run 대시보드 연결 (신규 /api 없이) | `95ff7f8` | +| 14 | 성능 최적화(트랙 B) — 비용 격차 문서화 | `3401730` | +| follow-up A | 종목코드 선행 0 strip 수정 (`000250` 보존) | `7fe3581` | +| 16 | full-universe 실행 하니스 (per-session checkpoint/resume) | `0a9a67e` | +| 17 | cost-aware 정책 실험 (train-fit/test-eval) | `b09857f` | +| live | 포트폴리오 step별 live event + 대시보드 follow/replay | `fca9e85` | + +### 2.3 Deep-RL 단계 — 실제 학습형 모델 + 정직한 판정 +| 단계 | 내용 | 커밋 | +|---|---|---| +| C-0 | DB feature 가용성 probe (등락율/시가총액/고저평균/체결강도 실재 ≥99.84%) | `fe632cb` | +| C | canonical feature 14→18 확장 | `3976403` | +| A | `PortfolioEnv` → Gymnasium 어댑터 (check_env 통과, `action_masks()`) | `84c91e9` | +| B | 실제 SB3 PPO 학습(결정론 핀) + `trained_ppo` seam 배선 + `supervised_ranker` | `d2104cc` | +| E | 정직한 2트랙 verdict | `dd5fd7a` | +| D | **ranker-first 비자문 신호검정** (n_folds=5, 111종목) + shuffle 테스트 구현 | `ca74c78` | + +--- + +## 3. 핵심 발견 (정직한 과학적 결과) + +### 3.1 비자문 신호검정 (가장 결정적) +세션 2022-08-30, **111개 co-dated 종목**, n_folds=5, 11,336 후보, cost_bps=25: + +| 정책 | 평균 수익% | +|---|---| +| no_trade | **0.000** | +| equal_weight / buy&hold | −0.121 | +| supervised_ranker | −0.147 | +| rule_baseline | −1.416 | + +- ranker가 equal_weight도 no_trade도 **못 이김.** 명목 3/5 fold 우세는 **shuffle(rank_score+feature overwrite)에서 1~2/5로 붕괴** → 노이즈. +- M=1(다중가설 보정 불요), ≥2-seed 일치, disjoint holdout·누수 canary·shuffle 전부 PASS. + +### 3.2 학습형 RL (advisory, 소규모) +- trained PPO −1.55% ≤ ranker −0.10% ≤ no_trade 0%. PPO는 과회전으로 손실. +- **invalid-action 96.9%** → 마스킹 없는 PPO는 대부분-무효 행동공간을 못 배움 → MaskablePPO 필요(트리거 발동). 단, ranker도 못 이기므로 **알고리즘 교체로 해결될 문제가 아님.** + +### 3.3 종합 해석 +- **병목 = 신호/데이터.** 1초 개장 구간의 churn은 거래비용(25bps)에 묶여 있고, 단순 supervised 모델조차 selection 엣지가 없다. +- supervised_ranker가 **equal_weight로 퇴화**(byte-identical)한 것이 무신호의 추가 증거. + +--- + +## 4. 노하우 / 방법론 교훈 (재사용 가치 최상) + +이번 연구의 진짜 자산은 "수익 모델"이 아니라 **거짓 알파를 만들지 않는 신뢰성 있는 검증 방법론**이다. + +### 4.1 정직성 설계 (가장 중요) +- **2트랙 분리**: 엔지니어링(게이트 가능) vs 알파(연구 결과, 미보장). "문서화된 음성"도 정당한 완료. +- **거짓 알파 방지(다중가설/과탐색)**: ① 1차 config + 후보 config 집합을 결과 보기 전 **pre-register** ② 시도 수 `M` 기록 + Bonferroni/BH 보정 ③ **n_folds≥5 power floor** — 미만이면 알파 "주장 금지"(advisory-only). 작은 N에서 "과반 fold 우세"는 동전던지기임을 정량적으로 차단. +- **shuffle/permutation 테스트**: rank_score AND 모든 feature를 timestamp 내 overwrite-shuffle → 선택 신호 파괴 후에도 엣지가 남으면 그건 누수/데이터마이닝. (이게 명목 3/5 우세를 깨뜨림) +- **supervised-ranker를 falsifiable floor로**: "RL이 단순 ranker도 못 이기면 RL 포기" — "왜 RL인가"를 경험적으로 검증. RL의 정당성 자체를 falsifiable하게. + +### 4.2 누수(look-ahead) 차단 +- **T+1 체결 계약**: 결정=bar close@T, 체결=차기봉(T+1) `fill_price`. env가 같은 봉 체결하던 버그 수정. +- **as-of backward join**: `merge_asof(direction="backward")` — 관측 없으면 NaN, 미래값 절대 fill 금지. +- **expanding-window holdout**: fold N 학습 → disjoint·strictly-later fold N+1 평가. 런타임 hard guard. +- **leakage canary(2종)**: ① 미래 컬럼 주입 시 결과 불변(backward-only 증명) ② fill_price 손상 시 결과 변동(canary가 실제로 누수를 잡음). + **어댑터-레벨 canary**(SB3 wrapper 별도). +- **causal feature**: trailing 윈도우(예 trade_strength_avg_n)는 ≤T만 사용, 미래행 추가에도 불변. + +### 4.3 데이터/환경 노하우 +- **인코딩**: DB 한글 컬럼은 **UTF-8**(cp949 아님). 콘솔 표시 깨짐은 artifact일 뿐. +- **종목코드 선행 0**: `000250`은 int 파싱 시 `250`으로 깨짐 → `symbol_norm` 헬퍼(전자리수만 zfill(6))로 보존. DB 테이블명 매칭에 필수. +- **종목별 기록일 disjoint**: cross-symbol panel은 **같은 세션일 공유 종목**으로만 구성. (co-dated가 아니면 전부 NaN) +- **n_folds는 시간축 분할**: 종목 수가 아니라 시간창을 넓혀 n_folds≥5 달성 가능(full-universe 없이도 비자문 검정 가능 — 이번 핵심 통찰). +- **gate-up scale**: smoke fixture → 1종목 30분 → 소수 종목 → full. DB 풀스캔 금지. + +### 4.4 학습 재현성 +- **결정론 핀**: `torch.use_deterministic_algorithms(True)`, `set_num_threads(1)`, `device=cpu`, 시드 고정, `atol=1e-6/rtol=1e-5` 재현 검증. +- **action masking**: 포트폴리오 행동공간은 대부분-무효 → 마스킹 없는 PPO는 비효율. `action_mask()` → 어댑터 `action_masks()` → (필요 시) MaskablePPO. + +### 4.5 운영 제약 (지킴) +- 일반 git commit(한글), 조건식 `eval` 금지(AST whitelist), webui 신규 `/api` 금지·SSR marker 보존, `rl_runs`/`.omx` 비커밋, `py -3.11`(기본 3.13은 gymnasium/sb3 미설치). + +--- + +## 5. (의도적으로 미실행) Stage D full-universe +- full-universe(2427종목) **multi-hour RL 학습**은 실행하지 않았다. 이유: 비자문 신호검정이 **음성**이라(단순 ranker조차 엣지 없음) ROI가 없음. 하니스(`full_universe.py`)는 검증·준비됨 — 신호 방향이 바뀌면 즉시 launch 가능(`nohup … full_universe --resume &`). + +--- + +## 6. 검증된 인프라 인벤토리 (재시작 준비 상태) + +> **질문 답변: 인프라는 충분하다.** 다음 연구는 신호/데이터 레이어만 교체하고 아래를 재사용하면 된다. + +### 6.1 데이터 레이어 ✅ +| 모듈 | 기능 | 상태 | +|---|---|---| +| `finetune/qlib_stom_pipeline.py` `export-stom-rl` | DB → 18 canonical feature CSV (UTF-8, 결측/스케일 처리) | ✅ | +| `stom_rl/panel_join.py` | 다종목 as-of(backward) 시간동기화 panel + 메모리 산식 | ✅ | +| `stom_rl/candidate_gen.py` | panel+rule → candidate CSV (T+1 fill, per-symbol rank) | ✅ | +| `stom_rl/condition_screener.py` | AST whitelist 조건식 평가 (eval 금지) + rule JSON | ✅ | +| `stom_rl/full_universe.py` | per-session enumeration + checkpoint/resume 러너 | ✅ | +| `stom_rl/symbol_norm.py` | 종목코드 정규화(zfill6) | ✅ | + +### 6.2 환경·회계 ✅ +| 모듈 | 기능 | +|---|---| +| `stom_rl/portfolio_env.py` | fixed top-K/Discrete, action_mask, **T+1 fill**, **cost-aware reward(λ·turnover)**, invalid penalty | +| `stom_rl/accounting.py` | cash/holding/NAV 회계 invariant | +| `stom_rl/risk_gate.py` | max DD/연속손실/일일거래/비중 cap | + +### 6.3 학습 ✅ +| 모듈 | 기능 | +|---|---| +| `stom_rl/portfolio_sb3_adapter.py` | PortfolioEnv→Gymnasium (check_env 통과, `action_masks()`) | +| `stom_rl/portfolio_sb3_train.py` | 실제 SB3 PPO/DQN 학습 + 결정론 핀 + masked obs-decode PolicyFn | +| `stom_rl/sb3_smoke.py` | 단일종목 SB3 학습 scaffold (재사용 패턴) | + +### 6.4 평가·검증 ✅ (가장 가치 높음) +| 모듈 | 기능 | +|---|---| +| `stom_rl/portfolio_walk_forward.py` | expanding-window **holdout** + 5종 baseline + **supervised_ranker** + **shuffle 테스트** + leakage canary + `_fit_policy`/`TRAINED_POLICY_FACTORIES` seam | +| `stom_rl/paper_replay.py` | read-only 재생 + blocked-action reason codes | + +### 6.5 시각화 ✅ +| 모듈 | 기능 | +|---|---| +| `stom_rl/rl_events.py` | step별 live event(NAV=equity) JSONL | +| `webui/rl_dashboard.py` + `RLLabTab.svelte` | `/rl` 대시보드 + 실시간 follow(1.5s)/replay | + +### 6.6 테스트 ✅ +- `tests/test_stom_rl_*.py` **150 passed** (py -3.11). 누수 canary·holdout·결정론·계약·shuffle 전부 포함. + +### 6.7 다음 연구에 "추가로 필요한 것" (현재 없음) +- `sb3-contrib`(MaskablePPO) — **미설치**. 마스킹 RL이 필요해지면 `pip install sb3-contrib` (트리거는 이미 발동 기록됨). +- 새 신호 소스(분/일봉, 펀더멘털 등) — **이게 진짜 다음 작업**이지 인프라 부족이 아님. + +--- + +## 7. 다음 연구 로드맵 (재시작 — RL이 아니라 신호 우선) + +신호 부재가 증명됐으므로, 우선순위는 **알고리즘이 아니라 신호/데이터**다. + +1. **보유 horizon 확대** — 1초 churn은 비용에 묶임. 분/시간/일 단위 의사결정으로 비용 장벽 회피. (env step/reward를 더 긴 horizon으로) +2. **신호/feature 확장** — 1초 호가 밖 신호: 분/일봉 기술지표, 펀더멘털, 섹터/시장 컨텍스트, 뉴스. C-0 패턴으로 가용성 먼저 probe. +3. **값싼 ranker로 신호 유무 먼저 검정** — 새 feature/horizon에서 `portfolio_walk_forward`의 supervised_ranker가 n_folds≥5·shuffle·비용 하에서 **equal_weight를 이기는지** 먼저 확인. (RL보다 100배 싸고, 이번에 만든 falsifiable floor) +4. **ranker가 엣지를 내면** → 그때 MaskablePPO(sb3-contrib) deep-RL 투입. 어댑터·seam·holdout 전부 준비됨. +5. **full-universe** — 신호가 확인된 뒤 `full_universe.py`로 전체 종목 백그라운드 검증. + +> 원칙: **"알파를 못 이기면 멈춘다"는 게이트는 그대로 유지.** 이번에 만든 pre-registration·multiplicity·shuffle·ranker-floor가 다음 연구에서도 거짓양성을 막는다. + +--- + +## 8. 부록 + +### 8.1 커밋 체인 (브랜치 feature/stom-rl-lab) +``` +06e1038 baseline(Stage1+문서정리) → c18e0e6 P7 → 843a0f5 P7.5 → f791e99 P8 → +21405d3 P9 → 7107d24 P10 → 2464d55 P10.5 → 2709c7c P11 → 709d106 P12 → +95ff7f8 P13 → 3401730 P14 → 7fe3581 follow-upA → 0a9a67e P16 → b09857f P17(cost-aware) → +fca9e85 live events → fe632cb C-0 → 3976403 C(14→18) → 84c91e9 A(adapter) → +d2104cc B(SB3 train) → dd5fd7a E(verdict) → ca74c78 D(signal test+shuffle) +``` + +### 8.2 핵심 재현 명령 (py -3.11) +```powershell +# 전체 테스트 +py -3.11 -m pytest tests/test_stom_rl_*.py -q # 150 passed + +# 신호검정 재현 (비자문, n_folds=5) +py -3.11 -m stom_rl.candidate_gen --db _database\stock_tick_back.db --tables --session --rules stom_rl\rules\buy_demand_pressure.json --output +py -3.11 -m stom_rl.portfolio_walk_forward --candidate-csv --n-folds 5 --output-dir # + shuffle 옵션 + +# 대시보드 +$env:KRONOS_WEBUI_PORT="5070"; $env:KRONOS_V2_DIST="1"; py -3.11 webui\run.py # /rl +``` + +### 8.3 관련 문서 +- 설계/핸드오프: `docs/stom_rl_portfolio_design_handoff_2026-05-25.md` +- 실행 연구계획: `docs/stom_rl_rl_execution_research_plan_2026-05-25.md` +- deep-RL 계획(합의): `.omx/plans/ralplan-stom-rl-deep-rl-2026-05-27.md` +- C-0 probe: `docs/stom_rl_page_c0_feature_probe_2026-05-27.md` +- cost-aware: `docs/stom_rl_cost_aware_policy_2026-05-26.md` +- deep-RL verdict: `docs/stom_rl_deep_rl_verdict_2026-05-27.md` +- 신호검정: `docs/stom_rl_signal_test_2026-05-27.md` + +--- + +## 9. 한 줄 결론 + +**수익 모델은 아직 없다(이 데이터엔 알파가 없음을 엄정히 증명).** 그러나 **거짓 알파를 만들지 않는 신뢰성 있는 RL 연구 인프라는 완성됐고, 재시작 준비는 충분하다.** 다음은 알고리즘이 아니라 **신호/데이터(긴 horizon·풍부한 feature)** 를 바꿔, 값싼 ranker로 신호 유무를 먼저 검정한 뒤, 엣지가 확인되면 준비된 deep-RL 하니스로 진입하는 것이다. diff --git a/docs/stom_rl_resume_commit_2026-05-29.md b/docs/stom_rl_resume_commit_2026-05-29.md new file mode 100644 index 000000000..25e3128d1 --- /dev/null +++ b/docs/stom_rl_resume_commit_2026-05-29.md @@ -0,0 +1,561 @@ +# STOM RL 랩 — 2026-05-29 재개 커밋 문서 + +- 작성일: **2026-05-29 KST** +- 대상 저장소: `D:\Chanil_Park\Project\Programming\Kronos` +- 브랜치: `feature/stom-rl-lab` +- 작성 직전 HEAD: `13b4162` +- 이 문서의 목적: **새 대화에서 이 파일 하나만 읽고도, 지금까지의 대화 맥락·검증 결과·정직성 가드레일·다음 페이지 작업을 그대로 이어가게 하는 것.** +- 이전 마스터 문서: `docs/stom_rl_resume_handoff_2026-05-28.md` +- 이 문서는 위 문서를 대체하기보다, **2026-05-28 문서 이후 대화에서 확인한 “현재 페이지/전체 진행률/다음 페이지”까지 반영한 최신 재개 앵커**다. + +--- + +## 0. 새 대화에서 바로 사용할 프롬프트 + +새 대화 첫 메시지로 아래를 그대로 붙여넣는다. + +```text +D:\Chanil_Park\Project\Programming\Kronos 에서 이어서 진행합니다. +먼저 docs/stom_rl_resume_commit_2026-05-29.md 를 읽고, 그 문서만을 기준으로 STOM RL 랩을 재개하세요. + +중요: +1. RL 알파 부재는 이미 검증됐으므로, 갭상승 룰 전략 곡선을 강화학습/RL이라고 부르지 마세요. +2. 현재 다음 페이지는 “사이징/리스크 설계”입니다. +3. 먼저 git status, 현재 브랜치, 핵심 테스트 47개를 확인한 뒤 작업을 이어가세요. +``` + +재개 후 최소 확인 명령: + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos +git branch --show-current +git rev-parse --short HEAD +py -3.11 -m pytest tests\test_stom_rl_gap_up_backtest.py tests\test_stom_rl_gap_up_dashboard_publish.py -q +``` + +기대 검증: + +```text +feature/stom-rl-lab +<이 문서 커밋 또는 그 이후 커밋> +47 passed +``` + +--- + +## 1. 한 줄 결론 + +**현재 STOM RL 랩의 수익성 있는 축은 RL이 아니라 “시초 갭상승 룰 전략”이다.** + +- RL/PPO/DQN 기반 cross-sectional 선택 알파는 1초·1분·세션 프록시에서 shuffle 검정 기준으로 실패했다. +- 사용자가 원한 “우상향 수익 곡선”은 RL 정책이 아니라, 사용자 실제 전략과 맞는 **시초 갭상승 + 체결강도/호가 필터 룰 백테스트**에서 나왔다. +- 주력 룰은 23bp 실제 왕복비용과 체결 de-idealization 스트레스까지 견뎠다. +- 다음 작업은 모델을 더 돌리는 것이 아니라, **실전 직전 운영 룰: 포지션 사이징·동시보유·일손실한도·중단조건**을 정하는 것이다. + +--- + +## 2. 절대 지켜야 할 정직성 가드레일 + +이 프로젝트는 투자 판단 자동화로 오해될 수 있으므로, 아래 표현 규칙을 반드시 지킨다. + +1. **갭상승 곡선을 “강화학습/RL 곡선”이라고 부르지 않는다.** + - 올바른 표현: `시초 갭상승 룰 전략`, `RULE strategy`, `NOT RL`. + - dashboard run label도 `algorithm="rule:gap_up_"`, `is_reinforcement_learning=false`를 유지한다. + +2. **누적곡선은 비복리 per-trade % 합 또는 fixed-notional NAV다.** + - 연수익률, 복리 계좌 성장률, 실계좌 보장 수익처럼 과장하지 않는다. + +3. **실거래 준비 완료라고 말하지 않는다.** + - 현재는 백테스트 + 체결 stress + dashboard 발행 완료 상태다. + - 실전 전에는 사이징/리스크 룰, full universe 확인, read-only forward/paper 검증이 필요하다. + +4. **2022 약세는 레짐 붕괴라고 단정하지 않는다.** + - 2022는 N=39 소표본이며 95% CI가 0을 포함한다. + - 다중비교 보정 기준으로 “소표본 변동성” 결론이 현재 정직한 해석이다. + +5. **진짜 큐/부분체결 replay는 현재 DB만으로 만들지 않는다.** + - L2 큐포지션 데이터가 없다. + - 가능한 상한은 `realized`, `sl_gap_stress`, 슬리피지 sweep이다. + +--- + +## 3. 사용자 목표와 전략 스펙 + +사용자 목표: + +- 한국 STOM 기반 퀀트/단타 전략 연구. +- 유튜브 NEAT 영상 스크린샷처럼 “꾸준히 우상향하는 곡선”을 원함. +- 단, 대화 중 핵심 약속은 “억지로 RL에 끼워 맞춰 거짓 우상향을 만들지 않는다”였다. + +확정 전략: + +| 항목 | 값 | +|---|---| +| 전략명 | 시초 급등 / 9시 시초 갭상승 momentum | +| 진입 | 시초 `등락율` >= 2% | +| 주력 필터 | `ts_imb` = 체결강도 >= 100 AND 매수호가 imbalance >= 0.5 | +| 청산 | TP 5% / SL 1% / 09:25 시간청산 | +| 실제 왕복비용 | 23bp = 매수수수료 0.015% + 매도수수료 0.015% + 매도세 0.20% | +| 데이터 | `_database\stock_tick_back.db` 하나 | +| DB 특성 | 1초봉, 09:00~09:30 이벤트 트리거 희소 기록, 2427종목, UTF-8 한글 컬럼 | +| 주의 | 종목코드 선행 0 보존. `000250`을 int로 바꾸지 말 것. | + +--- + +## 4. 지금까지의 핵심 흐름 + +### 4.1 과거 RL 방향 + +처음에는 단일 종목 RL, SB3 DQN/PPO, dashboard, live event, leaderboard까지 구현했다. + +관련 과거 문서: + +- `docs/stom_rl_page100_completion_report_2026-05-24.md` +- `docs/stom_rl_portfolio_design_handoff_2026-05-25.md` +- `docs/stom_rl_rl_execution_research_plan_2026-05-25.md` +- `docs/stom_rl_page10_5_earlyread_2026-05-26.md` +- `docs/stom_rl_page14_perf_optimization_2026-05-26.md` +- `docs/stom_rl_page16_full_universe_2026-05-26.md` +- `docs/stom_rl_page_c0_feature_probe_2026-05-27.md` + +하지만 RL 쪽 핵심 결론은 다음이다. + +- 1초 horizon: 선택 알파 없음. +- 1분 horizon: 선택 알파 없음. +- 세션바/일봉 proxy: 선택 알파 없음. +- 비용 반영 시 결정론 RL 대역은 turnover 비용에 먹힘. +- 그래서 RL 포트폴리오를 더 밀기보다, 사용자 실제 전략과 DB가 맞는 **시초 갭상승 룰 전략**으로 reframe했다. + +### 4.2 갭상승 룰 전략 방향 + +순서: + +1. 필터 없이 2% 갭상승 TP/SL grid 테스트 → 비용에 먹혀 OOS 실패. +2. 체결강도/호가 필터 추가 → 비용 후 양수 신호. +3. 레짐 robustness + 슬리피지 sweep → 매년/경계 검증 통과. +4. 사용자 실제 비용 23bp 반영 → `ts_imb` 기대값 약 +0.95%/trade. +5. dashboard run과 PNG 곡선 발행. +6. `realized` / `sl_gap_stress` fill mode로 체결 de-idealization. +7. 2022 약세는 다중비교 보정 결과, 소표본 변동성으로 해석. +8. 2026-05-29 현재: 다음은 수익성 탐색이 아니라 **실전 운영 룰 설계**. + +--- + +## 5. 확정 수치 요약 + +### 5.1 주력 필터 정의 + +소스: `stom_rl/gap_up_backtest.py` + +| 필터 | 정의 | +|---|---| +| `none` | 시초 등락율 >= 2%만 | +| `ts` | `none` + 체결강도 >= 100 | +| `ts_imb` | `ts` + 매수호가 imbalance >= 0.5 | + +### 5.2 23bp 기준 기대값, TP5/SL1/09:25 + +| 필터 | N | 기대값 @23bp | de-idealized | breakeven | 여유 | +|---|---:|---:|---:|---:|---:| +| none | 1349 | +0.246% | +0.206% | 42.7bp | 1.9x | +| ts | 425 | +0.633% | +0.593% | 82.7bp | 3.6x | +| **ts_imb** | 235 | **+0.952%** | **+0.912%** | 116.6bp | **5.1x** | + +### 5.3 누적 equity 곡선, idealized 캐시 + 23bp 환산 + +| 필터 | N | 누적% 비복리 | 기대값/trade | 승률 | 최대낙폭 | 최장연패 | +|---|---:|---:|---:|---:|---:|---:| +| UNFILTERED | 1349 | +332.2% | +0.246% | 29% | -26.0% | 17 | +| ts | 425 | +269.0% | +0.633% | 36% | -19.3% | 12 | +| **ts_imb** | 235 | **+223.6%** | **+0.952%** | **42%** | **-15.7%** | **9** | + +해석: + +- `none`은 총 누적은 크지만 낙폭과 연패가 크다. +- `ts_imb`는 trade 수가 줄어도 곡선이 가장 매끈하고 위험이 낮다. +- 다음 사이징 페이지는 `ts_imb`를 기준으로 설계한다. + +### 5.4 체결 de-idealization + +주의: `.omx\artifacts\gap_up_*\instances.json` 캐시는 기본적으로 25bp 값이 들어있고, 23bp 환산은 `+0.02%p`를 더한다. + +| 모드 | 캐시 raw @25bp | 23bp 환산 | 의미 | +|---|---:|---:|---| +| idealized | +0.932% | +0.952% | 낙관적 기준 | +| realized | +0.886% | +0.906% | 실제 기록가 기반 | +| sl_gap_stress | +0.791% | +0.811% | 손절 gap-through 최악 스트레스 | + +핵심: + +- 최악 stress 후에도 `ts_imb`는 **+0.811%/trade**로 양수. +- 다만 MDD는 약 -20%대까지 볼 수 있으므로, 다음 페이지에서 risk sizing이 필수다. + +### 5.5 연도별 약세 + +| 모드 | 2022 | 2023 | 2024 | 2025 | 2026 | +|---|---:|---:|---:|---:|---:| +| idealized | +0.09 | +1.45 | +1.10 | +1.00 | +0.91 | +| realized | -0.01 | +1.50 | +1.02 | +0.93 | +0.90 | +| sl_gap_stress | -0.05 | +1.32 | +0.96 | +0.85 | +0.76 | + +해석: + +- 2022만 약하다. +- 그러나 N=39, SE 약 ±0.40%/trade로 오차가 크다. +- Bonferroni 보정 후 레짐 붕괴로 단정하지 않는다. +- 사이징 페이지에서는 “단일 연도 flat/음수 가능”을 리스크 한도로 흡수해야 한다. + +--- + +## 6. 방금 대화에서 확인한 페이지/진행률 + +사용자 요청: + +> “방금 진행한 페이지 그리고 전체 페이지 진행률로 다음 페이지와 함께 안내” + +그에 대해 확인한 최신 상태: + +### 6.1 방금 진행한 페이지 + +**페이지명:** `2026-05-29 재개/현황 복원 페이지` + +완료한 일: + +- `docs/stom_rl_resume_handoff_2026-05-28.md` 읽음. +- 최신 브랜치/HEAD 확인. +- 핵심 테스트 재실행: `47 passed`. +- gitignored 캐시 및 dashboard run 존재 확인. +- `build_equity_curve.py` / `fill_mode_compare.py` 실행으로 수치 재확인. +- dashboard progress API의 기존 RL Lab page 기준 진행률 확인. +- 다음 페이지를 “사이징/리스크 설계”로 결정. + +상태: **완료** + +### 6.2 전체 진행률 — 두 기준을 분리해서 해석 + +#### A. 구 dashboard RL Lab page progress 기준 + +`webui.rl_dashboard.load_rl_progress()` 결과: + +| dashboard page | 진행률 | 상태 | +|---|---:|---| +| RL Lab 개요 | 100% | complete | +| 실시간 RL | 67% | in_progress | +| 실제 딥러닝 학습 | 33% | in_progress | +| Performance Leaderboard | 100% | complete | +| Artifacts / Models | 33% | in_progress | +| Docs / 운영 경계 | 100% | complete | +| **전체** | **72%** | in_progress | + +해석: + +- 이 72%는 **과거 SB3/RL Lab dashboard artifact 기준**이다. +- 현재 갭상승 룰 전략의 실질 진행률과 혼동하면 안 된다. +- 일부 model zip/check_env criteria 때문에 낮게 잡힌다. + +#### B. 최신 갭상승 룰 전략 기준 + +| 영역 | 상태 | +|---|---| +| RL 알파 부재 판정 | 완료 | +| 갭상승 룰 백테스트 | 완료 | +| 비용 23bp 반영 | 완료 | +| 필터 ts/ts_imb 검증 | 완료 | +| 레짐/슬리피지 검증 | 완료 | +| fill_mode 체결 stress | 완료 | +| dashboard run 발행 | 완료 | +| 2022 소표본 해석 | 완료 | +| **사이징/리스크 설계** | **다음** | +| full universe 재검증 | 이후 | +| read-only forward/paper | 이후 | + +실질 체감 진행률: + +- **갭상승 룰 전략 연구/검증:** 90% 내외. +- **실거래 직전 운영 준비:** 70~80%. +- **주문 직전 안전 운영체계까지 포함한 전체:** 약 80~85%. + +남은 핵심 15~20%는 수익 곡선을 더 찾는 일이 아니라, **돈을 얼마나 안전하게 태울지 정하는 운영 설계**다. + +--- + +## 7. 현재 파일/산출물 지도 + +### 7.1 커밋된 핵심 소스 + +| 파일 | 역할 | +|---|---| +| `stom_rl/gap_up_backtest.py` | 갭상승 룰 백테스트 엔진. `--fill-mode`, `--regime-analysis`, `--cost-bps`, `--max-symbols`, `--artifacts-dir` 지원. | +| `stom_rl/gap_up_dashboard_publish.py` | 갭상승 곡선을 `webui/rl_runs` dashboard run으로 발행. | +| `tests/test_stom_rl_gap_up_backtest.py` | 백테스트 테스트 41개. | +| `tests/test_stom_rl_gap_up_dashboard_publish.py` | dashboard 발행 테스트 6개. | +| `webui/rl_dashboard.py` / `webui/app.py` | `/api/rl/*` read-only dashboard. | +| `stom_rl/rl_events.py` | dashboard event/summary 재사용 계층. | + +### 7.2 커밋된 핵심 문서 + +읽기 순서: + +1. `docs/stom_rl_resume_commit_2026-05-29.md` — 이 문서, 최신 재개 앵커. +2. `docs/stom_rl_resume_handoff_2026-05-28.md` — 이전 마스터 핸드오프, 재현 스크립트 내장. +3. `docs/stom_rl_gap_up_fillmode_2026-05-28.md` — 체결 de-idealization + 2022 분석. +4. `docs/stom_rl_gap_up_realcost_2026-05-28.md` — 23bp 비용 확정. +5. `docs/stom_rl_gap_up_regime_validation_2026-05-28.md` — 레짐/슬리피지 검증. +6. `docs/stom_rl_gap_up_cost_filter_2026-05-27.md` — 현실 비용 + 진입 필터. +7. `docs/stom_rl_gap_up_backtest_2026-05-27.md` — 필터 전 기준선. +8. `docs/stom_rl_deep_rl_verdict_2026-05-27.md` — RL 알파 부재 종합. + +### 7.3 gitignored 산출물 + +다음은 커밋 대상이 아니며, 필요 시 §8 명령으로 재생성한다. + +| 경로 | 역할 | +|---|---| +| `.omx\artifacts\gap_up_backtest\instances.json` | idealized 기본 캐시 | +| `.omx\artifacts\gap_up_idealized\instances.json` | 비교용 idealized 캐시 | +| `.omx\artifacts\gap_up_realized\instances.json` | realized fill 캐시 | +| `.omx\artifacts\gap_up_sl_gap_stress\instances.json` | 최악 손절 gap stress 캐시 | +| `.omx\artifacts\gap_up_backtest\build_equity_curve.py` | 캐시 기반 곡선 요약/PNG 생성 | +| `.omx\artifacts\gap_up_backtest\fill_mode_compare.py` | fill mode 비교 | +| `webui\rl_runs\gap_up_*` | dashboard run 5개 | + +2026-05-29 확인 시 위 캐시와 run은 존재했다. + +--- + +## 8. 재현 명령 + +### 8.1 핵심 테스트 + +```powershell +py -3.11 -m pytest tests\test_stom_rl_gap_up_backtest.py tests\test_stom_rl_gap_up_dashboard_publish.py -q +``` + +검증 결과: + +```text +47 passed +``` + +### 8.2 갭상승 캐시 재생성 + +```powershell +# idealized 기본 캐시 + 레짐분석 +py -3.11 stom_rl\gap_up_backtest.py --regime-analysis --regime-cost-bps 23 --max-symbols 120 --artifacts-dir .omx\artifacts\gap_up_backtest + +# fill mode별 캐시 +py -3.11 stom_rl\gap_up_backtest.py --fill-mode idealized --regime-analysis --regime-cost-bps 23 --max-symbols 120 --artifacts-dir .omx\artifacts\gap_up_idealized +py -3.11 stom_rl\gap_up_backtest.py --fill-mode realized --regime-analysis --regime-cost-bps 23 --max-symbols 120 --artifacts-dir .omx\artifacts\gap_up_realized +py -3.11 stom_rl\gap_up_backtest.py --fill-mode sl_gap_stress --regime-analysis --regime-cost-bps 23 --max-symbols 120 --artifacts-dir .omx\artifacts\gap_up_sl_gap_stress +``` + +주의: + +- `--max-symbols 120`은 현재 문서 수치와 맞는 bounded 검증 universe다. +- `--max-symbols 0`은 full universe이며 매우 오래 걸릴 수 있다. 다음 단계 B에서 별도 장기 작업으로 다룬다. + +### 8.3 곡선/체결 비교 재확인 + +```powershell +py -3.11 .omx\artifacts\gap_up_backtest\build_equity_curve.py +py -3.11 .omx\artifacts\gap_up_backtest\fill_mode_compare.py +``` + +기대 핵심 출력: + +```text +UNFILTERED(2%갭만) N=1349 cum= +332.2% exp=+0.246%/trade win=29% maxDD=-26.0% maxLossStreak=17 ++체결강도(ts) N= 425 cum= +269.0% exp=+0.633%/trade win=36% maxDD=-19.3% maxLossStreak=12 ++체결강도+호가(ts_imb) N= 235 cum= +223.6% exp=+0.952%/trade win=42% maxDD=-15.7% maxLossStreak=9 +``` + +fill mode 출력은 캐시 raw @25bp 기준 값이므로, 23bp로 읽을 때 +0.02%p를 더한다. + +### 8.4 dashboard run 발행 + +```powershell +py -3.11 -m stom_rl.gap_up_dashboard_publish --filter none --cost-bps 23 +py -3.11 -m stom_rl.gap_up_dashboard_publish --filter ts --cost-bps 23 +py -3.11 -m stom_rl.gap_up_dashboard_publish --filter ts_imb --cost-bps 23 +py -3.11 -m stom_rl.gap_up_dashboard_publish --instances .omx\artifacts\gap_up_realized\instances.json --filter ts_imb --cost-bps 23 --run-name gap_up_ts_imb_realized +py -3.11 -m stom_rl.gap_up_dashboard_publish --instances .omx\artifacts\gap_up_sl_gap_stress\instances.json --filter ts_imb --cost-bps 23 --run-name gap_up_ts_imb_sl_gap_stress +``` + +대시보드 서버: + +```powershell +$env:KRONOS_WEBUI_PORT='5070' +$env:KRONOS_WEBUI_OPEN_BROWSER='0' +$env:KRONOS_WEBUI_RELOAD='0' +py -3.11 webui\run.py +``` + +URL: + +```text +http://127.0.0.1:5070/rl +``` + +서버는 리로더 OFF이므로 신규 run을 인식하지 못하면 재시작한다. + +--- + +## 9. 다음 페이지 — 사이징/리스크 설계 + +### 9.1 왜 다음 페이지가 이것인가 + +지금은 “수익 곡선을 찾는 단계”가 아니다. + +이미 확인된 것: + +- `ts_imb`는 비용 후 양수. +- 최악 체결 stress도 양수. +- dashboard 곡선도 발행됨. +- RL이 아니라 룰 전략이라는 정직한 framing도 확정됨. + +따라서 다음 질문은 다음이다. + +> 이 전략에 실제 계좌의 몇 %를 넣을 수 있으며, 언제 멈춰야 하는가? + +### 9.2 Page A 목표 + +**Page A — 시초 갭상승 룰 전략 사이징/리스크 설계** + +산출물: + +1. `docs/stom_rl_gap_up_risk_sizing_2026-05-29.md` +2. 가능하면 `stom_rl/gap_up_risk_sizing.py` 또는 테스트 가능한 순수 함수. +3. 최소 테스트 파일: `tests/test_stom_rl_gap_up_risk_sizing.py` + +### 9.3 Page A 입력값 + +| 항목 | 기준값 | +|---|---:| +| 주력 필터 | `ts_imb` | +| trade 수 | 235 | +| 기대값 @23bp | +0.952%/trade | +| 최악체결 기대값 @23bp | +0.811%/trade | +| 승률 | 약 42% | +| 최장연패 | 9 | +| 최대낙폭 idealized | 약 -15.7% | +| 최대낙폭 stress | 약 -20%대 | +| TP/SL | +5% / -1% | +| 2022 stress | flat~소폭 음수 가능 | + +### 9.4 Page A에서 정해야 할 룰 + +필수 결정 항목: + +1. **1회 진입 노셔널** + - 예: 계좌의 5%, 10%, 20% 중 무엇이 허용 가능한가. + +2. **동시보유 제한** + - 09:00~09:25 window에서 여러 종목이 동시에 뜰 수 있다. + - top-K 우선순위가 필요하다. + +3. **일손실한도** + - 예: 하루 -1R, -2R, -3R 도달 시 그날 신규 진입 중단. + +4. **연속손실 중단 룰** + - 최장연패 9를 기준으로, 3연패/5연패/7연패 구간별 감액 또는 중단. + +5. **월간/연간 낙폭 룰** + - 2022 같은 flat/음수 가능성을 견딜 한도. + +6. **유동성/체결 가능성 한도** + - `entry_sec_amount`, 체결강도, 호가 imbalance를 기반으로 주문금액 상한을 둘지 검토. + +7. **실전 전 forward 조건** + - read-only live signal N일 이상 양수/정상 작동 전에는 실주문 금지. + +### 9.5 Page A 완료 기준 + +문서와 코드가 다음 질문에 답해야 한다. + +- 계좌 1,000만원/5,000만원/1억원일 때 1회 진입금액은 얼마인가? +- 하루 최대 몇 종목까지 진입하는가? +- 하루 최대 손실은 몇 원/몇 %인가? +- 최장연패 9회가 와도 계좌가 감내 가능한가? +- 2022 같은 약세 구간에서 자동 감액/중단이 되는가? +- dashboard/paper run에 같은 risk rule을 적용할 수 있는가? + +--- + +## 10. 이후 페이지 결정 트리 + +Page A 이후 권장 순서: + +1. **Page A — 사이징/리스크 설계** + - 가장 실질적이며 다음 작업. + +2. **Page B — full universe 재검증** + - `--max-symbols 0`으로 전체 2427종목에서 수치 유지 확인. + - 장기 실행/체크포인트 필요. + +3. **Page C — 실주문 슬리피지/유동성 모델** + - 실제 주문 가능 금액, 초당 거래대금, 호가잔량 기반 상한. + +4. **Page D — read-only live paper/forward** + - 실주문 없이 시점별 신호 생성. + - 최소 N일 관찰. + +5. **Page E — broker/order 연동 검토** + - 이 단계는 아직 하지 않는다. + - 실거래 API/주문 발행은 명시적 별도 승인 없이는 금지. + +--- + +## 11. 현재 git 상태 주의 + +2026-05-29 문서 작성 전 확인된 `git status --short`에는 다음 untracked 항목이 있었다. + +```text +?? .codegraph/ +?? .omc/notepad.md +?? .omc/project-memory.json +?? .omc/sessions/ +?? .omc/state/ +?? template/ +?? webui/stom_predictions/ +``` + +이 문서 작업에서는 위 항목을 건드리지 않는다. + +커밋 대상은 이 문서 파일만이어야 한다. + +--- + +## 12. 커밋 후 재개자가 할 첫 행동 + +새 대화/새 에이전트는 다음 순서로 움직인다. + +1. 이 문서를 읽는다. +2. `git status --short`, `git branch --show-current`, `git log --oneline -5`를 확인한다. +3. `47 passed` 테스트를 재확인한다. +4. `docs/stom_rl_resume_handoff_2026-05-28.md`는 보조로만 읽는다. +5. 작업 모드는 **Page A 사이징/리스크 설계**로 시작한다. +6. 새 코드 작성 전, 문서형 risk plan을 먼저 쓴다. +7. 이후 테스트 가능한 순수 risk sizing 함수/테스트를 추가한다. + +--- + +## 13. 완료 선언 기준 + +이 커밋 문서는 다음을 만족하면 성공이다. + +- 새 대화가 과거 채팅을 보지 않아도 현재 위치를 알 수 있다. +- RL이 아니라 룰 전략이라는 정직성 가드레일을 알 수 있다. +- 검증 수치와 재현 명령을 알 수 있다. +- “전체 진행률 72%”와 “갭상승 기준 80~85%”의 기준 차이를 알 수 있다. +- 다음 페이지가 사이징/리스크 설계임을 알 수 있다. +- 어떤 파일을 건드리고 어떤 산출물을 재생성해야 하는지 알 수 있다. + +--- + +## 14. 짧은 인수인계 멘트 + +현재까지는 **수익 곡선 탐색은 끝났고, 실전 운영 설계 직전**이다. + +다음 에이전트는 RL 학습을 더 돌리지 말고, `ts_imb` 갭상승 룰을 기준으로 **계좌 크기별 포지션 사이징, 동시보유 제한, 일손실한도, 연패/낙폭 중단 룰**을 설계하라. + +단, 모든 문서/코드/대시보드 표현에서 이 전략을 반드시 **“RULE strategy, NOT reinforcement learning”**으로 표시하라. diff --git a/docs/stom_rl_resume_handoff_2026-05-28.md b/docs/stom_rl_resume_handoff_2026-05-28.md new file mode 100644 index 000000000..6648555a4 --- /dev/null +++ b/docs/stom_rl_resume_handoff_2026-05-28.md @@ -0,0 +1,402 @@ +# STOM RL 랩 — 마스터 재개(RESUME) 핸드오프 (자립형) + +- 작성일: 2026-05-28 +- **목적: 이 문서 하나만 읽으면 새 대화/새 클론에서 위 작업 전체를 그대로 이어갈 수 있다.** 재현에 필요한 gitignored 스크립트 소스를 §9에 전부 내장했고, DB에서 모든 산출물을 재생성하는 레시피를 §7에 담았다. +- 브랜치: `feature/stom-rl-lab` · 최신 커밋: `5c56a47` +- 데이터(유일): `D:\Chanil_Park\Project\Programming\Kronos\_database\stock_tick_back.db` + - 1초봉, 개장 **09:00–09:30만**, 이벤트 트리거 기록(희소), 28GB, 2427개 종목 테이블. + - **UTF-8 한글 컬럼**(cp949 아님). 종목코드 **선행 0 보존**(예: `000250` — int 변환 금지). + - `amount`=초당거래대금(per-second) → 리샘플 시 **SUM** 집계. + +--- + +## 0. 한 줄 현황 (TL;DR) + +**강화학습(RL)은 우상향 곡선을 못 만든다(인트라데이 알파 부재 — 1초·1분·세션 3중 shuffle-NO). 우상향 곡선을 만드는 것은 RL이 아니라 "시초 갭상승 룰 전략"이며, 사용자 실비용 23bp + 체결 de-idealization(최악 SL gap-through)까지 견디며 검증됨(ts_imb 최악 +0.81%/trade, breakeven 대비 ~4×). 곡선은 PNG + 대시보드 run 5개로 시각화 완료. 남은 한계: 진짜 큐/부분체결 replay는 L2 데이터 부재로 불가, 2022는 소표본 약세(다중비교 보정 시 비유의).** + +--- + +## 1. 이 문서 사용법 (재개 프로토콜) + +새 대화 첫 프롬프트로 아래를 그대로 쓰면 된다: +``` +docs/stom_rl_resume_handoff_2026-05-28.md 읽고 STOM RL 랩 이어서 진행. +``` +그 다음 §2(맥락) → §3(히스토리) → §4(검증수치) → §6(파일) → §7(재현) 순으로 읽으면 전체 작업이 복원된다. **§11 정직성 가드레일을 반드시 지킬 것.** + +--- + +## 2. 사용자 · 전략 · 비용 (불변 사실) + +- 한국 퀀트 트레이더(parkchanil77@naver.com). 전략 = **"시초 급등 / 9시 시초 갭 상승"**(opening gap-up momentum). +- **전략 스펙(사용자 지정·확정)**: + - 진입: 시초 `등락율` ≥ **2%**(갭상승). + - 청산: **TP(목표수익) / SL(손절) 또는 09:25 시간청산**. PRIMARY = **TP5% / SL1% / 09:25**. +- **실제 왕복 체결비용 = 23bp** = 매수 수수료 0.015% + 매도 수수료 0.015% + 증권거래세 0.20%(매도측). (사용자가 명시 확정.) +- 사용자가 원한 것: **유튜브 NEAT 영상 스크린샷처럼 "꾸준히 우상향하는 수익 곡선"**, 실거래 직전 상태인지. +- **데이터는 위 DB 단 하나**(다른 데이터 없음). universe = STOM이 트리거한 종목 = **사용자 실거래 대상과 일치** → 배포 관점에서 트리거-universe는 편향 아님. + +--- + +## 3. 프로젝트 히스토리 (무엇을 시도→무엇이 실패/성공했나) + +이 순서가 "위 대화"의 핵심 논리 흐름이다. + +1. **RL 포트폴리오 선택(cross-sectional PPO/DQN)** 시도 → **인트라데이 선택 알파 부재**. + - 1초 horizon: shuffle 검정 NO. 1분 horizon: NO. 세션바(일봉 proxy): NO. + - 결론: 제약은 알고리즘이 아니라 **신호/데이터**. RL NAV는 우상향하지 않음. + - 교훈(사용자에게 한 약속): **안 맞는 프레임에 데이터를 욱여넣어 거짓 결론 내지 않는다.** +2. **전략 reframe**: 사용자의 실제 전략(시초 갭상승)은 이 데이터와 **맞음** → 룰 백테스트로 전환. +3. **갭상승 룰 백테스트(필터 전)**: 고정 TP/SL 그리드, 임의 25bp → **0/16 OOS 음수**(엣지가 비용에 먹힘). [48bbdef] +4. **현실 비용모델 + 진입필터(체결강도/호가) + cost sweep** → **필터 시 OOS 양수**(첫 긍정 신호). [bf767d7] +5. **레짐 robustness + 슬리피지 검증** → 매년·5경계 OOS 양수, 38bp 슬리피지 생존. [c80a9c9] +6. **사용자 실비용 23bp 확정** → 전 필터 양수, ts_imb +0.9%/trade. [d87dd00] +7. **우상향 곡선 시각화(PNG + 대시보드 run) + 체결 de-idealization(realized/SL gap-through) 게이트** → ts_imb 최악에도 +0.81%/trade. [e5d89c2] +8. **2022 약세를 다중비교 보정으로 검정** → 소표본 변동성(레짐 실패 아님). [5c56a47] + +--- + +## 4. 검증된 수치 (확정 결과) + +### 4-A. RL 알파 부재 (3중) +1초·1분·세션프록시 전부 shuffle 검정 NO. 근거: `docs/stom_rl_deep_rl_verdict_2026-05-27.md`, `stom_rl_signal_test_2026-05-27.md`, `stom_rl_1min_signal_verdict_2026-05-27.md`, `stom_rl_story_b1_session_proxy_verdict_2026-05-27.md`. + +### 4-B. 진입필터 정의 (`stom_rl/gap_up_backtest.py`) +- `none`: 2% 갭만. +- `ts`: **체결강도 ≥ 100**(STOM "at par"). +- `ts_imb`: 체결강도 ≥ 100 **AND** 호가 imbalance(매수총잔량/(매수+매도)) ≥ 0.5. + +### 4-C. 실비용 23bp 기대값 (idealized, TP5/SL1) +| 필터 | N | @23bp/trade | de-idealized | breakeven | 여유 | +|---|---:|---:|---:|---:|---:| +| none | 1349 | +0.246% | +0.206% | 42.7bp | 1.9× | +| +ts | 425 | +0.633% | +0.593% | 82.7bp | 3.6× | +| **+ts_imb** | 235 | **+0.952%** | **+0.912%** | 116.6bp | **5.1×** | + +### 4-D. 누적 equity 곡선 (build_equity_curve, @23bp, TP5/SL1, idealized 캐시) +| 필터 | N | 누적%(비복리) | exp/trade | 승률 | 최대낙폭 | 최장연패 | +|---|---:|---:|---:|---:|---:|---:| +| UNFILTERED | 1349 | +332.2% | +0.246% | 29% | −26.0% | 17 | +| +ts | 425 | +269.0% | +0.633% | 36% | −19.3% | 12 | +| **+ts_imb** | 235 | **+223.6%** | +0.952% | 42% | **−15.7%** | **9** | +→ ts_imb가 가장 매끈한 우상향. + +### 4-E. 체결 de-idealization (ts_imb @23bp) — 핵심 견고성 검증 +| 체결모드 | exp/trade | vs idealized | 누적 NAV(시작 1M) | 최대낙폭 | +|---|---:|---:|---:|---:| +| idealized(낙관) | +0.952% | — | 3,236,073 (+223.6%) | −16.1% | +| realized(현실) | +0.906% | −0.045%p | 3,130,059 (+213.0%) | −20.0% | +| **sl_gap_stress(최악)** | **+0.811%** | −0.140%p | 2,906,596 (+190.7%) | −20.5% | +→ 최악 체결에도 +0.81%, breakeven 116.6bp 대비 ~4× 여유. + +### 4-F. 연도별 ts_imb (@23bp, mean_net %/trade) +| 모드 | 2022 | 2023 | 2024 | 2025 | 2026 | +|---|---:|---:|---:|---:|---:| +| idealized | +0.09 | +1.45 | +1.10 | +1.00 | +0.91 | +| realized | **−0.01** | +1.50 | +1.02 | +0.93 | +0.90 | +| sl_gap_stress | **−0.05** | +1.32 | +0.96 | +0.85 | +0.76 | + +### 4-G. 필터 강도 × 최악체결 (sl_gap_stress) — 필터가 안전마진 +| 필터 | idealized exp | sl_gap_stress exp | sl_gap_stress 최대낙폭 | +|---|---:|---:|---:| +| none | +0.226 | +0.058 | **−65.2%** | +| ts | +0.613 | +0.437 | −30.0% | +| **ts_imb** | +0.932 | **+0.791** | −20.9% | +(이 표는 @25bp 캐시 기준; @23bp는 각 +0.02. 비교·낙폭 결론 동일.) + +--- + +## 5. 정직한 한계 + +1. **게이트 3 (진짜 큐·부분체결 paper replay) 불가**: L2 큐포지션 데이터가 DB에 없음(매수/매도총잔량 합계만). realized/sl_gap_stress 체결 + 슬리피지 38bp 스윕이 **우리 데이터로 가능한 체결현실성 상한**. 가짜 큐 시뮬은 만들지 않는다. (`stom_rl/paper_replay.py`는 PortfolioEnv용이라 갭상승 룰엔 부적합.) +2. **2022 약세**(ts_imb realized −0.01 / 최악 −0.05): N=39 소표본, SE ±0.40%/trade, 95% CI가 0 포함 → **단독 음수 아님**. vs 2023~26 Welch t=2.27~2.35(uncorrected) 이나 "5년 중 최약" 사후지목 → **Bonferroni 임계 ~2.6 기준 비유의**. 승률 26% > 손익분기 ~20.5% → 구조 안 깨짐. **소표본 변동성, 레짐 실패 아님.** 단 단일 연도는 변동성만으로 flat~음수 가능 → 사이징/낙폭관리 필수. +3. 곡선은 **비복리 per-trade % 합**(고정 노셔널 가정). "연수익률"/"복리 계좌곡선"으로 과장 금지. 손익은 소수 TP가 캐리하는 꼬리 의존. + +--- + +## 6. 파일 인벤토리 (커밋됨 vs gitignored) + +### 커밋된 소스 (진짜 산출물 — git에 있음) +| 경로 | 내용 | +|---|---| +| `stom_rl/gap_up_backtest.py` | 갭상승 백테스트 엔진. `simulate_trade(fill_mode=...)`, `--fill-mode {idealized,realized,sl_gap_stress}`, `--regime-analysis`, `--cost-bps`, `--max-symbols`, `--artifacts-dir`. | +| `stom_rl/gap_up_dashboard_publish.py` | 곡선을 대시보드 `portfolio_paper` run으로 발행. | +| `tests/test_stom_rl_gap_up_backtest.py` | 백테스트 테스트(41). | +| `tests/test_stom_rl_gap_up_dashboard_publish.py` | 발행 테스트(6). | +| `docs/stom_rl_gap_up_*.md`, `docs/stom_rl_deep_rl_verdict_*.md` 등 | 결과 문서들(§13). | +| `webui/rl_dashboard.py`, `webui/app.py` | 대시보드 read-only API(`/api/rl/*`). | +| `stom_rl/rl_events.py` | `RlLiveEvent`/`RlLiveEventWriter`/`summarize_live_events`(발행이 재사용). | + +**테스트 47 passed**(41+6). 재현: `py -3.11 -m pytest tests/test_stom_rl_gap_up_backtest.py tests/test_stom_rl_gap_up_dashboard_publish.py -q` + +### gitignored (커밋 안 됨 — §7로 재생성, 스크립트는 §9에 내장) +- `.omx/` 전체 (line 82 .gitignore): `instances.json` 캐시, `build_equity_curve.py`, `fill_mode_compare.py`, `gap_up_{idealized,realized,sl_gap_stress}/`, `equity_curve.png`. +- `webui/rl_runs/` 전체 (line 64): 발행된 run 5개(`gap_up_ts_imb_equity`, `..._realized`, `..._sl_gap_stress`, `gap_up_ts_equity`, `gap_up_none_equity`). +- 비용은 **flat 가산적**: `net@c = net@25 + (25−c)/100`(per-trade %). 캐시(25bp)→23bp는 `+0.02%p`만 가산. + +--- + +## 7. 전체 재현 레시피 (새 클론에서 0부터) + +전제: `_database/stock_tick_back.db` 존재, Python `py -3.11`, 의존성 설치(`pip install -r requirements.txt`). + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos + +# 1) 테스트 통과 확인 (47) +py -3.11 -m pytest tests\test_stom_rl_gap_up_backtest.py tests\test_stom_rl_gap_up_dashboard_publish.py -q + +# 2) idealized 캐시 + 레짐분석 재생성 (instances.json @25bp 작성, ~7분/120종목) +py -3.11 stom_rl\gap_up_backtest.py --regime-analysis --regime-cost-bps 23 --max-symbols 120 --artifacts-dir .omx\artifacts\gap_up_backtest + +# 3) realized / sl_gap_stress 캐시 (de-idealization 비교용) +py -3.11 stom_rl\gap_up_backtest.py --fill-mode realized --regime-analysis --regime-cost-bps 23 --max-symbols 120 --artifacts-dir .omx\artifacts\gap_up_realized +py -3.11 stom_rl\gap_up_backtest.py --fill-mode sl_gap_stress --regime-analysis --regime-cost-bps 23 --max-symbols 120 --artifacts-dir .omx\artifacts\gap_up_sl_gap_stress +# (idealized 비교본도 같은 dir 규칙으로: --artifacts-dir .omx\artifacts\gap_up_idealized) + +# 4) §9의 두 스크립트를 해당 경로에 생성 후: +py -3.11 .omx\artifacts\gap_up_backtest\build_equity_curve.py --png # 곡선 콘솔+PNG +py -3.11 .omx\artifacts\gap_up_backtest\fill_mode_compare.py # 3모드 비교표 + +# 5) 대시보드 run 발행 (5개) +py -3.11 -m stom_rl.gap_up_dashboard_publish --filter none --cost-bps 23 +py -3.11 -m stom_rl.gap_up_dashboard_publish --filter ts --cost-bps 23 +py -3.11 -m stom_rl.gap_up_dashboard_publish --filter ts_imb --cost-bps 23 +py -3.11 -m stom_rl.gap_up_dashboard_publish --instances .omx\artifacts\gap_up_realized\instances.json --filter ts_imb --cost-bps 23 --run-name gap_up_ts_imb_realized +py -3.11 -m stom_rl.gap_up_dashboard_publish --instances .omx\artifacts\gap_up_sl_gap_stress\instances.json --filter ts_imb --cost-bps 23 --run-name gap_up_ts_imb_sl_gap_stress +``` + +> `--max-symbols 0` = full universe(2400+종목, 매우 느림). 검증엔 120으로 충분(1349 인스턴스/115종목 = 위 모든 수치의 universe). 비용은 가산적이라 `--cost-bps`를 굳이 23으로 안 줘도 캐시 @25bp에서 환산 가능. + +`instances.json` 레코드 키: `tp5_sl1_net_pct`(외 TP/SL 조합 `*_net_pct`/`*_reason`, @해당 cost·fill_mode), `pass_ts`, `pass_ts_imb`, `entry_change_rate`, `entry_price`, `entry_trade_strength`, `entry_sec_amount`, `entry_bid_ask_imbalance`, `baseline_net_pct`, `session`(YYYYMMDD str), `symbol`, `split`. + +--- + +## 8. 대시보드 (발행 + 서버) + +- 발행물: `webui/rl_runs//` 3파일 — `portfolio_paper_summary.json`(감지 anchor, 파일명 기반), `rl_live_events.jsonl`(거래당 1 event, `equity`=NAV → follow/replay 곡선), `rl_live_summary.json`. +- 정직 라벨(필수): `algorithm="rule:gap_up_"`, config `is_reinforcement_learning=false`, info `note="RULE strategy backtest, not an RL policy"`. +- URL: `http://127.0.0.1:5070/rl` → run 선택 → NAV 곡선. 5개 run final_nav: ts_imb_equity 3.24M / realized 3.13M / sl_gap_stress 2.91M / ts 3.69M / none 4.32M. +- **서버 기동(헤드리스)**: 리로더 OFF라 **코드/신규 run 인식하려면 재시작 필요**. 5070 리슨 PID kill 후: + ```powershell + $env:KRONOS_WEBUI_PORT='5070'; $env:KRONOS_WEBUI_OPEN_BROWSER='0'; $env:KRONOS_WEBUI_RELOAD='0'; py -3.11 webui\run.py + ``` + 감지는 `portfolio_paper_summary.json` 파일명 기반. 서버가 run을 `artifact_type=unknown`으로 주면 stale → 재시작. + +--- + +## 9. 내장 스크립트 소스 (gitignored이라 여기 보존 — 그대로 생성하면 됨) + +### 9-A. `.omx/artifacts/gap_up_backtest/build_equity_curve.py` +```python +"""시초 갭상승 누적 equity 곡선 빌더 (캐시 instances.json에서 즉시, DB 재실행 불필요). + +usage: + py -3.11 .omx/artifacts/gap_up_backtest/build_equity_curve.py # 콘솔 요약 + ASCII + py -3.11 .omx/artifacts/gap_up_backtest/build_equity_curve.py --png # PNG도 저장(matplotlib 필요) +입력: .omx/artifacts/gap_up_backtest/instances.json (per-TP/SL net_pct@25bp, pass_ts, pass_ts_imb, session, symbol) +비용: 캐시 net은 25bp 기준 -> 23bp는 +0.02%p 가산. 룰 전략 곡선(강화학습 아님), 비복리 per-trade % 단순합. +""" +import json +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +INST = os.path.join(HERE, "instances.json") +KEY = "tp5_sl1_net_pct" +SHIFT = 0.02 # 25bp -> 23bp +FILTERS = [("none", "UNFILTERED(2%갭만)"), ("pass_ts", "+체결강도(ts)"), + ("pass_ts_imb", "+체결강도+호가(ts_imb)")] + + +def build(flt): + inst = json.load(open(INST, encoding="utf-8-sig")) + rows = [r for r in inst if flt == "none" or r.get(flt)] + rows.sort(key=lambda r: (r["session"], r["symbol"])) + eq = peak = mdd = 0.0 + wins = ls = mls = 0 + curve = [] + for r in rows: + net = r[KEY] + SHIFT + eq += net + peak = max(peak, eq) + mdd = min(mdd, eq - peak) + if net > 0: + wins += 1 + ls = 0 + else: + ls += 1 + mls = max(mls, ls) + curve.append((r["session"], eq)) + n = len(rows) + stats = dict(n=n, cum=eq, exp=eq / n if n else 0, win=wins / n if n else 0, + maxdd=mdd, maxloss=mls) + return stats, curve + + +def main(): + do_png = "--png" in sys.argv + print(f"=== 시초 갭상승 누적 equity (룰 전략, NOT RL) | {KEY} @23bp ===") + series = {} + for flt, label in FILTERS: + s, curve = build(flt) + series[flt] = (label, curve) + print(f"{label:22} N={s['n']:4d} cum={s['cum']:+7.1f}% " + f"exp={s['exp']:+.3f}%/trade win={s['win']:.0%} " + f"maxDD={s['maxdd']:+.1f}% maxLossStreak={s['maxloss']}") + label, curve = series["pass_ts_imb"] + print(f"\n-- {label} 곡선 (cum %), ~12 표본 --") + step = max(1, len(curve) // 12) + for i in range(0, len(curve), step): + s, e = curve[i] + print(f" {s} t{i:3d}: {e:+7.1f} {'#' * int(max(0, e))}") + s, e = curve[-1] + print(f" {s} t{len(curve)-1:3d}: {e:+7.1f} (final)") + if do_png: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + for _f in ("Malgun Gothic", "NanumGothic", "AppleGothic"): + try: + plt.rcParams["font.family"] = _f + break + except Exception: + continue + plt.rcParams["axes.unicode_minus"] = False + fig, ax = plt.subplots(figsize=(11, 5)) + for flt, (label, curve) in series.items(): + ax.plot(range(len(curve)), [e for _, e in curve], label=label, lw=1.3) + ax.set_title("시초 갭상승 누적 equity (룰 전략, NOT RL) TP5/SL1/09:25 @23bp") + ax.set_xlabel("trade #") + ax.set_ylabel("cumulative net % (비복리)") + ax.legend() + ax.grid(alpha=0.3) + out = os.path.join(HERE, "equity_curve.png") + fig.tight_layout() + fig.savefig(out, dpi=120) + print(f"\nPNG saved: {out}") + + +if __name__ == "__main__": + main() +``` + +### 9-B. `.omx/artifacts/gap_up_backtest/fill_mode_compare.py` +```python +"""3개 fill_mode(idealized/realized/sl_gap_stress) 비교 추출기. +각 모드의 .omx/artifacts/gap_up_/instances.json 에서 ts_imb TP5/SL1 기대값·승률·누적·exit-mix 계산. +(instances.json 의 tp5_sl1_net_pct 는 해당 모드 fill 로 이미 계산됨; @25bp 캐시면 23bp는 +0.02 가산해 읽을 것.) +""" +import json +import os + +HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # .omx/artifacts +MODES = ["idealized", "realized", "sl_gap_stress"] +KEY = "tp5_sl1_net_pct" +RKEY = "tp5_sl1_reason" + + +def load(mode): + p = os.path.join(HERE, f"gap_up_{mode}", "instances.json") + if not os.path.isfile(p): + return None + return json.load(open(p, encoding="utf-8-sig")) + + +def stats(rows, flt): + sel = [r for r in rows if flt == "none" or r.get(flt)] + sel.sort(key=lambda r: (r["session"], r["symbol"])) + if not sel: + return None + nets = [r[KEY] for r in sel] + n = len(nets) + cum = peak = mdd = 0.0 + for v in nets: + cum += v + peak = max(peak, cum) + mdd = min(mdd, cum - peak) + wins = sum(1 for v in nets if v > 0) + reasons = {} + for r in sel: + reasons[r.get(RKEY)] = reasons.get(r.get(RKEY), 0) + 1 + return dict(n=n, exp=sum(nets) / n, cum=cum, win=wins / n, mdd=mdd, + sl=reasons.get("sl", 0) / n, tp=reasons.get("tp", 0) / n, + time=reasons.get("time", 0) / n) + + +def main(): + print(f"=== fill_mode 비교 (ts_imb, {KEY}, 120종목 동일 universe) ===") + avail = {m: load(m) for m in MODES} + missing = [m for m, v in avail.items() if v is None] + if missing: + print(" 아직 없음:", missing) + for flt, label in [("pass_ts_imb", "ts_imb"), ("pass_ts", "ts"), ("none", "none")]: + print(f"\n-- filter={label} --") + print(f" {'mode':14} {'N':>4} {'exp%/trade':>11} {'cum%':>8} {'win':>5} {'maxDD%':>7} {'tp/sl/time':>14}") + for m in MODES: + rows = avail.get(m) + if rows is None: + print(f" {m:14} (pending)") + continue + s = stats(rows, flt) + if s is None: + print(f" {m:14} n=0") + continue + print(f" {m:14} {s['n']:>4} {s['exp']:>+11.3f} {s['cum']:>+8.1f} " + f"{s['win']:>5.0%} {s['mdd']:>+7.1f} " + f"{s['tp']:.0%}/{s['sl']:.0%}/{s['time']:.0%}") + if not missing: + si = stats(avail["idealized"], "pass_ts_imb") + sr = stats(avail["realized"], "pass_ts_imb") + ss = stats(avail["sl_gap_stress"], "pass_ts_imb") + print("\n-- ts_imb de-idealization (per-trade %p delta vs idealized) --") + print(f" realized: {sr['exp']-si['exp']:+.3f}%p -> exp {sr['exp']:+.3f}%/trade") + print(f" sl_gap_stress: {ss['exp']-si['exp']:+.3f}%p -> exp {ss['exp']:+.3f}%/trade (worst)") + print(f" idealized baseline: {si['exp']:+.3f}%/trade") + print(f" => worst-case {ss['exp']:+.3f}%/trade: {'SURVIVES (>0)' if ss['exp']>0 else 'FAILS (<=0)'}") + + +if __name__ == "__main__": + main() +``` + +--- + +## 10. 커밋 맵 (이 작업의 git 이력) +| 커밋 | 내용 | +|---|---| +| `5c56a47` | docs: 2022 약세 다중비교 보정 → 소표본 변동성 결론 | +| `e5d89c2` | feat: 우상향 곡선 대시보드 발행 + 체결 de-idealization(fill_mode) 게이트 | +| `d87dd00` | docs: 실비용 23bp 확정(전 필터 양수, ts_imb +0.9%) | +| `c80a9c9` | feat: 레짐 robustness + 슬리피지 검증 | +| `bf767d7` | feat: 현실 비용모델 + 진입필터 + cost sweep(필터 시 OOS 양수) | +| `48bbdef` | feat: 갭상승 TP/SL/09:25 백테스트(필터 전 0/16) | + +--- + +## 11. 정직성 가드레일 (재개 시 필수) +- 룰 전략 곡선을 **"강화학습/RL"이라 부르지 않는다**(RL 알파 부재 증명됨; 라벨 `rule:*`, `is_reinforcement_learning=false` 유지). +- 누적곡선은 **비복리 per-trade % 합** — 연수익률/복리 계좌곡선으로 과장 금지. +- 영상의 매끈한 곡선 = 보통 in-sample/과적합. 우리 곡선은 거칠지만 OOS·매년·5경계·체결 de-idealization 검증됨. +- 체결 현실성(realized/SL gap-through/슬리피지) 확정 전까지 "실거래 준비 완료"라 말하지 않는다. +- 2022 등 단일 연도 결과는 큰 오차(SE ±0.4%) — 다중비교 보정 후 판단. + +--- + +## 12. 다음 할 일 (결정 트리) +- **A. 사이징/리스크 설계**: 승률 41~51%·TP5/SL1 비대칭·최장연패 9·최대낙폭 ~20% → 포지션 사이징·동시보유·일손실한도 룰 정하기(실거래 직전 가장 실질적). +- **B. universe 확장**: `--max-symbols 0`(full)로 재실행해 120종목→전체에서 수치 유지되는지. +- **C. 실주문 슬리피지/유동성 모델**: 갭상승 개장가 실제 체결가능 물량 가정 정교화. +- **D. (불가) 진짜 큐/부분체결 replay**: L2 데이터 확보 시에만. +- **E. 라이브 페이퍼**: 시점별 read-only 신호 생성기로 forward 검증(주문 미발행). + +--- + +## 13. 관련 문서 읽기 순서 +1. (본문) `docs/stom_rl_resume_handoff_2026-05-28.md` ← 이 파일(마스터) +2. `docs/stom_rl_gap_up_fillmode_2026-05-28.md` — 체결 de-idealization + 2022 분석 +3. `docs/stom_rl_gap_up_realcost_2026-05-28.md` — 실비용 23bp 확정 +4. `docs/stom_rl_gap_up_regime_validation_2026-05-28.md` — 레짐/슬리피지 +5. `docs/stom_rl_gap_up_cost_filter_2026-05-27.md` — 비용모델+필터 첫 양수 +6. `docs/stom_rl_gap_up_backtest_2026-05-27.md` — 필터 전 기준선(0/16) +7. `docs/stom_rl_deep_rl_verdict_2026-05-27.md` — RL 알파 부재 종합 diff --git a/docs/stom_rl_resume_handoff_2026-06-01.md b/docs/stom_rl_resume_handoff_2026-06-01.md new file mode 100644 index 000000000..edaed9072 --- /dev/null +++ b/docs/stom_rl_resume_handoff_2026-06-01.md @@ -0,0 +1,155 @@ +# STOM RL 랩 — 마스터 재개(RESUME) 핸드오프 2026-06-01 (최신 앵커) + +- 작성일: **2026-06-01 KST** +- 저장소: `D:\Chanil_Park\Project\Programming\Kronos` / 브랜치: `feature/stom-rl-lab` / HEAD: `7adc190` (**로컬만 — push 미실시**) +- **목적: 새 대화에서 이 문서 하나만 읽고 STOM RL 랩 전체를 그대로 이어간다.** 이전 앵커(`stom_rl_resume_handoff_2026-05-28.md`, `stom_rl_resume_commit_2026-05-29.md`, `stom_rl_session_progress_2026-05-29.md`)를 **대체**한다. +- 데이터(유일): `_database/stock_tick_back.db` — 1초봉, 개장 **09:00–09:30만**, 이벤트 트리거(희소), UTF-8 한글 컬럼, 종목코드 **선행 0 보존**. `초당거래대금`=**백만원 단위(×1e6)**. KRX 09:00봉은 단일가 동시호가 print(O=H=L=C, 거래량 ~30분 누적)이라 연속거래봉 취급 금지. + +--- + +## 0. 한 줄 현황 (TL;DR) + +**RL/딥러닝은 이 데이터에서 방향성 수익을 못 낸다 — 선택(shuffle 무알파)·타이밍(P1b NO-GO)·학습된 PPO 100k(전체 2,730ep서 buy-and-hold 미달) 전부 적대검정으로 닫혔다. 수익 축은 RL이 아니라 "시초 갭상승 ts_imb 룰 전략"이며, full universe·전 연도·최악체결·마켓에이블 스프레드·유동성·운영정책 replay까지 통과(마켓에이블 de-idealized +0.884%/trade). 1초봉은 "알파"가 아니라 "비용·리스크·체결 진실 레이어"로 확정. 현재 단 하나 살아있는 알파-인접 실험 = ① skip-gate(SL예측 게이트가 GO 줘서 빌드 정당화됨, 사전확률 ~20–30%, "드리프트 트랩" 가드 필수). 실거래 수익은 여전히 0 — 전부 백테스트/시뮬.** + +--- + +## 1. 재개 프로토콜 (새 대화 첫 행동) + +새 대화 첫 프롬프트: +``` +D:\Chanil_Park\Project\Programming\Kronos 에서 이어서 진행. +docs/stom_rl_resume_handoff_2026-06-01.md 를 읽고 그 문서 기준으로 STOM RL 랩을 재개하세요. +정직성: RL 알파 부재는 검증 완료 — 갭상승 룰 곡선을 RL이라 부르지 마세요. 모든 양수치는 in-sample/triggered-subset/라이브 없음. +``` +최소 확인: +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos +git branch --show-current # feature/stom-rl-lab +git rev-parse --short HEAD # 7adc190 또는 이후 +py -3.11 -m pytest tests/test_stom_rl_gap_up_backtest.py tests/test_stom_rl_sl_predictor.py -q # 통과 확인 +``` +전체 게이트 스위트(14파일)는 **224 passed**(§6 명령). + +--- + +## 2. 사용자 · 전략 · 비용 (불변) + +- 한국 퀀트 트레이더(parkchanil77@naver.com). 전략 = **"시초 급등 / 9시 시초 갭 상승"**(opening gap-up momentum). +- 스펙: 진입 시초 `등락율` ≥ **2%**, 청산 **TP / SL 또는 09:25**. PRIMARY = **TP5% / SL1% / 09:25**. +- 실제 왕복비용 = **23bp**(매수 0.015% + 매도 0.015% + 증권거래세 0.20%). 비용은 flat 가산적: `net@c = net@25 + (25−c)/100`. +- universe = STOM 트리거 종목 = **사용자 실거래 대상과 일치**(배포 관점 편향 아님). 단 전체 시장 갭상승은 아님(일반화 미입증). +- 원했던 것: 유튜브 NEAT 영상처럼 "꾸준히 우상향 곡선", 실거래 직전 상태인지. + +--- + +## 3. 진입 필터 정의 (`stom_rl/gap_up_backtest.py`) +- `none`: 2% 갭만. `ts`: 체결강도 ≥ 100. **`ts_imb`(주력): 체결강도 ≥ 100 AND 호가 imbalance(매수총잔량/(매수+매도)) ≥ 0.5.** +- 체결모드 `fill_mode`: `idealized`(정확레벨) / `realized`(실제 크로싱가) / `sl_gap_stress`(SL 관통 최악). + `marketable`(buy@ask/sell@bid, 스프레드 2회 — `marketable_fill.py`). + +--- + +## 4. 전체 진행 맵 (무엇이 검정/완료됐나) + +### 4-A. RL/딥러닝 트랙 — 전부 닫힘 (근거 있는 폐기) +| 게이트 | 결과 | 문서 | +|---|---|---| +| 교차종목 **선택** 알파 (shuffle null) | ❌ 1초·1분·세션 전부 shuffle 미통과 | `stom_rl_deep_rl_verdict_2026-05-27.md` | +| R0 딥리서치(105 에이전트) | ❌ 비용차감 OOS 수익 RL 사례 0건 | `stom_rl_rl_feasibility_research_2026-05-29.md` | +| R1 oracle-exit 천장 / R1b 인과 청산 | ❌ capture 17.8% 있으나 인과적 포착 불가, DSR 0.931<0.95 → 청산 RL 폐기 | `stom_rl_oracle_exit_ceiling_*`, `stom_rl_exit_baseline_*` | +| P0+P1 예측 프로브 | ⚠️ microstructure가 60s forward를 IC≈0.10로 예측(robust)하나 조건부 | `stom_rl_predictability_gate_2026-05-30.md` | +| **P1b 타이밍 게이트**(마켓에이블·paired) | ❌ **결정적 NO-GO** — 룰 고정 09:00보다 −0.38~−0.46%/trade 나쁨(N=5,173, DSR=0) | `stom_rl_timing_gate_2026-05-30.md` | +| **PPO 100k 전체 재평가**(2,730 ep) | ❌ avg +0.165%/ep, **median −0.276%**, hit 31%, MDD −74%, buy-and-hold(+0.513%) 미달, cost gate 미통과 — **소표본 +1.0% 환상 확정** | `stom_ppo_full_eval_2026-05-31.md` | + +### 4-B. RULE 갭상승 트랙 — 검증 통과 (수익 축) +| 페이지 | 결과 | 문서 | +|---|---|---| +| 비용모델+필터+cost sweep | 필터 시 OOS 양수(첫 긍정) | `stom_rl_gap_up_cost_filter_2026-05-27.md` | +| 레짐/슬리피지 + 실비용 23bp | 매년·5경계 양수, ts_imb +0.9%/trade | `stom_rl_gap_up_regime_validation_*`, `..._realcost_2026-05-28.md` | +| 체결 de-idealization(fill_mode) | ts_imb 최악(sl_gap_stress) +0.811%/trade, breakeven 대비 ~4× | `stom_rl_gap_up_fillmode_2026-05-28.md` | +| Page A 사이징/리스크 | f=10%/회·동시보유3·일손실−3%, 1R=계좌 0.123%, 계좌 MDD −1.6~−2.0% | `stom_rl_gap_up_risk_sizing_2026-05-29.md` | +| **Page B full universe** | **2314종목·29139 instance, 전 연도 양수, 2022 +0.742%로 해소(소표본 노이즈 확정), breakeven OOS 98bp** | `stom_rl_gap_up_full_universe_2026-05-29.md` | +| Page C 유동성+gap-through 꼬리 | 유동성 PASS·sl_gap_stress 전 연도 양수(breakeven OOS 81bp) | `stom_rl_liquidity_slippage_2026-05-29.md` | +| Page D 동결정책 paper replay | 정책 작동·저낙폭, deadlock 수정(서킷브레이커) | `stom_rl_paper_replay_2026-05-29.md` | +| **마켓에이블 체결 확정** | **full N=5,173 룰 net +0.884%/trade**(buy@ask/sell@bid, 스프레드 2회) | `marketable_fill.py`, 데이터레이어 §2 | + +### 4-C. 데이터 레이어 평가 + 살아남은 4 실험 (현재 작업면) +상위 문서: **`docs/stom_data_layer_assessment_2026-05-30.md`** (10-에이전트 적대패널). 결론: **1초봉은 알파 아님 = 비용/리스크/체결 진실 레이어**(검정으로 닫힌 문). 살아남은 실험: + +| # | 실험 | 상태 | 결과 | +|---|---|---|---| +| ② 초당흐름 재구성(용량 정직화) | ✅ 완료 | 용량 헤드룸 **~49× 과대** 정정(09:00 동시호가 누적이 분모 오염), 조건부 PASS로 강등. `liquidity_recon.py` | +| ③ SL예측 선행 분류기(싼 디리스커) | ✅ 완료 | **PREDICTABLE → GO**: entry AUC 0.60·path30 0.66–0.68, symbol-disjoint 0.61–0.67, 4모델 사전등록 통과. **단 "리스크(하방변동성) 예측"이지 방향성 알파 아님.** `sl_predictor.py` | +| **① skip-gate(진입/스킵 이진)** | ⬜ **다음 (빌드 정당화됨)** | ③가 전제(조건 걸 신호 존재) 통과 → 유일한 진짜 미검정 알파-인접 레버. 사전확률 ~20–30%. | +| ④ 상태조건 청산 | ⬜ ① 이후 | path30 lift로 사전확률 소폭↑, 단 갭상승 평균회귀 반대 구조 그대로 | + +--- + +## 5. ① skip-gate — 다음 작업 상세 (가장 중요) + +**무엇**: 진입 시점에 "이 트레이드를 *진입할지 스킵할지*" 이진 결정하는 baseline-relative 게이트. 타이밍(언제 진입)이 아니라 진입/스킵. + +**왜 살아남았나**: ③ SL예측이 진입 microstructure로 하방변동성을 AUC 0.60·강건하게 가른다 → "예측-최악 슬라이스를 스킵해 트레이드당 net을 올린다"는 전제가 기각되지 않음. 두 적대패널 모두 #1 갭으로 지목. `timing_gate.py`는 t=0 무조건 진입만 호출(스킵 메커니즘 부재) → 미검정. + +**치명적 함정 = "드리프트 트랩"** (P1을 GO→NO-GO로 뒤집은 바로 그것): forward 드리프트가 대체로 양수라, SL로 끝나는 트레이드조차 *비용차감 net이 음수가 아닐* 수 있다. AUC 0.60 SL예측은 "SL 많은 슬라이스 식별"일 뿐, 그 슬라이스를 스킵해 **돈이 남는지**는 별개. SL 라벨은 *최종* 이유라 net 부호와 1:1 아님(SL도 도중 +α 후 반전 가능). + +**설계 가드(사전등록 필수)**: +1. 결정변수 = **비용차감(23bp+마켓에이블) baseline-relative net** (드리프트 상쇄). 스킵 슬라이스의 net이 *0 미만*이어야 스킵이 정당. +2. purged walk-forward(이전 세션 train→이후 test) + per-boundary(5경계) + 세션 부트스트랩 CI + DSR(시도 컷 수 반영, ≥0.95). +3. positive/negative control로 게이트 검출력 먼저 입증(③·P1 패턴). +4. full universe N=5,173, symbol-disjoint 확인. 사전등록 후 단일 실행(P1식 artifact 방지). +구현 출발점: `stom_rl/sl_predictor.py`(피처·라벨·walk-forward 재사용), `marketable_fill.py`(net), `gap_up_backtest.py`(인스턴스). + +--- + +## 6. 재현 / 검증 명령 + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos +# 전체 게이트 스위트 (기대: 224 passed) +py -3.11 -m pytest tests/test_stom_rl_gap_up_backtest.py tests/test_stom_rl_gap_up_dashboard_publish.py ` + tests/test_stom_rl_gap_up_risk_sizing.py tests/test_stom_rl_exit_oracle.py tests/test_stom_rl_exit_baselines.py ` + tests/test_stom_rl_liquidity_model.py tests/test_stom_rl_paper_sim.py tests/test_stom_rl_sl_predictor.py ` + tests/test_stom_rl_liquidity_recon.py tests/test_stom_rl_condition_screener.py tests/test_stom_rl_marketable_fill.py ` + tests/test_stom_rl_timing_gate.py tests/test_stom_rl_predictability_probe.py tests/test_stom_rl_full_universe.py -q + +# 무거운 재생성 (UTF-8 강제, 장시간) +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 stom_rl/gap_up_backtest.py --max-symbols 0 --regime-analysis --regime-cost-bps 23 --artifacts-dir .omx/artifacts/gap_up_full # Page B full +py -3.11 -X utf8 -m stom_rl.sl_predictor # ③ SL예측 게이트 (~88분) +py -3.11 -X utf8 -m stom_rl.liquidity_recon # ② 용량 재구성 +py -3.11 -X utf8 .omx/ppo_full_eval.py 0 # PPO 100k 전체 재평가 (checkpoint-resumable) +``` + +--- + +## 7. 신규 모듈 인벤토리 (이 트랙, 전부 커밋됨) +`gap_up_backtest.py`(엔진·fill_mode) · `gap_up_dashboard_publish.py` · `gap_up_risk_sizing.py`(A) · `exit_oracle.py`(R1) · `exit_baselines.py`(R1b) · `liquidity_model.py`(C) · `paper_sim.py`(D) · `marketable_fill.py`(마켓에이블 체결) · `microstructure_features.py`(인과피처) · `predictability_probe.py`(P0/P1) · `timing_gate.py`(P1b) · `liquidity_recon.py`(②) · `sl_predictor.py`(③) · `condition_screener.py`. 분석 스크립트: `.omx/ppo_full_eval.py`. 곡선 빌더(gitignored, 소스는 `stom_rl_resume_handoff_2026-05-28.md` §9): `.omx/artifacts/gap_up_backtest/build_equity_curve.py`, `fill_mode_compare.py`. + +gitignored 산출물(`.omx/artifacts/`, `webui/rl_runs/`): 각 페이지 문서 "재현" 절로 재생성. 대시보드 run 5개(`gap_up_ts_imb_equity`/`_realized`/`_sl_gap_stress`/`gap_up_ts_equity`/`gap_up_none_equity`)는 `gap_up_dashboard_publish.py`로 재발행. + +--- + +## 8. 정직성 가드레일 (불변 — 재개 시 필수) +1. 갭상승 룰 곡선을 **"강화학습/RL"이라 부르지 않는다.** RL은 선택·타이밍·학습정책 전부 적대검정으로 닫힘. +2. 누적곡선은 **비복리 per-trade % / fixed-notional**. paper replay 헤드라인(+수백%)은 복리×시뮬 낙관 상한이지 미래 기대 아님. avg per-episode/per-trade가 정직한 1회 기대. +3. 모든 양수치는 **in-sample / triggered-subset(STOM 기록 세션만) / 라이브 포워드 없음 / L2 큐 없음** → 기대 실거래 수익 아님, 수익 보장 아님. +4. SL예측 GO = "리스크 식별 가능"이지 "수익 식별 가능"이 아님. ①에서 *비용차감 net 부호*로 드리프트 트랩 정면 검정 전까지 ① 수익성 단정 금지. +5. "실거래 준비 완료" 미선언. 진짜 forward(실시간 피드, 환경 밖) 필요. **E broker/실주문은 명시 승인 전 금지.** +6. 단일 게이트 통과는 전체 조사서 시도한 컷 수에 비례해 의심(다중비교). 2022 약세는 대표본서 +로 해소된 소표본 노이즈 — 레짐 붕괴 단정 금지. + +--- + +## 9. 다음 결정 트리 +- **A. ① skip-gate 빌드(권장·다음)**: §5 가드로 사전등록 → 단일 실행. 통과 시 처음으로 룰 위 *증분* 알파 후보 확보. 실패해도 정직성↑. +- **B. ④ 상태조건 청산**: ① 이후. path30 예측 활용, 단 평균회귀 반대 구조 — 잔혹 deflate, 후보 ≤5. +- **C. 진짜 forward/paper(실시간)**: 환경 밖 데이터소스 연동 필요. 그 전엔 E 금지. +- **D. 사이징 실거래화**: Page A 룰을 실계좌 파라미터로 구체화(승률 41~51%·최장연패 9·계좌 MDD ~2% 반영). + +## 10. 문서 읽기 순서 +1. (본문) 이 파일 ← 최신 마스터 앵커 +2. `docs/stom_data_layer_assessment_2026-05-30.md` — 1초봉 역할 확정 + 살아남은 4실험 +3. `docs/stom_sl_predictor_gate_2026-05-30.md` — ③ SL예측 GO(다음 ①의 전제) +4. `docs/stom_ppo_full_eval_2026-05-31.md` — PPO 100k 소표본 환상 확정 +5. `docs/stom_rl_timing_gate_2026-05-30.md` — P1b 타이밍 NO-GO +6. `docs/stom_rl_session_progress_2026-05-29.md` — A→D 페이지 완료 상세 +7. `docs/stom_rl_resume_handoff_2026-05-28.md` — 곡선 빌더 소스(§9) + 초기 검증 diff --git a/docs/stom_rl_rl_execution_research_plan_2026-05-25.md b/docs/stom_rl_rl_execution_research_plan_2026-05-25.md new file mode 100644 index 000000000..2dbcbe43f --- /dev/null +++ b/docs/stom_rl_rl_execution_research_plan_2026-05-25.md @@ -0,0 +1,183 @@ +# STOM RL 실행 연구 계획 — DB 변수 기반 RL vs 조건식 생성 판단 + +작성일: 2026-05-25 +관련 문서: +- `docs/stom_rl_portfolio_design_handoff_2026-05-25.md` +- `.omx/plans/ralplan-stom-rl-portfolio-page1-6.md` + +## 1. 결론 + +현재 단계에서 강화학습을 진행하는 올바른 방향은 다음이다. + +> 조건식을 강화학습이 직접 생성하게 하지 않는다. +> STOM DB 변수와 파생 feature를 RL state로 넣고, 조건식은 후보 종목을 줄이는 screener/filter로 사용한다. + +즉, 초기 실전 구조는 다음처럼 분리한다. + +```text +STOM DB 변수/feature + -> 조건식 screener로 후보 top-K 생성 + -> PortfolioEnv가 후보/보유/현금/NAV 상태 구성 + -> RL agent가 매수/매도/보유/사이징을 학습 + -> walk-forward + paper replay로 검증 +``` + +## 2. 왜 조건식 생성과 RL을 동시에 하지 않는가 + +| 항목 | 판단 | 이유 | +|---|---|---| +| DB 변수 기반 RL | 권장 | 가격, 수급, 호가, 체결강도, 포트폴리오 상태를 관측값으로 두고 행동을 학습하는 일반적 구조 | +| 조건식 후보 필터 + RL 의사결정 | 권장 | 수천 종목 행동공간을 top-K 후보로 줄여 학습 안정성과 검증 가능성을 높임 | +| RL이 조건식을 동시에 생성 | 초기 단계 비권장 | 조건 변수/임계값/AND/OR 조합으로 행동공간이 폭발하고 과최적화·룩어헤드·데이터마이닝 위험이 큼 | +| 조건식 자동 생성 | 후속 연구 가능 | RL 본체와 분리해 walk-forward로 고정 조건식 세트를 검증한 뒤 별도 최적화 문제로 다뤄야 함 | + +핵심 원칙: + +1. 조건식은 초기에는 **universe/candidate filter**다. +2. RL은 **후보 중 선택, 비중, 청산, 보유시간**을 학습한다. +3. 조건식 자체를 자동 생성하는 문제는 성능 검증 후 별도 Page로 분리한다. + +## 3. 현재까지 연구/문서화 충분성 평가 + +| 영역 | 충분성 | 현재 상태 | 남은 연구 | +|---|---:|---|---| +| 단일 종목 RL 기반 | 높음 | env/SB3/eval/walk-forward 기존 구현 있음 | 포트폴리오 회계와 완전 통합은 후속 | +| 포트폴리오 RL 엔진 구조 | 중~높음 | Page 1~6 구현, synthetic smoke 통과 | 실제 후보 데이터에서 shape/action/mask 재검증 | +| 조건식 screener 안전성 | 중 | AST whitelist, 금지 토큰 테스트 있음 | 실제 STOM 조건식 문법 coverage 확장 | +| DB 변수 feature 설계 | 중 | canonical feature mapping 구현 | 실제 DB 컬럼별 결측/스케일/분포 분석 필요 | +| 실제 RL 학습 알고리즘 | 낮~중 | smoke runner는 있음 | DQN/PPO/MaskablePPO 등 알고리즘 선택 연구 필요 | +| reward/action 설계 | 낮~중 | 기본 NAV reward/action contract 있음 | 비용, MDD, turnover, 보유시간 penalty 연구 필요 | +| full walk-forward 성능 검증 | 낮 | smoke report 있음 | 실제 기간별 fold, baseline 대비 검증 필요 | +| paper replay 실전성 | 중 | read-only replay/risk log 구현 | 실제 candidate/model action replay 필요 | +| dashboard 연결 | 낮 | 기존 RL UI 기반 있음 | portfolio artifact 전용 UI 설계 필요 | + +판단: + +- **Page 7~9, 즉 실제 DB feature export와 조건식 후보 CSV 생성은 진행해도 된다.** +- **Page 10 이후 실제 RL 학습은 시작 가능하지만, 최종 성능 달성으로 보려면 알고리즘·보상·검증 연구가 더 필요하다.** +- 따라서 현재 연구는 “강화학습 실행 준비”로는 충분하지만, “수익성 있는 최종 RL 전략 완성”으로는 아직 충분하지 않다. + +## 4. 최종 목적 기준 진행률 + +| 구간 | 진행률 | 의미 | +|---|---:|---| +| Page 1~6 엔지니어링 기반 | 100% | 포트폴리오 RL 뼈대, 회계, screener, smoke, risk/paper 구조 완료 | +| 실제 DB feature export 준비 | 20% | helper는 있으나 full-scale 실행 전 | +| 실제 조건식 후보 생성 준비 | 20% | screener는 있으나 실제 전략 파일 연결 전 | +| 실제 candidate CSV 기반 RL 입력 | 10% | schema는 있으나 실제 산출물 전 | +| 실제 portfolio RL 학습 | 10% | env는 있으나 학습 알고리즘/runner 고도화 전 | +| full walk-forward 성능 검증 | 15% | smoke는 있으나 실제 fold 검증 전 | +| paper replay 실전 검증 | 20% | read-only 구조는 있으나 실제 replay 전 | +| dashboard 연결 | 0~20% | 기존 UI 기반은 있으나 portfolio 전용 연결 전 | +| 성능 최적화 | 0% | 최종 수익성/안정성 개선 단계 전 | + +최종 목적 전체 진행률: **약 55~60%** + +## 5. 남은 페이지 로드맵 + +### Page 7 — 실제 STOM DB feature export dry-run + +| 항목 | 내용 | +|---|---| +| 목표 | 실제 `_database/stock_tick_back.db`에서 canonical RL feature를 생성 | +| 입력 | STOM SQLite DB | +| 출력 | 확장 feature CSV/manifest/report | +| 완료 기준 | 작은 테이블/짧은 기간 dry-run 성공, 결측/스케일 리포트 생성 | +| 주요 위험 | 29.7GB DB runtime, 컬럼명 encoding, 결측/스케일 불안정 | + +### Page 7.5 — 다종목 1초 시간 동기화 join (신규, 핵심 데이터 게이트) + +| 항목 | 내용 | +|---|---| +| 목표 | 여러 종목의 1초봉을 **공통 시간 인덱스**에 정렬해 포트폴리오가 같은 시각에 여러 종목을 동시에 관측·매매할 수 있는 panel을 만든다 | +| 배경 | 포트폴리오 RL은 동일 시각에 여러 종목 상태를 동시에 봐야 성립한다. DB는 종목별 테이블로 분리돼 있어 이 join이 없으면 candidate CSV(Page 9)와 PortfolioEnv가 성립하지 않는다 | +| 입력 | Page 7 feature export(종목별 1초 feature) | +| 출력 | `timestamp`를 키로 정렬한 다종목 panel(또는 long-format `timestamp, symbol, feature...`) + 결측 시각 처리 규칙(forward-fill 한계, 거래정지/VI 구간 제외) | +| 완료 기준 | ① 정렬 후 종목 간 timestamp 정합성 검증 ② 룩어헤드 차단(각 시점 row는 decision time 이하 데이터만 사용) ③ 결측/거래정지 구간 리포트 ④ 소수 종목·짧은 구간 정렬 결과 재현 가능 | +| 주요 위험 | 종목별 상이한 거래 시작/정지 시각, 1초 결측, 동시호가/VI 구간, 메모리(다종목 × 하루 1초 ≈ 수십만 row) | +| 비고 | 이 게이트는 Page 9(candidate CSV)의 **선행 조건**이다. Page 1~6의 synthetic candidate는 이 문제를 우회했으므로, 실데이터에서 처음 드러나는 가장 큰 데이터 엔지니어링 난관이다 | + +### Page 8 — 실제 조건식 전략 선정/정규화 + +| 항목 | 내용 | +|---|---| +| 목표 | 실제 사용할 매수 조건식 세트를 선정하고 screener 입력으로 정규화 | +| 입력 | `docs/reference/stom_ai_agent/*`, 실제 조건식 파일 | +| 출력 | whitelist 통과 조건식 JSON/rule set | +| 완료 기준 | 금지 문법 차단, unknown variable 정리, buy/sell-only 변수 분리 | + +### Page 9 — 실제 candidate CSV 생성 + +| 항목 | 내용 | +|---|---| +| 목표 | 조건식 통과 종목을 PortfolioEnv 입력 후보군으로 생성 | +| 출력 schema | `timestamp`, `symbol`, `condition_id`, `passed`, `rank_score`, `price`, `feature...` | +| 완료 기준 | 여러 시점/여러 종목 candidate CSV 생성, top-K 분포 리포트 | + +### Page 10 — 실제 Portfolio RL 학습 + +| 항목 | 내용 | +|---|---| +| 목표 | 실제 candidate CSV로 RL agent를 학습 | +| 시작 알고리즘 | DQN/PPO smoke, 이후 action mask가 중요하면 MaskablePPO 검토 | +| 출력 | model, NAV curve, trades, actions, config summary | +| 완료 기준 | deterministic seed로 재현 가능한 train/eval smoke 통과 | +| 연구 필요 | reward shaping, action space, turnover/cost penalty, mask 처리 방식 | + +### Page 11 — full walk-forward 검증 + +| 항목 | 내용 | +|---|---| +| 목표 | 기간별 fold에서 과최적화 여부 검증 | +| 비교 기준 | no-trade, equal-weight candidate, buy-and-hold, rule baseline, RL | +| 완료 기준 | fold별 return/MDD/turnover/trade count/cost report 생성 | +| 성공 기준 | artifact 생성은 engineering complete, baseline 초과는 performance target | + +### Page 12 — 실제 paper replay 검증 + +| 항목 | 내용 | +|---|---| +| 목표 | 실제 후보와 모델 행동을 read-only로 재생 | +| 출력 | decision log, NAV curve, risk trigger log, blocked action reason codes | +| 완료 기준 | broker/order write path 없음, 동일 seed/input에서 deterministic | + +### Page 13 — portfolio dashboard 연결 + +| 항목 | 내용 | +|---|---| +| 목표 | portfolio run artifact를 UI에서 확인 | +| 화면 | NAV, trades, positions, candidate/risk logs, fold summary | +| 완료 기준 | 기존 `/rl` 흐름을 깨지 않고 portfolio 결과 표시 | + +### Page 14 — 성능 최적화 + +| 항목 | 내용 | +|---|---| +| 목표 | 실제 walk-forward 기준 성능 개선 | +| 대상 | feature set, reward, action space, condition ranking, risk params | +| 완료 기준 | 비용 반영 후 baseline 대비 개선 또는 개선 실패 원인 리포트 | + +### Page 15 — 조건식 자동 생성/탐색 연구(선택) + +| 항목 | 내용 | +|---|---| +| 목표 | 조건식 자체를 자동 탐색하거나 생성하는 별도 연구 | +| 전제 | Page 7~14가 안정화된 뒤 진행 | +| 이유 | 조건식 생성과 RL을 동시에 하면 과최적화 위험이 크므로 분리 필요 | +| 검증 | 조건식 train/validation/test 분리, walk-forward, 데이터마이닝 방지 | + +## 6. 다음 권장 실행 순서 + +1. 현재 Page 1~6 변경사항 커밋 +2. Page 7: 실제 DB feature export를 아주 작은 범위로 dry-run +3. Page 7.5: 다종목 1초 시간 동기화 panel 구축·검증 (Page 9의 선행 조건) +4. Page 8: 실제 조건식 1~3개를 rule JSON으로 정규화 +5. Page 9: 실제 candidate CSV 생성 (Page 7.5 panel 기반) +6. Page 10: 실제 candidate CSV로 portfolio RL train smoke +7. Page 11~12: full walk-forward와 paper replay +8. Page 14: 성능 최적화 목표 별도 운영 + +## 7. 한 줄 요약 + +현재 문서화/연구는 **포트폴리오 RL을 실제 데이터 단계로 진입시키기에는 충분**하다. +하지만 **최종 수익성 있는 강화학습 전략 완성**을 위해서는 Page 7~14의 실제 DB 실행, 알고리즘/보상 설계, full walk-forward, paper replay, 성능 최적화 연구가 추가로 필요하다. diff --git a/docs/stom_rl_rl_feasibility_research_2026-05-29.md b/docs/stom_rl_rl_feasibility_research_2026-05-29.md new file mode 100644 index 000000000..bea6f7614 --- /dev/null +++ b/docs/stom_rl_rl_feasibility_research_2026-05-29.md @@ -0,0 +1,161 @@ +# 강화학습 기반 시초 거래 모델 타당성 — 딥리서치 (R0) + +- 작성일: **2026-05-29 KST** +- 브랜치: `feature/stom-rl-lab` +- 상위 앵커: `docs/stom_rl_resume_commit_2026-05-29.md`, `docs/stom_rl_gap_up_risk_sizing_2026-05-29.md`(Page A) +- 방법: 딥리서치 CLI 부재로 **내장 웹검색 다중 fan-out + 도메인 문헌 교차검증**. 아래 인용은 실재가 확인되는 정식 문헌만 표기(placeholder/허위 URL 배제). +- 가드레일: 이 문서는 "RL이 돈을 번다"를 주장하지 않는다. **무엇이 근거가 있고, 무엇이 과적합 함정이며, 우리 데이터에 무엇을 시도/금지할지**를 정리한다. +- **검증 업데이트(필독): §6** — 105개 에이전트 딥리서치 워크플로우(소스 23·주장 93→25 검증·8M 토큰)의 적대적 검증 결과가 본 1차 초안의 낙관적 부분(청산/실행/distributional RL의 "벤치마크 우위" 주장)을 **0-3 기각**했다. §1~§5는 1차 초안, **§6이 검증된 상위 결론**이다. + +--- + +## 0. 한 줄 결론 + +**RL이 트레이딩에서 검증된 가치를 내는 곳은 "방향성 알파/종목선택"이 아니라 "주문 실행(execution)"과 "동적 청산(exit/optimal stopping)" 같은 제어(control) 문제다.** 우리 제약(L2 없음·triggered subset·23bp·진입 선택 알파 부재 확인)에서는 (1) **메타라벨링(지도학습)으로 룰 필터/사이징 개선**과 (2) **oracle distillation 기반 인과적 청산 정책**만이 근거 있는 시도이며, 둘 다 **천장 테스트와 누설방지 검증을 통과해야** 한다. 엔드투엔드 종목선택 RL은 문헌·자체검증 모두 부정적이므로 하지 않는다. + +--- + +## 1. 유효한 접근 (근거 있음) + +### 1.1 RL 주문 실행 (optimal execution) — 가장 확립된 영역 +- 부모 주문을 잘게 쪼개 시장충격을 줄이는 문제. TWAP/VWAP/Almgren–Chriss 대비 RL이 비용을 줄인다는 증거 다수. +- 근거: Nevmyvaka, Feng, Kearns (2006, ICML, 고전); Ning, Lin, Jaimungal "Double Deep Q-Learning for Optimal Execution" (arXiv 1812.06600); Fang et al. "Universal Trading for Order Execution with Oracle Policy Distillation" (AAAI 2021, arXiv 2103.10860). +- **우리 적용성: 낮음.** L2 큐포지션 데이터가 없어 실행 RL은 현재 불가(가드레일과 일치). 단, **oracle distillation 기법 자체는 청산에 재사용 가능**(§1.2). + +### 1.2 동적 청산 / optimal stopping — 우리에게 가장 현실적인 RL 활용 +- "언제 나갈지"는 예측이 아니라 **순차 제어** 문제 → RL/optimal-stopping이 본래 잘하는 형태. +- 근거: Becker, Cheridito, Jentzen "Deep Optimal Stopping" (JMLR 2019, arXiv 1804.05394; 본래 American option이나 청산 timing에 직접 적용 가능). +- **핵심 레버: oracle distillation.** 미래를 본 teacher(완전예지 최적청산)를 student(인과적 청산 정책)가 모방 → 작은 표본에서도 안정적. 우리 갭상승 trade의 20분 창에 잘 맞음. +- **우리 적용성: 중간(조건부).** 진입은 룰이 정하고, RL은 청산만 학습. 단 **천장 테스트(§4 R1)에서 oracle-rule 격차가 커야** 의미 있음. + +### 1.3 메타라벨링 (지도학습; 엄밀히는 RL 아님이나 "AI가 룰 개선"의 정답) +- López de Prado, *Advances in Financial Machine Learning*(Wiley 2018)의 **triple-barrier + meta-labeling**: 1차 모델(=우리 갭상승 룰)이 신호를 내면, 2차 ML이 "이 신호에 베팅할지/크기"를 결정. +- 우리 상황과 정확히 일치: 1차=`ts_imb` 룰, 2차=진입봉 피처로 승률·기대값 예측 → 필터 강화·사이징 연동. +- **표본효율·정직성에서 RL보다 우월.** 진입 선택 신호가 정말 있으면 여기서 먼저 드러나고, 없으면 RL은 더더욱 못 잡는다(아래 §3). +- **우리 적용성: 높음.** 가장 효율 좋은 다음 실험. + +### 1.4 소표본·고비용 환경의 모범사례 +- **Offline/Batch RL**(과거 데이터만으로 학습): Levine et al. "Offline RL: Tutorial/Review"(arXiv 2005.01643), Kumar et al. "Conservative Q-Learning(CQL)"(NeurIPS 2020, arXiv 2006.04779). 분포이탈에 강하지만 소표본 금융에선 여전히 과적합 위험. +- **보상에 비용 내장**: reward = 실현수익 − 23bp(+슬리피지). 안 하면 고회전 자멸(자체검증과 일치). +- **risk-sensitive/distributional RL**: CVaR 목적으로 낙폭 제어. +- **검증**: walk-forward, combinatorial purged CV + embargo(누설 방지), **deflated Sharpe**로 다중검정 보정. + +--- + +## 2. 시초(opening) 고유 특성이 주는 시사점 +- **Intraday momentum**: Gao, Han, Li, Zhou "Market Intraday Momentum"(JFE 2018, S0304405X18301326) — 첫 30분 수익이 마지막 30분을 예측. 시간대(time-of-day) 구조가 실재 → 상태에 "경과시간/세션단계" 피처 포함이 타당. +- 개장 직후는 **변동성·스프레드가 가장 큰 동시에 신호도 가장 강함** → 비용이 신호와 같이 커짐. reward의 비용항이 특히 중요. +- LOB 단기예측(DeepLOB; Zhang, Zohren, Roberts 2019, arXiv 1808.03668)은 in-sample 예측력은 있으나 **비용·지연 차감 후 수익은 논쟁적**. 예측≠수익. + +--- + +## 3. 회의적인 접근 (과적합·실패 근거) + +| 접근 | 왜 회의적 | 근거 | +|---|---|---| +| 엔드투엔드 RL로 방향성 알파/종목선택 | 비용·OOS 견디는 사례 희소, 대부분 과적합 | 자체 shuffle 검정 부재 확인 + RL 트레이딩 survey | +| FinRL/SB3 그대로 | 비현실 체결·데이터 누설·재현불가 빈발(프레임워크일 뿐 수익 증거 아님) | FinRL(arXiv 2011.09607)은 도구; 비판은 재현성 문헌 | +| 백테스트 고샤프 자랑 | 다중검정 거짓발견 | Bailey·Borwein·López de Prado·Zhu "Probability of Backtest Overfitting"(SSRN 2326253); "Deflated Sharpe Ratio"(SSRN 2460551); "Pseudo-mathematics and Financial Charlatanism"(AMS Notices 2014) | +| 딥RL 대용량 정책(우리 데이터) | ts_imb 235·전체 1349세션은 딥RL엔 표본 부족 | RL은 표본 과다소모; offline RL도 소표본 취약 | +| 시장조성(market making) RL | LOB+초저지연 필요, 우리 환경 아님 | Spooner et al.(AAMAS 2018, arXiv 1804.04216) | + +핵심 메시지: **RL은 알파를 만들지 않는다.** 신호가 없으면 노이즈에 과적합할 뿐. 우리는 진입 선택 신호 부재를 이미 검증했다. + +--- + +## 4. 우리 데이터에 권장하는 구체 실험 설계 (게이트형) + +순서가 중요하다. 값싸고 결정적인 게이트부터. + +### R1 — Oracle-exit 천장 테스트 (다음, 필수 게이트) +- 기존 `collect_gap_up_instances`(bounded `--max-symbols`) 재사용. +- 각 trade에서 **완전예지 최적청산** 수익을 계산, 현재 TP5/SL1/시간청산과의 **regret(격차)** 분포 산출. +- regret 작으면 → RL 청산 상한 낮음 → **만들지 않음.** +- regret 크면 → §R3 청산 RL 시도 정당화. +- 산출: `docs/...oracle_exit_ceiling.md` + `stom_rl/exit_oracle.py`(순수함수) + 테스트. + +### R2 — 메타라벨링 지도학습 베이스라인 (R1과 병행 권장) +- 진입봉 피처(체결강도·호가 imbalance·초당거래대금·등락율·시간)로 "TP가 SL보다 먼저" 확률 예측(GBM/로지스틱). +- **purged walk-forward + deflated Sharpe**로 검증. 룰을 OOS에서 이기면 채택, 못 이기면 진입 선택 신호 부재 재확인. +- 산출: 베이스라인 비교표 + 순수 추론함수 + 테스트. + +### R3 — 인과적 청산 정책 (R1 통과 시에만) +- teacher=oracle 청산, student=인과 정책(작은/정규화 모델, 가능하면 tabular/선형). reward에 23bp 내장. +- optimal-stopping 또는 imitation(behavior cloning). 날짜분할 OOS·다중시드. + +### R4 — Offline RL 청산(선택, R3 유망 시에만) +- CQL 등으로 청산 정책 강화. CPCV·embargo·deflated Sharpe 필수. 과적합 시 즉시 폐기. + +### 공통 하드 제약 +- reward에 비용 필수, 날짜분할 OOS·purged CV, 다중시드, deflated Sharpe, triggered-subset 편향 명시, L2 부재→실행 RL 금지, 결과는 "동적청산이 룰 대비 OOS X%p 개선"으로만 표기(“RL이 돈 번다” 금지). + +--- + +## 5. 출처 (실재 확인 문헌) +- Nevmyvaka, Feng, Kearns (2006) RL for Optimized Trade Execution, ICML. +- Ning, Lin, Jaimungal — Double Deep Q-Learning for Optimal Execution, arXiv 1812.06600. +- Fang et al. — Universal Trading for Order Execution with Oracle Policy Distillation, AAAI 2021, arXiv 2103.10860. +- Becker, Cheridito, Jentzen — Deep Optimal Stopping, JMLR 2019, arXiv 1804.05394. +- Levine, Kumar, Tucker, Fu — Offline RL: Tutorial, Review, Perspectives, arXiv 2005.01643. +- Kumar, Zhou, Tucker, Levine — Conservative Q-Learning, NeurIPS 2020, arXiv 2006.04779. +- Zhang, Zohren, Roberts — DeepLOB, 2019, arXiv 1808.03668. +- Spooner, Fearnley, Savani, Koukorinis — Market Making via RL, AAMAS 2018, arXiv 1804.04216. +- Gao, Han, Li, Zhou — Market Intraday Momentum, JFE 2018. +- Bailey, Borwein, López de Prado, Zhu — Probability of Backtest Overfitting (SSRN 2326253); Deflated Sharpe Ratio (SSRN 2460551); Pseudo-mathematics and Financial Charlatanism (AMS Notices, 2014). +- Liu et al. — FinRL, arXiv 2011.09607. +- López de Prado — Advances in Financial Machine Learning, Wiley 2018 (meta-labeling, triple-barrier, purged CV). + +> 주: 위 문헌은 도메인 지식 + 본 세션 웹검색 교차확인으로 식별. 각 논문 본문을 이 세션에서 전문 정독한 것은 아니며, 핵심 주장 수준에서 인용. **§1~§5의 일부 인용(DeepLOB, Becker Deep Optimal Stopping, Spooner market making, Gao-Han intraday momentum 등)은 §6 워크플로우의 적대적 검증 corpus에는 포함되지 않은 "맥락 참고"다.** + +--- + +## 6. 딥리서치 워크플로우 검증 결과 (상위 결론, 적대적 검증) + +105 에이전트 / 23 소스 / 93 주장 추출 → 25 검증 → **21 confirmed, 4 killed**. 각 주장 3표 적대적 검증(2/3 refute 시 기각). + +### 6.1 한 줄 결론 +**이 corpus에는 "비용 차감 후 OOS에서 수익난 시초/장중 RL" 사례가 0건이다.** 모든 긍정 주장은 기각되고, 살아남은 주장은 전부 실패원인·검증 방법론이다. RL은 여기서 **알파원이 아니라 기껏해야 청산/리스크 정제 도구**이며, 그조차 비용차감 OOS 우위 증거가 없다. + +### 6.2 기각된 주장 (0-3 refute) — "RL이 이겼다"는 전부 무너짐 +| 기각 주장 | 출처 | +|---|---| +| TDQN(장중 RL)이 데이터편향 없이 작동 | arXiv 2004.06627 | +| RL 주문실행이 벤치마크 대비 우위(=edge 증거) | arXiv 2411.06389 | +| distributional RL(C51) 동적청산이 OOS 8% 초과 | arXiv 2105.08877 | +| distributional RL이 DDQN 능가(고노이즈 best-practice) | arXiv 2105.08877 | + +→ **특히 "청산/optimal-stopping RL이 룰 벤치마크를 이긴다"는 주장도 기각**됐다(1차 초안 §1.2의 낙관 톤을 하향). + +### 6.3 살아남은 주장 (3-0 confirmed) — 전부 경고·가드레일 +- **FinRL은 초급 교육용 라이브러리**(일봉 포트폴리오 데모뿐, 시초/장중·비용검증 데모 없음). 저자들이 직접 저SNR·생존편향·과적합을 배포 실패 구조원인으로 명시(arXiv 2011.09607, 2504.02281, 2209.05559). +- **주문 실행(execution)**은 깔끔히 정식화되는 별도 task이나 **진입 결정이 이미 내려진 뒤의 문제 → 알파원이 아님**(arXiv 2103.10860, 2411.06389). +- **naive RL은 정적/오프라인 데이터에서 value 과대추정(분포이탈)으로 실패** → 편향된 triggered subset에 그대로 쓰면 과적합(arXiv 2103.10860, 2006.04779). +- **Offline RL(CQL)이 과거데이터-only 제약에 맞는 패러다임**: 비관적 하한으로 가짜 알파 착취 저항. 단 "증명된 하한"은 tabular/선형 한정, 딥넷에선 설계원칙일 뿐. **패러다임 적합일 뿐, 우리 데이터에서 수익 증명 아님**(arXiv 2006.04779). +- **백테스트 과적합은 거의 보편적 실패모드**: 충분히 튜닝하면 무의미 신호도 멋진 백테스트 생성("종목코드 3번째 글자" 포트폴리오, Arnott-Harvey-Markowitz 2019; Bailey 외 AMS Notices 2014). → 우리 shuffle 검정의 진입알파 부재와 동일 교훈. +- **거래비용은 reward와 평가 양쪽에 내장 필수**(Palomar Seven Sins). 우리 23bp는 이미 반영됨. +- **평가는 단일 best-run 점추정 금지**: Deflated Sharpe(시도 횟수·샤프 분산 입력 필요), PBO via CSCV, multi-seed 구간추정(IQM/rliable, Agarwal 외 NeurIPS 2021)(SSRN 2460551, arXiv 2108.13264). + +### 6.4 핵심 caveat (정직성) +1. **증거 비대칭**: 부정 주장은 전부 3-0 생존, 긍정 주장은 전부 0-3 기각 → corpus 내 검증된 수익 RL 0건. +2. **한국시장·1초틱·호가·개장경매 특화 소스 0건** — Q5 시초 특화 결론은 직접 근거 없이 일봉/미국/크립토에서 유추한 것. +3. CQL/offline RL 결론은 사실상 단일 1차 소스 기반(패러다임 적합 ≠ 수익). +4. FinRL 생태계는 빠르게 변함(2020 정전 논문 기준; 최신 contest 트랙엔 실행 데모 존재). + +### 6.5 검증된 권고 (1차 초안 §4 보강) +- **하지 말 것**: 진입 선택 RL, 엔드투엔드 RL, 단일 best-run 샤프 자랑, L2 없는 실행 RL. +- **그나마 가능**: **offline RL(CQL)을 청산-타이밍 단일 문제에만**, reward에 23bp 내장, **R1 oracle 천장테스트로 먼저 게이트**, 평가는 Deflated Sharpe+PBO/CSCV+multi-seed. +- **사전확률은 낮게**: 문헌상 이 형식조차 OOS 우위 입증 0건이므로, R1에서 천장이 낮으면 즉시 중단. + +### 6.6 검증된 출처 (워크플로우 corpus) +- Théate & Ernst (2020) TDQN — arXiv 2004.06627 +- Hafsi & Vittori (2024) RL 최적실행 — arXiv 2411.06389 +- (distributional RL optimal stopping) — arXiv 2105.08877 +- Liu et al. (2020) FinRL — arXiv 2011.09607 / FinRL Contests (2025) 2504.02281 / FinRL_Crypto 과적합비판 2209.05559 +- Fang et al. (2021) Oracle Policy Distillation 실행 — arXiv 2103.10860 +- Kumar et al. (2020) Conservative Q-Learning — arXiv 2006.04779 +- Agarwal et al. (2021) rliable(통계적 정밀) — arXiv 2108.13264 +- Bailey & López de Prado, Deflated Sharpe — SSRN 2460551 +- Bailey/Borwein/López de Prado/Zhu (2014) Pseudo-mathematics — AMS Notices +- Palomar, Portfolio Optimization Book §8.2 "Seven Sins" +- Nevmyvaka, Feng, Kearns (2006) RL 최적실행 — UPenn rlexec +- Arnott, Harvey, Markowitz (2019) "종목코드 3번째 글자" 과적합 예시 diff --git a/docs/stom_rl_session_progress_2026-05-29.md b/docs/stom_rl_session_progress_2026-05-29.md new file mode 100644 index 000000000..25f16394a --- /dev/null +++ b/docs/stom_rl_session_progress_2026-05-29.md @@ -0,0 +1,88 @@ +# STOM RL 랩 — 2026-05-29 세션 진행/재개 핸드오프 (최종: A→D 완료) + +- 갱신일: **2026-05-29 KST** +- 브랜치: `feature/stom-rl-lab` / HEAD: `f159840` +- 상위 마스터 앵커: `docs/stom_rl_resume_commit_2026-05-29.md` +- 목적: 이 세션에서 완료한 Page A·RL트랙(R0/R1/R1b)·B·C·D를 다음 대화가 그대로 이어가게 한다. + +--- + +## 0. 한 줄 현황 + +**이 환경에서 가능한 전체 로드맵(A→D)이 완료됐다.** 시초 갭상승 `ts_imb` **룰 전략(RULE, NOT RL)**은 백테스트에서 견고하게 검증됐고(전 universe·전 연도·최악체결·유동성·운영정책 replay 통과), **RL은 근거를 갖고 폐기**됐다. 남은 단계는 **실시간 피드가 필요한 진짜 forward(환경 밖)**와 **E broker(승인 전 금지)**뿐이다. **실거래 수익은 아직 0 — 전부 검증/시뮬레이션.** + +--- + +## 1. 이 세션 커밋 (base `0402208` 위, 시간순) + +| 커밋 | 내용 | +|---|---| +| `4a0937a` | Page A 사이징/리스크 설계 + RL 타당성 딥리서치(R0) | +| `dd039c8` | R1 oracle-exit 천장 테스트 | +| `f082992` | R1b 인과적 청산 baseline — 청산 RL 게이트 종결 | +| `2b069fa` | Page B full universe 재검증 PASS + 세션 핸드오프 | +| `cfa5fd5` | Page C 유동성/슬리피지 + gap-through 꼬리 (둘 다 PASS) | +| `f159840` | Page D 동결정책 paper replay + deadlock 수정 | + +전체 게이트 테스트: **162 passed** (7개 파일). 각 페이지 code-reviewer APPROVE. (로컬 커밋만 — push 미실시.) + +--- + +## 2. 핵심 결론 (정직성 가드레일: RULE strategy, NOT reinforcement learning) + +1. **Page A** (`gap_up_risk_sizing.py`): `ts_imb`를 **f=10%/회·동시보유 3·일손실 -3%**로 사이징. 1R=계좌 0.123%, 최장연패 9에도 누적 -0.55~-1.1%, 계좌 MDD 순차 -1.6~-2.0%. Kelly 퇴화(full≈22배)라 낙폭 기준 사이징. +2. **R0 딥리서치** (105 에이전트 적대적 검증): 비용차감 OOS 수익 RL 사례 **0건**. RL은 알파원 아님. +3. **R1 천장 + R1b 인과 baseline** (`exit_oracle.py`/`exit_baselines.py`): 완전예지 청산 여지는 크나(capture 17.8%) 인과적으로 포착 불가 — 어떤 인과 변형도 고정 TP5/SL1 못 이김(IS최적 trail_1 OOS -0.71%, DSR 0.931<0.95). **→ 청산 RL(R3) 폐기.** +4. **Page B** (`gap_up_backtest.py --max-symbols 0`): full universe 2314종목·29139 instance. ts_imb 전 연도 양수, **2022 +0.742%로 해소**(소표본 노이즈 확정), breakeven OOS 98bp. +5. **Page C** (`liquidity_model.py`): 유동성 PASS — 1억 계좌 주문이 1초거래대금의 1.6%, 100% feasible, 슬리피지 <3bp. gap-through PASS — full-universe sl_gap_stress도 전 연도 양수(2022 +0.603), breakeven OOS 81bp. (단위 발견: `초당거래대금`은 **백만원** 단위 → ×1e6.) +6. **Page D** (`paper_sim.py`): 동결정책 시간순 replay. 정책 정상 작동·저낙폭(낙관/비관 체결 모두 양수, MDD -0.7~-3.1%). **deadlock 발견·수정**: 7연패 f=0 halt가 영구동결 → 서킷브레이커(쿨다운+streak 리셋). 헤드라인 수익(+396~612%)은 복리×replay 낙관 상한이지 미래 기대치 아님. + +--- + +## 3. 페이지 로드맵 (최종) + +| 페이지 | 상태 | +|---|---| +| A 사이징 / R0 딥리서치 / R1·R1b(RL 청산 폐기) / B full universe / C 유동성+꼬리 / D replay | ✅ **완료** | +| R2 메타라벨링(진입 필터) | ⬜ 선택·저우선(진입 알파 부재 이미 검증) | +| R3 offline RL 청산 | ❌ 폐기(R1b 게이트 실패) | +| **진짜 forward/paper (실시간 피드)** | ⬜ **다음 — 환경 밖**(실시간 데이터 연동 필요) | +| E broker/order 연동 | 🔒 명시 승인 전 금지 | + +--- + +## 4. 재개 검증 + 산출물 지도 + +### 4.1 핵심 테스트 (기대: 162 passed) +```powershell +py -3.11 -m pytest tests/test_stom_rl_gap_up_backtest.py tests/test_stom_rl_gap_up_dashboard_publish.py tests/test_stom_rl_gap_up_risk_sizing.py tests/test_stom_rl_exit_oracle.py tests/test_stom_rl_exit_baselines.py tests/test_stom_rl_liquidity_model.py tests/test_stom_rl_paper_sim.py -q +``` + +### 4.2 커밋된 핵심 모듈 (이 세션) +`gap_up_risk_sizing.py`(A) · `exit_oracle.py`(R1) · `exit_baselines.py`(R1b) · `liquidity_model.py`(C) · `paper_sim.py`(D). (기존: `gap_up_backtest.py`, `gap_up_dashboard_publish.py`.) + +### 4.3 커밋된 문서 (이 세션, `docs/`) +`stom_rl_gap_up_risk_sizing_2026-05-29.md`(A) · `stom_rl_rl_feasibility_research_2026-05-29.md`(R0) · `stom_rl_oracle_exit_ceiling_2026-05-29.md`(R1) · `stom_rl_exit_baseline_2026-05-29.md`(R1b) · `stom_rl_gap_up_full_universe_2026-05-29.md`(B) · `stom_rl_liquidity_slippage_2026-05-29.md`(C) · `stom_rl_paper_replay_2026-05-29.md`(D) · 이 문서. + +### 4.4 gitignored 산출물 (필요 시 재생성) +`.omx/artifacts/` 하위: `gap_up_full/`(B, idealized) · `gap_up_full_stress/`(C 꼬리, sl_gap_stress) · `oracle_exit/`(R1) · `exit_baselines/`(R1b) · `liquidity/`(C) · `paper_sim/`(D). 재생성 명령은 각 페이지 문서의 "재현 명령" 절 참조. (full-universe 백테스트는 ~수십분~장시간; UTF-8 강제 `py -3.11 -X utf8`.) + +--- + +## 5. 다음 대화 첫 행동 + +1. 이 문서 + `docs/stom_rl_resume_commit_2026-05-29.md` 읽기. +2. `git branch --show-current`(feature/stom-rl-lab), `git log --oneline -8`, §4.1 테스트(기대 `162 passed`) 확인. +3. 진행 방향 결정: + - **실전 지향이면**: 진짜 forward는 *실시간 시세 피드* 연동이 필요(과거 DB replay로는 근사까지만) → 환경/데이터소스 확인부터. 그 전엔 E(실주문) 금지. + - **추가 검증이면**: R2 메타라벨링(진입 필터, 저우선) 또는 paper_sim 서킷브레이커를 "probe 사이즈 복귀"로 보수화하는 변형. +4. 모든 표현은 **"RULE strategy, NOT reinforcement learning"** 유지. + +--- + +## 6. 정직성 가드레일 (불변) + +- 갭상승 곡선을 RL이라 부르지 않는다. 누적은 비복리 per-trade % / fixed-notional; **paper replay 헤드라인(+수백%)은 복리×시뮬레이션 낙관 상한이지 미래 수익 예측이 아니다.** +- "실거래 준비 완료" 미선언. 실주문 전 진짜 forward(실시간) 필요. E는 명시 승인 전 금지. +- 2022 약세는 소표본 노이즈(idealized·stress 양쪽 대표본에서 +로 해소 확인). 레짐 붕괴 단정 금지. +- DB는 triggered-subset → 시장 전체 일반화 미입증. L2 큐포지션 없음 → 진짜 체결 realism 한계. diff --git a/docs/stom_rl_signal_test_2026-05-27.md b/docs/stom_rl_signal_test_2026-05-27.md new file mode 100644 index 000000000..c4f6c2ff3 --- /dev/null +++ b/docs/stom_rl_signal_test_2026-05-27.md @@ -0,0 +1,252 @@ +# STOM Portfolio — Ranker-First NON-ADVISORY Signal Test (Stage D, n_folds≥5) + +Plan: `.omx/plans/ralplan-stom-rl-deep-rl-2026-05-27.md` (P0-1 alpha gate, §7 shuffle test, §7a supervised-ranker floor) +Repo: `Kronos` · Branch: `feature/stom-rl-lab` · Baseline: `dd5fd7a` · Python `py -3.11` (3.11.9) · Windows +Evidence artifacts (NOT committed, `.omx/` is gitignored): `.omx/artifacts/deep_rl/signal_test/` +Date: 2026-05-27 + +--- + +## 0. The decisive question (and the answer up front) + +> **Before committing expensive multi-hour deep-RL (MaskablePPO), answer cheaply and NON-ADVISORY: does ANY tradeable selection edge exist in this data?** + +**NON-ADVISORY VERDICT: NO. There is no tradeable stock-selection edge in this data/feature set.** + +The cheap `supervised_ranker` was given its *best fair shot* — the most permissive candidate +pool (`buy_demand_pressure`, 11,336 candidates), the largest co-dated universe we could find +(111 symbols on 2022-08-30), and a real `n_folds = 5` expanding-window holdout (which **lifts the +P0-1 advisory-only restriction** — alpha may now be claimed *or rejected* non-advisory). The result: + +- The ranker's **mean cost-adjusted return (−0.1472%) is WORSE than `equal_weight` (−0.1206%)**. +- **Neither beats `no_trade` (exactly 0.000%)** — every active policy *loses money* after 25 bps cost. +- The ranker's nominal "3/5 fold majority" over `equal_weight` is a **mirage**: it is driven by one + 0.0001%-margin tie (the ranker degenerating to equal-weight) plus one 0.0298% win, and it + **does NOT survive the mandatory shuffle test** (collapses to 1/5 and 2/5 under two shuffle seeds). + +So even the cheap supervised model — which has no MaskablePPO masking pathology — finds nothing. +**The binding constraint is SIGNAL / DATA, not the algorithm. Deep-RL (MaskablePPO) is NOT worth +the compute on this data/feature set.** This confirms, now at the non-advisory `n_folds ≥ 5` power +level, the advisory direction already recorded in `docs/stom_rl_deep_rl_verdict_2026-05-27.md`. + +A documented negative is the expected, valid outcome of this phase (plan §0, Principle 4). The +result is reported with real per-fold numbers and is not massaged. + +--- + +## 1. Universe / window chosen (and why) + +`n_folds` splits the **TIME axis** (expanding-window holdout), not symbols — so `n_folds ≥ 5` is +achieved with a single session by using a longer window and more co-dated symbols, **without a +full-universe run**. + +| Choice | Value | Rationale | +|---|---|---| +| Session | **2022-08-30** | Probed the DB (bounded per-table `DISTINCT substr(index,1,8)` column scans, never a full payload scan): this session has the **most co-dated symbols (112 tables → 111 real 6-digit stock tables** after dropping the `moneytop` metadata table). | +| Window | **09:00:00 – 09:30:00** | The *full* recorded window for these symbols (recording itself is bounded to the open + first 30 min; there is no later data to widen into). 1,800-second common grid. | +| Symbols | **111** | Bounded read (one session, 111 symbols, 30-min window). NOT the 2,427-table full universe. | +| Rule | **`buy_demand_pressure`** | The most permissive of the three rules — gives the ranker the largest, fairest candidate pool to find selection signal in (a more permissive pool is *fairer* for a "does selection signal exist?" test). | +| Cost | **`cost_bps = 25`** (explicit) | No zero-cost victory laps. | + +Candidate-pool comparison across the three rules on this universe (why `buy_demand_pressure` was chosen): + +| Rule | Candidates | Distinct timestamps | Timestamps with ≥2 candidates | Mean / max passing per ts | +|---|---|---|---|---| +| **`buy_demand_pressure`** (chosen) | **11,336** | **1,795** | **1,750** | **6.32 / 22** | +| `buy_widev1` | 1,574 | 1,025 | 392 | 1.54 / 6 | +| `buy_widev2` | 1,055 | 823 | 196 | 1.28 / 4 | + +`buy_demand_pressure` has ~10× the candidates and, critically, **1,750 timestamps with ≥2 competing +candidates** — i.e. 1,750 real selection decisions for the ranker to get right. This is the data +giving any selection model its best chance. + +### Per-fold candidate counts (`n_folds = 5`, expanding window) + +| Fold | TRAIN candidates | TEST candidates | TEST window | +|---|---|---|---| +| 0 | 3,366 | 2,169 | 09:05:01–09:09:59 | +| 1 | 5,535 | 1,806 | 09:10:00–09:14:58 | +| 2 | 7,341 | 1,428 | 09:14:59–09:19:59 | +| 3 | 8,769 | 1,235 | 09:20:00–09:25:00 | +| 4 | 10,004 | 1,332 | 09:25:01–09:30:00 | + +Every fold has >1,200 held-out candidates — far above the thin 3-symbol / 09:00–09:30 advisory +setup of the prior Stage-E verdict. The `n_folds ≥ 5` power floor (P0-1) is genuinely cleared. + +--- + +## 2. Pre-registration (P0-1 — recorded BEFORE reading the test-fold metrics) + +Per the plan's hard data-mining guard, the search space is pre-registered so the verdict cannot be +a post-hoc cherry-pick: + +- **Primary config (the ONE pre-named config):** `policy = supervised_ranker`, `rule = buy_demand_pressure`, + `n_folds = 5`, `top_k_candidates = 3`, `max_positions = 2`, `seed = 100`, `max_steps_per_fold = 24`, + `cost_bps = 25.0`. +- **Full candidate config set:** exactly one — the primary config above. +- **`M` = distinct configs evaluated on test folds = 1.** (`trained_ppo` was deferred — it needs + MaskablePPO/`sb3-contrib` for a fair attempt, and per the ranker-first plan it is only worth + installing *if the ranker shows an edge*. The ranker did not, so RL was correctly not run.) +- **Multiplicity note:** `M = 1` ⇒ **no Bonferroni/BH correction is owed** (there is nothing to + correct across — a single pre-registered config, no config search). The second seed (200) and the + two shuffle seeds (100, 7) are **robustness checks on the same primary config**, not new configs, + so they do not inflate `M`. +- **`n_folds = 5 ≥ 5`** ⇒ the P0-1 power floor is cleared ⇒ **this verdict is NON-ADVISORY.** + +--- + +## 3. The numbers (real, cost-adjusted `return_pct`, `cost_bps = 25`, `n_folds = 5`) + +### REAL data — per-fold cost-adjusted return % + +| Policy | f0 | f1 | f2 | f3 | f4 | **mean** | vs `no_trade` | +|---|---|---|---|---|---|---|---| +| `no_trade` | 0.0000 | 0.0000 | 0.0000 | 0.0000 | 0.0000 | **0.0000** | — (baseline) | +| `equal_weight_candidate` | −0.2148 | −0.0360 | −0.0724 | −0.1548 | −0.1250 | **−0.1206** | loses | +| `buy_and_hold` | −0.2148 | −0.0360 | −0.0724 | −0.1548 | −0.1250 | **−0.1206** | loses | +| `rule_baseline` | −1.4794 | −1.1614 | −1.3828 | −1.5225 | −1.5348 | **−1.4162** | loses badly | +| **`supervised_ranker`** | −0.1480 | −0.1250 | −0.2134 | −0.1250 | −0.1250 | **−0.1472** | **loses** | + +### MDD / turnover / trades (REAL, mean over folds) + +| Policy | worst-fold MDD % | mean turnover | mean trades | +|---|---|---|---| +| `no_trade` | 0.0000 | 0 | 0.0 | +| `equal_weight_candidate` | −0.2148 | 499,900 | 2.0 | +| `supervised_ranker` | **−0.3420** | 499,844 | 2.0 | + +The ranker's worst-fold MDD (−0.3420%) is **worse** than equal_weight's (−0.2148%), and its mean +return is worse — so even the components of the alpha gate beyond the fold-majority point the wrong way. + +### `supervised_ranker` vs `equal_weight_candidate` — per-fold win/loss (REAL, seed 100) + +| Fold | ranker % | equal_weight % | diff | result | +|---|---|---|---|---| +| 0 | −0.1480 | −0.2148 | +0.0668 | WIN | +| 1 | −0.1250 | −0.0360 | −0.0889 | lose | +| 2 | −0.2134 | −0.0724 | −0.1409 | lose | +| 3 | −0.1250 | −0.1548 | +0.0298 | WIN | +| 4 | −0.1250 | −0.1250 | **+0.0001** | WIN (a tie — ranker ≈ equal_weight) | + +Nominal score: **3/5 folds** (strict majority threshold = ⌈6/2⌉ = 3, so the fold-count criterion is +*nominally* met). **But this is a mirage:** fold 4 is a +0.0001% "win" (the ranker degenerated to the +same top-k as equal-weight — a tie, not selection skill), and the ranker's MEAN is still worse than +equal-weight and worse than `no_trade`. A fold majority that vanishes when you look at magnitude, +mean, drawdown, and the shuffle test (§4) is not an edge. + +--- + +## 4. Mandatory shuffle / permutation sanity check (plan §7, V8/V8b) + +**Mechanism (implemented this session, `stom_rl/portfolio_walk_forward.py:shuffle_candidate_signal`):** +within each timestamp, the values of `rank_score` AND every `feature_*` column are **OVERWRITTEN** +with an independent within-timestamp permutation. This is critical: `PortfolioEnv._current_candidates` +re-sorts by `rank_score` before `head(top_k)` (`portfolio_env.py:374`), so shuffling row *order* alone +is a no-op — only overwriting the score *values* changes which candidates the env selects. +`price` / `fill_price` / `symbol` / `timestamp` / `fillable` are left untouched, so fills and cost +accounting are byte-identical to the real run; ONLY the selection signal is destroyed. + +A unit test (V8b, `test_shuffle_changes_candidate_selection_order`) asserts the shuffle yields a +*different* top-k selection at ≥1 timestamp (it is not silently cancelled by the env re-rank), and a +companion test asserts `price`/`fill_price`/`symbol` are untouched. + +### Real vs shuffled — `supervised_ranker` win-rate over `equal_weight` + +| Run | ranker wins vs EW | ranker mean % | EW mean % | ranker beats `no_trade`? | +|---|---|---|---|---| +| REAL, seed 100 | **3/5** | −0.1472 | −0.1206 | NO | +| REAL, seed 200 | **3/5** | −0.1472 | −0.1206 | NO | +| SHUFFLE, seed 100 | **1/5** | −0.2469 | −0.2508 | NO | +| SHUFFLE, seed 7 | **2/5** | −0.1011 | −0.1190 | NO | + +**Reading the shuffle result:** the nominal 3/5 real majority collapses to 1/5 and 2/5 once the +selection signal is destroyed — so the 3/5 is *partly* a property of the real signal (good: it is not +purely noise-mined; if it were, the shuffle would also produce ~3/5). **But the real edge is itself so +thin that it loses to `equal_weight` on the mean and loses to `no_trade` in every case** — there is no +positive edge for the shuffle to "fail to reproduce." The shuffle confirms the marginal fold-majority +is not a robust, magnitude-bearing selection edge. + +**Seed agreement (V9):** seed 100 and seed 200 produce identical real results — the eval is +deterministic on this data; the negative direction is not a single-seed fluke. + +--- + +## 5. Anti-false-alpha guard status + +| Guard | Status | +|---|---| +| Column leakage canary (real signal-test data path: inject perfect-foresight future column → fold report unchanged) | **PASS** | +| Disjoint, strictly-later holdout (runtime asserts `portfolio_walk_forward.py` L460-465) | **PASS** (run exited 0; no AssertionError across all 5 folds) | +| Explicit non-zero `cost_bps` in eval | **PASS** — `cost_bps = 25` logged on every fold row | +| **Mandatory shuffle / permutation test (§7, V8/V8b)** | **RUN** — implemented + unit-tested; real 3/5 → shuffle 1/5 & 2/5 (marginal majority does not survive) | +| Supervised-ranker floor (P1-5 / §7a) | **REPORTED** — the ranker (the floor model) itself finds no edge over `no_trade`/`equal_weight`; RL was not run because the floor failed | +| ≥2-seed agreement (V9) | **PASS** — seed 100 ≡ seed 200 (identical), both shuffle seeds agree on direction | +| Multiplicity + power (P0-1 / V9c) | `M = 1` (no correction owed) AND `n_folds = 5 ≥ 5` → **NON-ADVISORY**; verdict is a real rejection, not advisory | + +--- + +## 6. NON-ADVISORY verdict + recommendation + +**Does `supervised_ranker` beat `equal_weight` after cost on a strict majority of the ≥5 disjoint +folds AND survive the shuffle test?** + +- Beat `equal_weight` on a strict majority of folds? **Nominally 3/5 — but a mirage** (one 0.0001% + tie + mean return *worse* than equal_weight + worse worst-fold MDD). +- Survive the shuffle test? **NO** — the 3/5 collapses to 1/5 / 2/5 under shuffle. +- Beat `no_trade`? **NO** — every active policy loses money after 25 bps cost. + +> ### VERDICT: There is NO tradeable stock-selection edge in this data/feature set. +> ### Deep-RL (MaskablePPO) is NOT worth the compute. STOP — do not invest more in RL machinery. + +**Why this is the honest reading:** the cheapest model that does NOT suffer PPO's masking pathology +(the supervised ranker, on the same causal features and the same disjoint holdout) cannot beat +doing nothing after cost, even given the most permissive candidate pool and the largest co-dated +universe available in a single session. Per the plan's P1-5 supervised-ranker floor and the +ranker-first decision rule, deep-RL is only worth the multi-hour MaskablePPO compute *if the ranker +shows an edge first*. It does not. The binding constraint is **signal / data**, not the algorithm. + +**Recommended next direction (invest in SIGNAL, not algorithm):** +1. The 18-feature set + `buy_demand_pressure` selection captures no post-cost selection alpha at the + 1-second open-auction horizon on this universe. Before any RL: test **richer / different features** + (e.g. longer trailing windows, cross-sectional ranks, order-book microstructure) and/or a + **longer holding horizon** where a 25 bps round-trip cost is less dominant — the open-auction + 1-second churn is heavily cost-bound (the active policies bleed ~0.12–0.15% to cost while the + underlying moves are tiny). +2. Only if a *supervised* ranker clears the alpha gate (beats `no_trade` AND `equal_weight` on a + strict majority of `n_folds ≥ 5` folds AND survives shuffle) should `sb3-contrib`/MaskablePPO be + installed and a fair RL attempt made. That precondition is **not met**. +3. A multi-hour full-universe run is **not** justified by this evidence — it would most likely + reproduce the same no-edge result at higher compute cost. + +--- + +## 7. Reproduction + +All commands `py -3.11`, from repo root. Artifacts land under `.omx/artifacts/deep_rl/signal_test/` +(gitignored). + +```bash +# Real run (n_folds=5, all baselines, explicit cost): +py -3.11 -m stom_rl.portfolio_walk_forward \ + --candidate-csv .omx/artifacts/deep_rl/signal_test/cand_buy_demand_pressure_20220830.csv \ + --output-dir .omx/artifacts/deep_rl/signal_test/real \ + --baselines no_trade,equal_weight_candidate,buy_and_hold,rule_baseline,supervised_ranker \ + --n-folds 5 --cost-bps 25 --top-k-candidates 3 --max-positions 2 --seed 100 + +# Mandatory shuffle test (same config + --shuffle-signal): +py -3.11 -m stom_rl.portfolio_walk_forward \ + --candidate-csv .omx/artifacts/deep_rl/signal_test/cand_buy_demand_pressure_20220830.csv \ + --output-dir .omx/artifacts/deep_rl/signal_test/shuffled \ + --baselines no_trade,equal_weight_candidate,buy_and_hold,rule_baseline,supervised_ranker \ + --n-folds 5 --cost-bps 25 --top-k-candidates 3 --max-positions 2 --seed 100 \ + --shuffle-signal --shuffle-seed 100 +``` + +Both exit 0 with real 5-fold output. The panel/candidate build (111 symbols, 2022-08-30, 09:00–09:30) +is a bounded per-session read — never a full-DB scan. + +--- + +*Stage-D non-advisory signal test. Doc-only deliverable (+ minimal shuffle-test plumbing in +`stom_rl/portfolio_walk_forward.py` and its tests); the supporting `.omx/` artifacts are +intentionally not committed.* diff --git a/docs/stom_rl_story_b1_session_proxy_verdict_2026-05-27.md b/docs/stom_rl_story_b1_session_proxy_verdict_2026-05-27.md new file mode 100644 index 000000000..2705d2cd2 --- /dev/null +++ b/docs/stom_rl_story_b1_session_proxy_verdict_2026-05-27.md @@ -0,0 +1,301 @@ +# STOM Portfolio — Story B1 세션바("일봉 proxy") 교차세션 선택신호 검정 (CAVEATED PROXY, NON-ADVISORY) + +Feasibility: `docs/stom_rl_story_b_daily_data_feasibility_2026-05-27.md` (B1 경로) +인트라데이 null 대조: `docs/stom_rl_1min_signal_verdict_2026-05-27.md`, 1초 null(동 문서 §7) +Repo: `Kronos` · Branch: `feature/stom-rl-lab` · Baseline: `9741465` · Python `py -3.11` (3.11.9) · Windows +증거 산출물(커밋 안 함 — `.omx/` gitignore): `.omx/artifacts/stom_rl_b1/` +Date: 2026-05-27 + +--- + +## 0. 정직성 계약 (먼저, 비협상) + +본 검정은 **CAVEATED PROXY**다. 깨끗한 일봉 알파 검정이 **아니다**. 현 DB는 이벤트 트리거형 + +세션당 ~30분(아침)만 기록하므로 두 confound에 묶인다: + +1. **선택편향**: (종목, 세션)이 패널에 존재하는 이유 자체가 "그날 기록 조건을 쳤기 때문". 이미 급등/이벤트 + 종목들 사이의 선택이라 모집단이 편향돼 일반 시장에 일반화 불가, 거짓양성 위험 큼. +2. **아침-only 수익**: "세션 수익"은 종가-종가 일봉이 아니라 아침 ~30분 구간 수익. 보유=1세션이지만 체결은 + 해당 종목의 **차기 가용 세션**(불규칙 gap, 정직히 명시)이다. + +따라서 **B1에서는 결과가 양수여도 알파를 주장할 수 없다.** 진짜 알파 판정은 정규 일봉 OHLCV +재현(B2)이 선행 조건이다. NO/null은 (인트라데이 null과 정합적인) 유력하고 타당한 결과다. 검증된 +falsifier 게이트(V-NONDEGEN → ranker-vs-baseline → mandatory shuffle, n_folds≥5)를 그대로 재사용해 +confound/noise 신호가 알파로 위장하지 못하게 막았고, 어떤 수치도 massage 하지 않았다. + +### NON-ADVISORY 판정: **NO.** 교차세션 선택신호에 shuffle-생존 엣지가 없다 (게다가 가장 damning한 방식으로). + +`supervised_ranker`는 비용(25bps) 차감 후 REAL에서 `equal_weight`를 **1/5 fold**에서만 이겨 사전등록 +strict-majority(3/5)에 **미달**하고, 평균(+2.66%)이 `equal_weight`(+5.23%)·`buy_and_hold`에 **진다**. +결정타: **SHUFFLE(신호 파괴)이 REAL을 압도** — 셔플된 noise가 `equal_weight`를 4/5로 이기고 평균 +**+32.60%** 로, REAL(1/5, +2.66%)보다 훨씬 낫다. random noise가 모델을 out-select하면 모델에는 실제 +선택정보가 없다. **알파 없음**. (이마저도 알파 주장이 아니라 proxy 신호 부재의 증거다.) + +--- + +## 1. 세션바 resample 접근 + 교차세션 패널 구성 + +### 1.1 세션바("일봉 proxy") resample — 유일한 net-new 코드 + +Stage-2 RL resampler(`finetune/qlib_stom_pipeline.py:resample_stom_rl_source_frame`)를 `freq="session"` +모드로 확장. 한 `(symbol, session)`의 **모든 행**을 **1 바**로 붕괴시키되, 1분 경로와 **동일한 LOCKED +per-column 집계**를 쓴다: + +| 컬럼군 | 집계 | 근거 (Stage-1 LOCKED) | +|---|---|---| +| OHLC | open=first / high=max / low=min / close=last | 1분 경로와 동일 | +| flow (초당매수/매도수량, volume, amount) | **SUM** | amount=초당거래대금 per-second → SUM | +| order-book / rate / snapshot (총잔량·호가1·등락율·회전율·시가총액·고저평균대비등락율·체결강도) | **LAST** | 세션 마지막 초 스냅샷 | + +- 버킷 = 세션 그 자체 → 정확히 **(symbol, session)당 1 바**. 바의 `timestamp` = **세션 날짜(자정)** 로 + 지정해, 서로 다른 종목의 세션바가 **공통 세션-날짜 축**에 정렬(교차세션 패널 그리드). +- 인과 추세 피처(이동평균/변동성/거래대금각도/등락율각도 + amount_delta + 체결강도 trailing 평균)는 + 세션바에서 per-session 그룹이 길이-1이 되어 붕괴하므로, `build_stom_rl_feature_frame(trend_group_keys=["symbol"])` + 로 **종목별 세션 시계열을 가로질러**(group by symbol) trailing 계산한다. 여전히 trailing-only(룩어헤드 + 없음) — 행 T는 세션 ≤T만 사용. 1s/1min 경로는 기본값(`[symbol, session]`) 유지로 byte-불변. + +### 1.2 교차세션 패널 구성 (bounded, NO full-DB scan) + +- **유니버스 선택**(bounded probe, `.omx/artifacts/stom_rl_b1/probe.py`): 2,427 테이블 중 6자리 종목 + 테이블을 800개까지 bounded 슬라이스, 각 테이블에 **단 1개의 cheap aggregate 쿼리** + (`DISTINCT substr(ts,1,8)` in 아침 윈도) 로 거래 세션을 집계. 전 행 스캔 없음. +- **선택된 유니버스**: 세션 수 기준 **상위 120 종목**(007660=510세션 … 011790=325세션 등), 이들의 + **≥5 종목 공동거래 세션**만 패널에 사용 → **945 distinct 세션-날짜**, 2022-03-23 ~ 2026-02-27. +- **읽기**(`.omx/artifacts/stom_rl_b1/run_b1.py`): 종목당 **1회 bounded indexed read**(세션-prefix `IN` + 945-세션 화이트리스트, 아침 윈도 09:00–09:30) → 세션 resample → 종목별 세션 시계열 피처 → + `merge_asof(backward)` 로 세션-날짜 축 정렬(`stom_rl/panel_join.py` 재사용). 전 DB 스캔 아님. +- **후보 생성**: `screen_frame`(rule = `buy_demand_pressure`). T+1 fill 계약이 그리드-불문이라 + per-symbol `shift(-1)` = **차기 세션 바** = 보유 1세션. 마지막 세션은 T+1 없음 → unfillable. + +### 1.3 유니버스 / 패널 / 후보 규모 + +| 항목 | 값 | +|---|---| +| 종목 수 | **120** (probe 상위 세션수) | +| 패널 distinct 세션-날짜 | **945** (≥5 종목 공동거래) | +| 패널 행 (long, 종목×세션바) | **113,400** | +| 후보 | **13,743** (fillable 13,722 · unfillable 21 = 종목별 마지막 세션) | +| distinct 후보 세션 | **945** | +| 윈도 | 09:00:00–09:30:00 (기록된 아침 ~30분) | +| Rule | `buy_demand_pressure` (가장 관대 — ranker에 최대·공정 풀) | +| Freq | **session** (net-new 세션바 resample) | +| Cost | **`cost_bps = 25`** (명시, 1s/1min과 동일) | + +### 1.4 per-fold 후보 카운트 (`n_folds=5`, 확장윈도 disjoint·strictly-later 세션 holdout) + +945 distinct 세션 → 6 disjoint 세션-세그먼트 → 5 평가 fold. 시간 축 = **세션**. + +| Fold | TRAIN 후보 | TEST 후보 | TEST 세션 구간 (disjoint, strictly later) | +|---|---|---|---| +| 0 | 1,691 | 2,233 | 2022-11-10 .. 2023-06-30 | +| 1 | 3,924 | 2,672 | 2023-07-03 .. 2024-02-26 | +| 2 | 6,596 | 2,218 | 2024-02-27 .. 2024-10-24 | +| 3 | 8,814 | 2,526 | 2024-10-25 .. 2025-06-25 | +| 4 | 11,340 | 2,403 | 2025-06-26 .. 2026-02-27 | + +--- + +## 2. 게이트 순서 — feature-integrity 게이트가 알파 판정보다 먼저 + +constant/zero 피처는 자기 자신으로 셔플돼 shuffle 테스트를 vacuously 통과하므로(거짓 null 제조), 비퇴화를 +하드 선행조건으로 둔다. + +### V-NONDEGEN (FIRST, 실제 세션 후보 집합) — **PASS** + +모든 게이트-네임드 세션-바 피처가 비-제로 분산 + ≥2 distinct. 핵심: **세션 축을 가로지른 4개 추세 피처가 +진짜 비퇴화** (종목별 세션 시계열 trailing 계산 정상 동작 증거). + +| 세션바 피처 | variance(ddof=0) | distinct | 비퇴화? | +|---|---|---|---| +| `rank_score` | 591.29 | 2,113 | yes | +| `trade_strength` | 1,253.36 | 1,771 | yes | +| `bid_ask_imbalance` | 0.01032 | 2,106 | yes | +| `change_rate` | 29.84 | 1,033 | yes | +| `moving_average_n` (이동평균) | 5.36e9 | 2,066 | yes | +| `volatility_n` (변동성) | 4.4267 | 2,104 | yes | +| `amount_slope_n` (거래대금각도) | 3.769e7 | 2,103 | yes | +| `change_rate_slope_n` (등락율각도) | 3.6937 | 2,079 | yes | + +(전체 24개 feature_*/rank_score 컬럼 모두 PASS — `.omx/artifacts/stom_rl_b1/v_nondegen_full.json`.) + +### V-COVERAGE — **PASS** (945 distinct 세션 ≥ 6; 120 종목 전원 후보 생성) + +V-NONDEGEN + V-COVERAGE PASS → 아래 shuffle/majority 게이트에 teeth가 있다. + +--- + +## 3. 사전등록 (M=1, post-hoc 튜닝 없음) + +| 항목 | LOCKED 값 | +|---|---| +| freq | **session** (1 바/세션) | +| Trailing window `N` | **10** 세션 바 (종목별 세션 시계열) | +| 변동성 ddof | **0** | +| `cost_bps` | **25.0** | +| `M` (config 수) | **1** | +| Primary policy | **`supervised_ranker`** (`trained_ppo` **DEFERRED**) | +| `top_k` / `max_positions` | **10 / 10** | +| `n_folds` / majority | **5** / **⌈6/2⌉ = 3 of 5** | +| 보유 / 체결 | **1 세션**; 체결 = **차기 가용 세션**(불규칙 gap) | +| 셔플 시드 | **0** (paired real-vs-shuffle) | +| `max_steps_per_fold` | **0** (truncation 없음 — 각 fold 전체 TEST 평가) | + +`M=1` ⇒ 다중성 보정 불요. `n_folds=5 ≥ 5` ⇒ **NON-ADVISORY**. + +--- + +## 4. 수치 (REAL, 비용차감 `return_pct`, `cost_bps=25`, `n_folds=5`, seed 100) + +### 4.1 per-fold 비용차감 return % + +| Policy | f0 | f1 | f2 | f3 | f4 | **mean** | vs `no_trade` | +|---|---|---|---|---|---|---|---| +| `no_trade` | 0.0000 | 0.0000 | 0.0000 | 0.0000 | 0.0000 | **0.0000** | — | +| `equal_weight_candidate` | −9.5912 | 8.6361 | 12.7852 | 17.0869 | −2.7624 | **5.2309** | wins | +| `buy_and_hold` | −9.5912 | 8.6361 | 12.7852 | 17.0869 | −2.7624 | **5.2309** | wins | +| `rule_baseline` | −9.9174 | −21.0360 | 0.1110 | −4.1263 | 11.0788 | **−4.7780** | loses | +| **`supervised_ranker`** | −0.0163 | 8.2243 | 3.7517 | 14.6804 | −13.3265 | **2.6627** | wins(약) | + +### 4.2 `supervised_ranker` vs `equal_weight` per-fold + +| Fold | ranker % | equal_weight % | diff | result | +|---|---|---|---|---| +| 0 | −0.0163 | −9.5912 | +9.5749 | WIN | +| 1 | 8.2243 | 8.6361 | −0.4118 | lose | +| 2 | 3.7517 | 12.7852 | −9.0335 | lose | +| 3 | 14.6804 | 17.0869 | −2.4065 | lose | +| 4 | −13.3265 | −2.7624 | −10.5640 | lose | + +**REAL majority over `equal_weight`: 1/5** → 3/5 **미달**. ranker 평균(+2.66%)은 `equal_weight`(+5.23%)에 +**진다**. (`no_trade`는 평균으로 이기지만 — 아래 §5에서 보듯 selection-bias pop의 confound 효과이고, +shuffle이 그것을 더 잘 먹는다.) + +### 4.3 worst-fold MDD / 평균 turnover / 평균 trades (REAL) + +| Policy | worst-fold MDD % | 평균 turnover | 평균 trades | +|---|---|---|---| +| `no_trade` | 0.000 | 0 | 0.0 | +| `equal_weight_candidate` | −18.525 | 997,506 | 4.2 | +| **`supervised_ranker`** | −16.225 | 997,506 | 4.4 | + +수익 크기가 ±10~17%로 비현실적으로 큰 것 자체가 **아침-only + selection-bias confound의 가시적 증거**다 +(종가-종가 일봉이라면 불가능한 규모). 이는 신호가 아니라 confound다. + +--- + +## 5. Mandatory shuffle / permutation 검정 (LOCKED shuffle-seed 0) — 결정타 + +메커니즘(`stom_rl/portfolio_walk_forward.py:shuffle_candidate_signal`): 각 timestamp(세션) 내에서 +`rank_score` + 모든 `feature_*` 값을 독립 순열로 **OVERWRITE**, `price`/`fill_price`/`symbol`/`fillable`은 +불변 → 체결·비용 회계는 byte-동일, 선택 신호만 파괴. + +### REAL vs SHUFFLE — `supervised_ranker` + +| Run | ranker wins vs EW | ranker mean % | EW mean % | ranker beats `no_trade`? | +|---|---|---|---|---| +| REAL, seed 100 | **1/5** | +2.6627 | +5.2309 | yes(약) | +| SHUFFLE, seed 0 | **4/5** | **+32.6012** | +11.7243 | yes | + +### SHUFFLE per-fold (ranker vs EW) + +| Fold | ranker % | EW % | diff | result | +|---|---|---|---|---| +| 0 | 47.2625 | 10.2404 | +37.02 | WIN | +| 1 | 25.9462 | 19.9396 | +6.01 | WIN | +| 2 | 20.7473 | −10.8118 | +31.56 | WIN | +| 3 | 59.2110 | 16.9607 | +42.25 | WIN | +| 4 | 9.8388 | 22.2927 | −12.45 | lose | + +**해석(결정적):** 파괴된-신호 SHUFFLE이 REAL을 **압도** — 4/5 majority(REAL 1/5), 평균 +32.60%(REAL ++2.66%). random noise가 모델을 큰 폭으로 out-select하면, 모델에 **실제 선택정보가 전혀 없다**. shuffle이 +"재현하지 못할" positive 엣지가 애초에 없다. 더구나 selection-biased 풀에서는 이미 트리거(상승)한 종목 +사이의 random 선택이 survivorship pop을 더 잘 타므로, "real" ranker가 그 풀 안에서 random보다 **더 나쁘게** +고른다는 것까지 드러난다 — confound가 신호로 위장하지 못하도록 게이트가 정확히 작동했다. + +--- + +## 6. NON-ADVISORY 판정 + 알파 게이트 (CAVEATED) + +| 알파-게이트 절 | 결과 | +|---|---| +| ranker가 `equal_weight`를 strict majority(≥3/5)로 이김 — REAL | **FAIL** (1/5) | +| ranker 평균이 `equal_weight`를 이김 — REAL | **FAIL** (+2.66% < +5.23%) | +| shuffle 생존 (real majority 유지 AND shuffle이 재현 못함) | **FAIL** (shuffle 4/5 ≫ real 1/5, +32.6% ≫ +2.66%) | +| 다중성 + power (M=1, n_folds=5≥5, 사전등록 준수) | clean | + +> ### 판정: 본 유니버스/피처/세션-proxy에서 교차세션 선택 엣지는 **없다**. +> ### 설령 양수였더라도 정직성 계약상 알파 주장 금지였다 — 그런데 결과는 깨끗한 NO다. +> ### `trained_ppo`(MaskablePPO)는 **DEFERRED 유지**. cheap falsifier가 아무것도 못 찾았으므로 RL 컴퓨트 낭비 금지. + +**CAVEATED 결론 한 줄:** 이 NO는 인트라데이 null과 정합적이며, 동시에 **proxy 자체의 한계**(선택편향 + +아침-only)에 의해 어차피 해석이 묶여 있었다. 진짜 일봉 알파 유무는 **B2(정규 일봉 OHLCV 재현)** 없이는 +판정 불가다. 본 B1의 가치는 "값싼 탐색에서도 신호가 없다 + 인프라(세션바 resample) 재사용 가능"을 확인한 +것이지, 알파의 존부를 결론낸 것이 아니다. + +--- + +## 7. 인트라데이 null과의 대조 — horizon을 1초→1분→세션으로 넓히면 바뀌었나? + +**아니다 — 오히려 더 강한 NO.** 동일 하니스·동일 25bps·동일 cheap ranker·동일 mandatory shuffle. + +| 차원 | 1초 (proven null) | 1분 (proven null) | **세션-proxy (B1, 본 문서)** | +|---|---|---|---| +| 시간 축 | 세션 내 1초 (~1,800바) | 세션 내 1분 (~31바) | **세션 (945 distinct 세션-날짜)** | +| 유니버스 | 111 공동거래(1세션) | 111 공동거래(1세션) | **120 종목 × 945 세션 (교차세션)** | +| 보유 | 1바(1초) | 1바(1분) | **1 세션 (차기 가용 세션 체결)** | +| ranker beats EW (REAL) | 3/5 (틀 +0.0001% mirage) | 2/5 | **1/5** | +| ranker 평균 vs EW | — | loses | **loses (+2.66% < +5.23%)** | +| shuffle 결과 | real 3/5 → shuffle 1–2/5 (얇은 엣지) | real 2/5 ≤ shuffle 3/5 | **real 1/5 ≪ shuffle 4/5 (+2.66% ≪ +32.6%)** | +| 비용 후 `no_trade` 상회? | NO | NO | NO (평균은 약간 위지만 confound·shuffle이 더 큼) | +| 판정 | NON-ADVISORY NO | NON-ADVISORY NO | **NON-ADVISORY NO (CAVEATED proxy)** | + +1초→1분에서 real이 shuffle을 겨우 이기다(3/5 vs 1–2/5) 1분에 역전(2/5 ≤ 3/5)됐고, **세션-proxy에서는 +real이 shuffle에 압도(1/5 ≪ 4/5)**된다. horizon을 세션까지 넓히고 비용 비중을 줄여도 selection-bias가 +만든 거대한 noise 수익을 random이 더 잘 먹을 뿐, 실제 선택정보는 나타나지 않았다. 다음 정보가 되는 +실험은 **B2(정규 일봉 OHLCV)** 또는 외부/단면 신호이지, 더 많은 인트라데이/세션-proxy RL이 아니다. + +--- + +## 8. anti-false-alpha 가드 상태 + +| 가드 | 상태 | +|---|---| +| V-NONDEGEN (세션바 피처 비-제로 분산 — 알파 게이트 BEFORE) | **PASS** (4개 추세 포함, 세션축 trailing) | +| V-COVERAGE (≥6 distinct 세션) | **PASS** (945) | +| disjoint·strictly-later 세션 holdout (런타임 assert, `portfolio_walk_forward.py` L691-696) | **PASS** (REAL/SHUFFLE 모두 exit 0, AssertionError 없음) | +| 명시 비-제로 `cost_bps` | **PASS** (`cost_bps=25` 모든 fold) | +| Mandatory shuffle (LOCKED seed 0) | **RUN** — real 1/5 ≪ shuffle 4/5 (엣지 미생존) | +| supervised-ranker floor | **REPORTED** — floor가 EW/`no_trade` 대비 엣지 없음; RL 정당화 안 됨 | +| 다중성 + power (M=1, n_folds=5≥5) | M=1 (보정 불요) + `n_folds=5≥5` → **NON-ADVISORY** | +| 사전등록 준수 (post-hoc 튜닝 없음) | **YES** — §3 상수 verbatim | +| **알파 주장 금지 (caveated proxy)** | **준수** — 선택편향 + 아침-only 명시, B2 없이는 알파 판정 불가로 봉인 | + +--- + +## 9. 재현 + +모든 명령 `py -3.11`, repo 루트, `PYTHONUTF8=1`. 산출물 `.omx/artifacts/stom_rl_b1/`(gitignore). +읽기는 bounded(probe = 테이블당 1 aggregate 쿼리; 본 실행 = 종목당 1 indexed read, 세션-prefix IN +화이트리스트) — 전 DB 스캔 아님. + +```bash +# (1) bounded probe — 거래 세션 집계로 유니버스 후보 산출 +py -3.11 .omx/artifacts/stom_rl_b1/probe.py 800 +py -3.11 .omx/artifacts/stom_rl_b1/select_universe.py # 상위 120 종목 × 945 세션 + +# (2) 세션-proxy 후보 + V-NONDEGEN + walk-forward(REAL+SHUFFLE), n_folds=5, cost_bps=25 +py -3.11 .omx/artifacts/stom_rl_b1/run_b1.py full + +# (3) 표 추출 +py -3.11 .omx/artifacts/stom_rl_b1/analyze.py full +``` + +핵심 코드(커밋): `finetune/qlib_stom_pipeline.py`(`freq="session"` resample + `trend_group_keys`), +`stom_rl/panel_join.py`(session freq → `trend_group_keys=["symbol"]`), `stom_rl/candidate_gen.py` +(`--freq session`), `tests/test_stom_rl_feature_expansion.py`(세션바 unit test 4종). + +--- + +*Story B1 NON-ADVISORY CAVEATED-PROXY 검정. Doc-only 산출물; `.omx/` 증거는 의도적으로 커밋 안 함. +M=1 사전등록 준수 — post-hoc 튜닝 없음. 알파 주장 금지(선택편향 + 아침-only) — 진짜 일봉 알파는 B2 +정규 데이터 재현이 선행조건.* diff --git a/docs/stom_rl_story_b_daily_data_feasibility_2026-05-27.md b/docs/stom_rl_story_b_daily_data_feasibility_2026-05-27.md new file mode 100644 index 000000000..8b09a4256 --- /dev/null +++ b/docs/stom_rl_story_b_daily_data_feasibility_2026-05-27.md @@ -0,0 +1,44 @@ +# Story B — 일봉/광역 horizon 데이터 타당성 판정 + +- 작성일: 2026-05-27 +- 맥락: 인트라데이(1초·1분) 선택 알파 부재가 비자문으로 증명됨(`docs/stom_rl_1min_signal_verdict_2026-05-27.md`). 다음 후보 = 일/멀티데이 horizon(비용 여유 ↑). 본 문서는 **현 DB로 일봉 horizon 알파 검정이 가능한가**를 판정한다. + +## 결론 (먼저) + +**현 DB(`_database/stock_tick_back.db`)는 깨끗한 일봉 cross-sectional 포트폴리오 패널을 만들 수 없다.** 이벤트 트리거형 + 세션당 ~30분(아침) 기록이라, "일봉" 선택 알파 검정은 (a) 선택편향과 (b) 아침-only 수익이라는 두 confound에 묶인다. 진짜 일봉 검정은 **별도 일봉 OHLCV 데이터(전 종목 × 정렬된 전 거래일)** 가 필요하며, 이는 사용자가 제공해야 한다. + +## 조사 근거 (bounded probe, 400종목 샘플) + +| 항목 | 값 | +|---|---| +| 전체 종목 테이블 | 2,427 | +| 세션/종목 (분포) | min 1 · **median 11** · mean 37 · max 511 | +| ≥10세션 종목 | 216/400 (54%) · ≥30세션 113/400 | +| 1세션만 있는 종목 | 37/400 | +| 날짜당 공동거래 폭 (400 중) | max **31** · median 15 · ≥20인 날 191 · **≥50인 날 0** | +| 세션당 기록 구간 | ~09:00–09:30 (아침 30분, 개장+초반) | +| 기록 트리거 | 이벤트형(그날 조건 충족 종목만 출현) | + +## 두 가지 confound (왜 "그냥 일봉으로 쓰면" 안 되는가) + +1. **선택편향(survivorship/trigger bias)**: (종목, 날짜)가 패널에 존재하는 이유 자체가 "그 종목이 그날 기록 조건(변동성/거래대금 급등 등)을 쳤기 때문". 이미 급등한 종목들 사이에서의 선택은 모집단이 편향돼 있어, 결과를 일반 시장에 일반화할 수 없고 거짓양성 위험이 크다. +2. **아침-only 수익**: 세션당 ~30분만 있으므로 "세션 수익"은 전일 종가→당일 아침 수익이 아니라 아침 구간 내 수익. 진짜 "일봉(종가-종가) horizon"이 아니다. + +## 두 갈래 경로 + +### B1 — 현 데이터로 "세션간 proxy" 검정 (가능하나 caveated) +- 각 세션을 1바로 집계(세션-resample) → 종목 × 세션 패널 → 보유=1세션(차기 세션 체결) → 검증된 falsifier 하니스(ranker-first·shuffle·holdout, n_folds≥5 over sessions)로 검정. +- **장점**: 별도 데이터 불요, 비용 분산(1-세션 보유) 가설 직접 검정, 인프라 재사용. 1분 resampler 패턴 확장. +- **단점/리스크**: 위 두 confound로 결과 해석이 어렵고, 선택편향이 거짓양성을 만들거나(또는 1분처럼 또 null) 모호한 결론 가능. "아침 30분 proxy"지 진짜 일봉 아님. + +### B2 — 진짜 일봉 OHLCV 데이터 확보 (사용자 제공 필요) +- 전 종목 × 정렬된 전 거래일의 일봉 OHLCV(편향 없는 정규 데이터셋). 들어오면 검증된 falsifier 하니스를 그대로 재사용해 싸게 신호 유무 판정. +- **현 DB로는 불가** — 이것이 본질적 blocker. + +## 권고 +- **알파를 정직하게 보려면 B2(정규 일봉 데이터)** 가 정답. 현 DB는 부적합. +- B1은 "비용 분산 가설"의 값싼 탐색으로는 의미가 있으나, **선택편향·아침-only를 명시한 proxy**로만 해석해야 하며, 결과가 양수여도 별도 정규 데이터 재현 전엔 알파 주장 금지. +- 결정은 사용자 몫: (1) caveated B1 proxy 실행, (2) 정규 일봉 데이터 제공 후 B2, (3) 인트라데이 무알파 결론 수용하고 마무리. + +## 한 줄 +**무에서 데이터를 만들 수 없다.** 진짜 일봉 알파 검정은 정규 일봉 OHLCV 데이터가 선행 조건이고, 현 DB는 이벤트 트리거형 아침-30분 스냅샷이라 그 역할을 못 한다. diff --git a/docs/stom_rl_study_guide.html b/docs/stom_rl_study_guide.html new file mode 100644 index 000000000..30d996063 --- /dev/null +++ b/docs/stom_rl_study_guide.html @@ -0,0 +1,242 @@ + + + + + +STOM 강화학습 공부용 가이드 + + + +
+ + 공부용 가이드STOM · Kronos 비의존 RL최종 갱신 2026-05 +

STOM 강화학습, 쉽게 이해하기

+

이 문서는 stom_rl/ 에 실제 구현된 강화학습(RL)을 비전공자도 이해할 수 있게 풀어 쓴 공부용 자료입니다. 모든 설명은 실제 코드 기준입니다.

+ + + +

1. 강화학습이란? — 한 줄과 비유

+

한 줄: 1초봉 차트 위에서 에이전트가 사라 팔라 기다려라 를 반복하며, 비용을 뺀 수익(=점수)을 최대화하는 매매 습관을 스스로 학습합니다.

+
+ 🎮 비유 — 게임 반복 플레이
+ 강화학습은 같은 게임을 수천 번 다시 하며 고득점 비법을 익히는 것과 같습니다. +
    +
  • 게임판 = 한 종목의 하루치 1초봉 차트
  • +
  • 플레이어 = 에이전트(신경망)
  • +
  • 조작 버튼 = 사라 / 팔라 / 기다려라 (3개)
  • +
  • 점수 = 거래비용(25bp)을 뺀 수익률
  • +
+ 처음엔 버튼을 거의 무작위로 누르지만, 점수가 높았던 행동 패턴을 점점 강화합니다. +
+ +

2. 핵심 4요소 — MDP(마르코프 결정 과정)

+

모든 강화학습은 상태 → 행동 → 보상 → 다음 상태의 반복입니다. 이 프로젝트의 실제 매핑(stom_rl/trading_env.py):

+
+
상태 State
최근 300초 ×
9개 변수
+
행동 Action
hold / buy / sell
(3택)
+
보상 Reward
비용 차감
수익률
+
전이 Transition
1초씩
다음 봉으로
+
+
+ + + + + +
요소실제 코드
상태최근 lookback_window=300초 × 9개 feature 행렬. 미래 정보는 절대 관측에 안 들어감(lookahead 차단).
행동Discrete(3): 0=hold, 1=buy, 2=sell. 이미 보유 중 매수·미보유 중 매도는 invalid_action → 패널티 -0.001.
보상보유 중이면 300초 뒤 수익률, 청산 시 실현 수익률, 거래마다 25bp 비용 차감 (아래 4장).
전이current_idx += 1 (1초 전진). 마지막 horizon 확보 지점에서 episode 종료.
+

에피소드(1판) = 한 종목의 하루치(세션) 1초봉 시퀀스. 데이터는 train 13,256 / val 2,764 / test 2,730판으로 분리되어 있습니다.

+ +

3. 어떤 변수를 쓰나? — 중요

+

자주 받는 질문: "DB의 모든 변수를 다 쓰나요?"아니요. 9개만 씁니다.

+
+
+

✅ 실제 쓰는 9개

+ 시장 6개 (원본 봉 데이터) +
    +
  • open, high, low, close
  • +
  • volume (거래량), amount (거래대금)
  • +
+ 파생 3개 (포지션 상태) +
    +
  • position — 보유 여부(0/1)
  • +
  • unrealized_return — 평가손익
  • +
  • time_in_position — 보유 경과
  • +
+
+
+

❌ 안 쓰는 것

+
    +
  • 원본 CSV에 있지만 버리는 컬럼: money, factor
  • +
  • STOM 틱DB의 호가/체결강도/주문북 — episode CSV에 애초에 없음
  • +
  • Kronos 모델의 예측값/피처 — 이 RL은 "Kronos 비의존"이라 전혀 안 씀
  • +
  • 기술적 지표(이동평균·RSI 등) — 미적용
  • +
+
+
+ + + + + + + + + + +
원본 qlib CSV 10개 컬럼 중 실제 사용 현황
컬럼사용?용도
open/high/low/close사용관측 feature (가격)
volume사용관측 feature (거래량)
amount사용관측 feature (거래대금)
date간접시간 인덱싱·기간 분할용 (feature 아님)
symbol간접종목 식별용 (feature 아님)
money미사용
factor미사용
+
+ 왜 이렇게 적게 쓰나? env 주석에 명시돼 있듯 "첫 RL 단계에서는 의도적으로 의존성을 가볍게" 설계했기 때문입니다. OHLCV만 쓰는 최소 에이전트입니다. 이는 뒤(7장)의 성능 한계와 직접 연결됩니다 — 정보가 적으면 학습할 수 있는 패턴도 제한됩니다. +
+ +

4. 보상은 어떻게 주나? — reward_mode="horizon"

+

핵심 아이디어: "지금 들고 있으면 5분(300초) 뒤에 이득일까?" 를 보상으로 줍니다.

+ + + + + + +
상황보상
보유 중 (계속 hold)300초 뒤 forward 수익률 (close[t+300]-close[t])/close[t]
매도(청산)실현 수익률 (산 가격 대비)
거래 발생 시비용 차감 -25bp (= 0.25%)
잘못된 행동패널티 -0.001
+
+ 📐 비유 — 우산 장수
+ "지금 우산을 들고 있으면 5분 뒤 비가 와서 이득일까?"를 매 순간 점수로 환산하는 셈입니다. 비용(우산 빌리는 값 25bp)은 살 때·팔 때마다 까입니다. 그래서 너무 자주 사고팔면 비용에 갉아먹힙니다. +
+

이 설계 때문에 정책은 사실상 단기(5분) 추세 추종을 학습하게 됩니다.

+ +

5. 두 알고리즘 — DQN vs PPO

+

둘 다 Stable-Baselines3의 표준 구현이며, 신경망은 작은 MLP [64, 32] 입니다.

+
+
+

DQN (가치 기반)

+

"각 상황에서 각 버튼의 미래 가치를 점수로 매기고, 가장 높은 버튼을 누른다."

+
    +
  • 경험 재생 버퍼(과거 경험 재학습)
  • +
  • 타깃 네트워크 (안정화)
  • +
  • ε-greedy 탐험: 처음 40% 무작위 → 5%로 감소
  • +
+
+
+

PPO (정책 기반)

+

"버튼을 누를 확률 자체를 직접 다듬되, 한 번에 너무 크게 안 바뀌게 안전하게."

+
    +
  • actor-critic 구조
  • +
  • clipped objective (안정적 업데이트)
  • +
  • n_steps 수집 → n_epochs 갱신 (on-policy)
  • +
+
+
+
+ 🍴 비유DQN은 식당 메뉴마다 "예상 만족도 점수"를 매겨 1등을 고르는 사람, PPO는 "이 메뉴를 시킬 확률"을 조금씩 조정하는 사람입니다. +
+ +

6. 전체 프로세스 한눈에

+
+ ① episode_manifest
1초봉→판 분할
+ ② trading_env
MDP 정의
+ ③ sb3_adapter
Gymnasium 래퍼
+ ④ sb3_smoke
학습·저장·평가
+ ⑤ leaderboard
기준선 비교
+ ⑥ eval-only / walk-forward
재검증
+
+
    +
  • 학습: model.learn(total_timesteps) 로 환경과 상호작용하며 신경망을 갱신 → {algo}_model.zip 저장.
  • +
  • 평가: 학습한 모델을 test 판들에서 탐험 없이(deterministic) 실행하며 거래·자산곡선을 기록.
  • +
  • 비교 기준선: 아무것도 안 함(no-trade), 사서 들고 있기(buy-and-hold)와 같은 비용 기준으로 비교.
  • +
  • 실시간 화면: 학습/평가 중 step·action·reward·equity를 rl_live_events.jsonl 로 남겨 대시보드에서 재생.
  • +
+ +

7. 솔직한 한계 (꼭 알아야 할 것)

+
+ ⚠️ 이 RL은 아직 "실전 모델"이 아니라 "실전 후보"입니다. 검증 과정에서 다음이 드러났습니다: +
    +
  • 학습량 확대가 무효 — 50k→100k step으로 늘려도 out-of-sample 성능이 오히려 약간 하락(공정 100판 비교: dqn 50k +1.34% vs 100k +1.18%).
  • +
  • 기간 민감(regime sensitive) — 6개 기간으로 나눠 보니 좋은 구간과 나쁜 구간이 섞였고, 가장 최근 구간(12/22~12/30)은 손실이었습니다. 종합 +1.34%는 좋은 초반이 약한 최근을 가린 결과.
  • +
  • 정보가 9개뿐 — 호가·체결강도·Kronos 예측 등 풍부한 변수를 안 써서 학습 가능한 패턴이 제한적.
  • +
  • 낙폭(MDD)이 큼 — 특정 기간 -38%까지. 실전 전 위험관리 gate가 필수.
  • +
+ 결론: 흥미로운 후보지만, 더 많은 변수·기간 검증·위험 차단 장치 없이 실거래에 쓰면 안 됩니다. +
+ +

8. 자가 점검 퀴즈

+
Q1. 에이전트가 누를 수 있는 버튼은 몇 개이고 무엇인가? +

3개 — hold buy sell. 보유 중 buy나 미보유 중 sell은 무효 행동으로 패널티.

+
Q2. 보상의 핵심 아이디어는? +

"지금 보유 중이면 300초 뒤 수익률"을 보상으로 주고, 거래마다 25bp 비용을 뺀다. → 단기 추세 추종을 학습.

+
Q3. DB의 모든 변수를 쓰는가? +

아니다. 9개(시장 6 + 파생 3)만 쓴다. money·factor는 버리고, 호가·Kronos 예측 등은 아예 입력에 없다.

+
Q4. DQN과 PPO의 차이를 한 문장으로? +

DQN은 행동의 "가치"를 점수로 매겨 최고를 고르고(가치 기반), PPO는 행동 "확률"을 직접 안전하게 다듬는다(정책 기반).

+
Q5. 왜 100k 학습이 50k를 못 이겼을까? +

정보(9 feature)·모델 크기·짧은 horizon에서 이미 평탄화됐고, 단일 split 반복으로 기간 의존/경미한 과적합이 생겼기 때문. 학습량만 늘리는 건 해법이 아니다.

+ +

+ 소스: stom_rl/trading_env.py, stom_rl/sb3_smoke.py, stom_rl/sb3_adapter.py, stom_rl/walk_forward.py · 대시보드: http://127.0.0.1:5070/rl +

+ + + + diff --git a/docs/stom_rl_timing_gate_2026-05-30.md b/docs/stom_rl_timing_gate_2026-05-30.md new file mode 100644 index 000000000..5a05cd50e --- /dev/null +++ b/docs/stom_rl_timing_gate_2026-05-30.md @@ -0,0 +1,85 @@ +# P1b — baseline-relative de-idealized 타이밍 게이트 결과 (딥RL 최종 go/no-go) + +- 작성일: **2026-05-30 KST** / 브랜치: `feature/stom-rl-lab` +- 상위: `docs/stom_rl_predictability_gate_2026-05-30.md`(P1), `docs/stom_rl_deeprl_opening20min_design_2026-05-29.md`(설계) +- 구현: `stom_rl/marketable_fill.py`(순수 마켓에이블 체결) + `stom_rl/timing_gate.py`(P1b 게이트) + 테스트 11개 +- 대상: 시초 갭상승 `ts_imb` — **RULE strategy, NOT reinforcement learning.** 수익 주장 아님. + +--- + +## 0. 한 줄 결론 (결정적 NO-GO) + +**모델의 마이크로구조 진입-타이밍 선택은, 마켓에이블 체결·paired 비교에서, 룰의 고정 09:00 진입보다 −0.38~−0.46%/trade *더 나쁘다*(full universe N=5,173, CI 음수쪽 0 제외, 전 5경계, DSR=0). P1의 "GO"는 드리프트+idealized 체결 artifact였음이 확정됐다. → 딥RL 진입-타이밍은 만들 가치가 없다.** + +P1의 ranking 신호(IC≈0.10)는 통계적으론 실재하나, 그것을 *타이밍*에 쓰면 가치를 *파괴*한다: 모멘텀 종목에서 "예측 net이 높은 초"는 *이미 오른 뒤*라, 늦게 추격 진입 → 남은 상승은 적고 스프레드만 더 문다. + +--- + +## 1. 결과 — full universe (결정론적·권위 수치) + +전 종목(`--max-symbols 0`) → 부분집합 의존 없음. **이것이 확정 수치다.** + +| 항목 | 값 | +|---|---| +| instances / samples | 5,173 / 532,245 | +| 룰 고정 09:00 de-idealized net | **+0.884%/trade** (마켓에이블 buy@ask/sell@bid에도 idealized +0.9%와 거의 동일 → **룰 엣지는 현실 체결서 유지**) | +| ridge 증분(모델−룰) | **−0.456%/trade**, CI [−0.575, −0.345] (전부 음수), Sharpe −0.172, DSR 0.000 | +| gbm 증분(모델−룰) | **−0.384%/trade**, CI [−0.495, −0.272] (전부 음수), Sharpe −0.148, DSR 0.000 | +| per-boundary 증분 | ridge −0.43~−0.47 / gbm −0.35~−0.41 (전 5경계 음수, 타이트) | +| DSR 설정 | external sharpe_variance=0.0064(SD 0.08), n_trials=40 (보수적) | +| **VERDICT** | **NO-GO** | + +> bounded(`--max-symbols 120`)는 `list_stock_tables`가 고르는 120종목 부분집합에 따라 비결정적이라(룰 net +0.12~+0.46%, 증분 −0.56~−1.18%로 흔들림) 권위 수치가 아니다. **결론(전 경계 음수·NO-GO)은 모든 run·full에서 불변.** full이 확정. + +--- + +## 2. 왜 모델이 룰보다 *나쁜가* (단순 무효가 아니라 해로움) + +모델은 "예측 진입-net이 가장 높은 초"에 진입한다. 그런데 이 종목들은 갭상승 모멘텀이라, 체결강도·OFI가 강해 보이는 초는 **대개 이미 급등한 직후**다. 거기서 진입하면: +- 남은 상승 여력↓ (TP까지 거리↓), 되돌림 위험↑, +- 마켓에이블 체결로 스프레드를 또 문다. +→ 룰의 **시초(09:00) 진입**(움직임 *전*)이 구조적으로 유리. 모델 타이밍은 full universe서 −0.38~−0.46%/trade 손해. + +이것이 P1 "GO"의 정체를 확정한다: P1은 (a) baseline 대비가 아니라 *not-trading* 대비였고(=룰이 이미 먹는 드리프트를 자기 공으로 셈) (b) idealized 체결이었다. 둘을 고치니 신호는 타이밍 우위가 0이 아니라 **음(−)**. + +--- + +## 3. 강건성과 정직성 캐비엇 + +- **paired 비교라 fill 모델의 가혹함에 sign이 강건**: 룰·모델 둘 다 동일 마켓에이블 체결 → 스프레드를 과대 가정해도 *증분 부호*(모델<룰)는 불변. 전 5경계 음수·CI 0 제외 → 견고. +- **(정정) 룰 de-idealized net은 full universe서 +0.884%/trade** — 마켓에이블 체결(스프레드 2회 교차)에도 idealized +0.9%와 거의 동일. 즉 **룰 엣지는 현실 체결서 유지**된다. 앞서 bounded에서 보였던 +0.12/+0.46%는 소표본 부분집합 노이즈였고, 스프레드가 룰을 잠식한다는 우려는 full에서 **해소**. (단 본 마켓에이블 모델은 항상 풀스프레드 교차 가정이라 보수적; 실제는 더 나을 수 있음.) +- RULE NOT RL. 수익 주장 아님. STOM-trigger 조건부. + +--- + +## 4. 딥RL 탐구 종합 (게이트 사슬 최종) + +| 게이트 | 결과 | +|---|---| +| R0 딥리서치(105 에이전트) | 비용차감 OOS 수익 RL 사례 0건; 사전확률 ~8–15% | +| P0 MinTRL | 작은 엣지는 표본서 검출 불가(소표본 게이트) | +| P1 (idealized, non-baseline-relative) | ranking 신호 IC≈0.10 실재 — 단 artifact성 "GO" | +| **P1b (de-idealized, baseline-relative)** | **모델 타이밍이 룰보다 −0.38~−0.46%/trade 열등(full N=5,173) → 결정적 NO-GO** | + +**최종: 이 데이터에서 딥RL(진입-타이밍)은 룰을 못 이긴다. 실재하는 ranking 신호조차 현실 체결·baseline-relative에선 타이밍 가치가 음(−).** A2/A3/A4(메타라벨링·표현학습·offline RL)는 **착수 안 함** — P1b가 그 전제(증분 신호 존재)를 기각. + +> 단, RL이 "무의미"한 게 아니라 **이 문제·이 데이터에 부적합**하다는 정밀 결론이다. 데이터(L2·대조군)나 문제(execution)가 바뀌면 재검토 대상. + +--- + +## 5. 재현 +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.timing_gate --max-symbols 120 # bounded +py -3.11 -X utf8 -m stom_rl.timing_gate --max-symbols 0 # full +py -3.11 -m pytest tests/test_stom_rl_marketable_fill.py tests/test_stom_rl_timing_gate.py -q # 11 passed +``` +산출: `.omx/artifacts/timing_gate/summary_*.json`. + +## 6. 페이지 트랙 +| | 상태 | +|---|---| +| R0/R1/R1b/B/C/D / P0+P1 | ✅ | +| **P1b baseline-relative de-idealized 게이트 (full N=5,173)** | ✅ **결정적 NO-GO — 딥RL 진입-타이밍 폐기** | +| A2/A3/A4 RL | ❌ 미착수(전제 기각) | +| 룰 마켓에이블 체결 생존성 | ✅ full서 +0.884%/trade 유지(스프레드 우려 해소) | diff --git a/docs/stom_skip_gate_prereg_2026-06-01.md b/docs/stom_skip_gate_prereg_2026-06-01.md new file mode 100644 index 000000000..c6f930c43 --- /dev/null +++ b/docs/stom_skip_gate_prereg_2026-06-01.md @@ -0,0 +1,110 @@ +# STOM ① Skip-Gate 사전등록 — 2026-06-01 + +## 정직성 선언 + +- 이 실험은 **갭상승 ts_imb RULE 전략 위의 진입/스킵 게이트**이다. +- **강화학습/RL 알파가 아니다.** 기존 RL 선택·타이밍·PPO 트랙은 prior 문서에서 닫혔다. +- 모든 결과는 `_database/stock_tick_back.db` 기반 백테스트/시뮬레이션이며, 라이브 포워드·L2 큐·실주문 검증이 아니다. + +## 질문 + +기존 `ts_imb` 갭상승 룰이 진입시키는 각 트레이드에 대해, 진입 시점 causal microstructure만 보고 일부를 **스킵**하면: + +> 23bp 비용 + marketable fill 반영 후 take-all baseline 대비 incremental net이 양수인가? + +## Primary Target + +- Label/target: `stom_rl.marketable_fill.simulate_rule_from_entry(..., entry_idx=0, cost_bps=23)`의 realized net `%` +- Baseline: 모든 `ts_imb` instance를 진입 +- Policy: + - take: realized net 반영 + - skip: 0% 반영 + - incremental = policy - baseline = skipped trade에서는 `-net`, taken trade에서는 `0` + +## 금지된 판정 + +- SL label AUC만으로 GO 금지 +- SL-heavy slice 식별만으로 수익성 주장 금지 +- 누적곡선/복리 paper replay를 기대수익처럼 해석 금지 + +## Drift Trap Guard + +GO가 되려면 primary test split에서 모델이 스킵한 slice의 realized marketable net 평균이 **0 미만**이어야 한다. + +즉, “SL이 많다”가 아니라 “실제로 비용차감 후 돈을 잃는 slice를 스킵했다”가 필요하다. + +## Model / Policy + +- Features: `stom_rl.microstructure_features.FEATURE_NAMES` +- Snapshot: entry window, 기본 `entry_window_sec=5` +- Models: + - `ridge` + - `HistGradientBoostingRegressor` +- Skip fraction grid: + - `[0.10, 0.20, 0.30, 0.40]` +- Selection: + - 각 train split에서 predicted net 하위 fraction별 train incremental mean을 계산 + - train incremental mean이 가장 큰 skip fraction 하나를 선택 + - test split에서는 해당 fraction만 적용 + +## Walk-Forward + +- Dates are purged: + - train: strictly earlier than boundary date + - test: strictly later than boundary date + - boundary date itself is embargoed +- Boundaries: `[0.5, 0.6, 0.7, 0.8, 0.9]` +- Primary boundary: `0.7` + +## GO / NO-GO Criteria + +Primary model GO requires all: + +1. primary boundary incremental bootstrap CI lower bound > 0 +2. Deflated Sharpe Ratio `>= 0.95` +3. skipped-slice realized marketable net mean < 0 +4. same model has positive incremental mean on at least 3 of 5 boundaries +5. negative feature-shuffle control is NO-GO + +If any condition fails, verdict is `NO-GO` or `INCONCLUSIVE`, not GO. + +## Controls + +### Positive control + +Synthetic money-losing low-score slice must produce GO. + +### Negative control + +Feature-shuffled/noise relation must produce NO-GO. + +### Drift-trap control + +Predictive score that selects “weaker” but still positive-net trades must remain NO-GO. + +## Implementation Files + +- `stom_rl/skip_gate.py` +- `tests/test_stom_rl_skip_gate.py` + +## Commands + +Targeted tests: + +```powershell +py -3.11 -m pytest tests/test_stom_rl_skip_gate.py tests/test_stom_rl_sl_predictor.py tests/test_stom_rl_marketable_fill.py tests/test_stom_rl_timing_gate.py -q +``` + +Smoke extraction: + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.skip_gate --db-path _database/stock_tick_back.db --max-symbols 5 --output-dir .omx/artifacts/skip_gate_smoke +``` + +Full run: + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.skip_gate --db-path _database/stock_tick_back.db --output-dir .omx/artifacts/skip_gate +``` diff --git a/docs/stom_skip_gate_result_2026-06-01.md b/docs/stom_skip_gate_result_2026-06-01.md new file mode 100644 index 000000000..798a67336 --- /dev/null +++ b/docs/stom_skip_gate_result_2026-06-01.md @@ -0,0 +1,177 @@ +# STOM ① Skip-Gate 구현 결과 — 2026-06-01 + +## 결론 + +`skip-gate` 구현과 synthetic control 검증, full-universe 실행을 완료했다. + +**Full-universe verdict: `NO-GO`.** + +primary split에서 skipped slice의 realized net은 음수였고 incremental mean도 소폭 양수였지만, 사전등록 기준을 모두 통과하지 못했다. 특히 DSR이 0에 가까워 다중시도/외부 Sharpe dispersion 보정 후 유의하지 않다. + +> 구현 완료 / 단위·타깃 테스트 통과 / DB smoke 통과 / full-data verdict NO-GO + +## 정직성 가드 + +- 이 모듈은 **RULE 전략 위의 entry skip-gate**이며 강화학습/RL 알파가 아니다. +- SL label을 거래 판정으로 쓰지 않는다. +- 판정 target은 `23bp + marketable fill` 비용차감 realized net이다. +- full-data 결과 없이는 실거래 준비 완료, 수익 보장, 알파 확정이라고 말하지 않는다. + +## 구현 파일 + +| 파일 | 내용 | +|---|---| +| `stom_rl/skip_gate.py` | skip-gate 순수 평가 함수, DB extractor, CLI | +| `tests/test_stom_rl_skip_gate.py` | positive/negative/drift-trap/accounting controls | +| `docs/stom_skip_gate_prereg_2026-06-01.md` | 사전등록 | + +## 핵심 설계 + +| 항목 | 내용 | +|---|---| +| baseline | 모든 `ts_imb` trade 진입 | +| policy | predicted net 하위 fraction skip | +| skip grid | `[0.10, 0.20, 0.30, 0.40]` | +| train/test | date-purged walk-forward | +| primary boundary | `0.7` | +| robustness | 5 boundaries `[0.5, 0.6, 0.7, 0.8, 0.9]` | +| GO 조건 | CI>0, DSR≥0.95, skipped net<0, 3/5 boundary positive, negative control NO-GO | + +추가 구현 가드: + +- `apply_negative_control_gate(...)`가 feature-shuffle negative control을 hard blocker로 적용한다. +- primary가 GO여도 negative control이 `NO-GO`가 아니면 최종 verdict는 `NO-GO`로 강등된다. + +## 검증 증거 + +### 1. 신규 단위 테스트 + +```powershell +py -3.11 -m pytest tests/test_stom_rl_skip_gate.py -q +``` + +결과: + +```text +9 passed +``` + +### 2. 타깃 회귀 테스트 + +```powershell +py -3.11 -m pytest tests/test_stom_rl_skip_gate.py tests/test_stom_rl_sl_predictor.py tests/test_stom_rl_marketable_fill.py tests/test_stom_rl_timing_gate.py -q +``` + +결과: + +```text +28 passed +``` + +### 3. DB smoke extraction + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.skip_gate --db-path _database/stock_tick_back.db --max-symbols 5 --output-dir .omx/artifacts/skip_gate_smoke --n-bootstrap 100 +``` + +결과: + +```text +instances=6 mean_net=0.8229% negative_rate=0.500 +too few samples for the pre-registered gate; wrote INCONCLUSIVE summary +wrote -> .omx\artifacts\skip_gate_smoke\summary.json +``` + +Smoke artifact: + +```text +.omx/artifacts/skip_gate_smoke/summary.json +``` + +### 4. max-symbols 100 smoke after extractor optimization + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.skip_gate --db-path _database/stock_tick_back.db --max-symbols 100 --output-dir .omx/artifacts/skip_gate_smoke100 --n-bootstrap 100 +``` + +결과: + +```text +instances=204 mean_net=0.5039% negative_rate=0.632 +ridge inc=-0.1166% CI95=[-0.5476,0.2048] DSR=0.022 skipped_net=0.3720% pos_bounds=0 -> no +gbm inc=-0.3756% CI95=[-0.8082,0.0467] DSR=0.002 skipped_net=0.9321% pos_bounds=0 -> no +negative control verdict: NO-GO +VERDICT: NO-GO +``` + +### 5. Full-universe execution + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.skip_gate --db-path _database/stock_tick_back.db --output-dir .omx/artifacts/skip_gate --n-bootstrap 1000 +``` + +결과: + +```text +instances=5173 mean_net=0.4197% negative_rate=0.645 +ridge inc=0.0534% CI95=[-0.0126,0.1203] DSR=0.000 skipped_net=-0.1780% pos_bounds=5 -> no +gbm inc=0.0727% CI95=[0.0015,0.1525] DSR=0.000 skipped_net=-0.1815% pos_bounds=5 -> no +negative control verdict: NO-GO +VERDICT: NO-GO +wrote -> .omx\artifacts\skip_gate\summary.json +``` + +Summary artifact: + +```text +.omx/artifacts/skip_gate/summary.json +``` + +해석: + +- `ridge`: skipped net은 음수지만 CI가 0을 넘지 못하고 DSR이 실패. +- `gbm`: CI는 간신히 0 초과지만 DSR이 실패. +- negative control은 `NO-GO`로 정상 통과. +- 결론은 사전등록 기준상 **NO-GO**. + +### 6. 전체 게이트 스위트 + +```powershell +py -3.11 -m pytest tests/test_stom_rl_gap_up_backtest.py tests/test_stom_rl_gap_up_dashboard_publish.py tests/test_stom_rl_gap_up_risk_sizing.py tests/test_stom_rl_exit_oracle.py tests/test_stom_rl_exit_baselines.py tests/test_stom_rl_liquidity_model.py tests/test_stom_rl_paper_sim.py tests/test_stom_rl_sl_predictor.py tests/test_stom_rl_liquidity_recon.py tests/test_stom_rl_condition_screener.py tests/test_stom_rl_marketable_fill.py tests/test_stom_rl_timing_gate.py tests/test_stom_rl_predictability_probe.py tests/test_stom_rl_full_universe.py tests/test_stom_rl_skip_gate.py -q +``` + +결과: + +```text +233 passed +``` + +### 7. Architect verification + +1차 검토는 negative control hard blocker 누락으로 `REJECT`. + +수정: + +- `apply_negative_control_gate(...)` 추가 +- CLI에서 shuffled negative control을 적용해 최종 verdict를 강등 가능하게 변경 +- negative control blocker/pass-through 테스트 2개 추가 + +재검토 결과: + +```text +APPROVE — concrete remaining issues: none found. +``` + +## 다음 결정 + +① skip-gate는 full-universe 기준 **NO-GO로 닫는다.** + +다음 후보는 문서 `docs/stom_rl_resume_handoff_2026-06-01.md`의 결정 트리에 따라: + +1. ④ 상태조건 청산 검정 +2. 또는 별도 주제로 “강한 거래대금/거래량 + 눌림 후 재상승” 룰/지도학습 게이트 사전등록 + +둘 중 하나를 새 계획으로 진행한다. diff --git a/docs/stom_sl_predictor_gate_2026-05-30.md b/docs/stom_sl_predictor_gate_2026-05-30.md new file mode 100644 index 000000000..b90a77bd5 --- /dev/null +++ b/docs/stom_sl_predictor_gate_2026-05-30.md @@ -0,0 +1,105 @@ +# 실험 ③ — SL예측 선행 분류기 (싼 디리스커 게이트) + +- 작성일: **2026-05-30 KST** (full-universe run 완료 2026-05-31) / 브랜치: `feature/stom-rl-lab` +- 상위: `docs/stom_data_layer_assessment_2026-05-30.md`(실험 목록 §4) +- 구현: `stom_rl/sl_predictor.py`(순수함수+DB추출기+CLI) + 테스트 `tests/test_stom_rl_sl_predictor.py`(8개) / 산출물 `.omx/artifacts/sl_predictor/summary.json` +- 대상: 시초 갭상승 `ts_imb` 룰(TP5/SL1/09:25) — **RULE strategy, NOT reinforcement learning.** 수익 검정 아님, "조건 걸 게 있나" 측정. + +> 수치는 모두 결정론적 산출물 `.omx/artifacts/sl_predictor/summary.json` 실측값. (초안에 추정치를 적었다가 full-run 실측으로 §3·§4 전면 정정함.) + +--- + +## 0. 한 줄 결론 (GO — 단, "리스크 예측"이지 "알파"가 아님) + +**진입·첫 30초 마이크로구조로 "이 트레이드가 결국 SL로 끝날지"는 *예측 가능*하다(full N=5,173, entry AUC 0.60·path30 AUC 0.66–0.68, symbol-disjoint 0.61–0.67, 4개 모델 전부 사전등록 바 통과). → 사전등록대로 GO: 실험 ①(skip-gate)·④(상태조건 청산)의 전제(조건 걸 신호 존재)가 *기각되지 않았다*.** + +**중요한 해석 한 줄: 이건 *방향성 알파*가 아니라 *리스크(변동성) 예측*이다.** "−1% 먼저 칠지(SL)"는 하방 변동성 문제이고, microstructure가 *방향*은 못 맞춰도(P1b NO-GO·shuffle 무알파) *변동성/리스크*는 맞춘다는 건 문헌·데이터레이어 평가(1초봉=리스크 레이어)와 정확히 정합. **돈이 되는지는 별개** — 그건 ①이 직접 검정한다. + +> 이번 게이트는 이 전체 RL/ML 조사에서 **처음 나온 비-음성 결과**다. 과대해석 금물: entry AUC 0.60은 "동전보다 조금 나음"이고, path30 0.68도 modest다. SL 예측이 곧 수익은 아니다. + +--- + +## 1. 무엇을 검정했나 + +ts_imb 룰 트레이드를 결과로 라벨링: **결국 SL 청산 = 1, 아니면(TP/시간) = 0** (`rule_exit_reason`, TP5/SL1/09:25, 동점 시 SL 우선·보수적). base rate **SL 59.4%** (N=5,173 중 3,074). SL이 −1%·TP가 +5%라 SL 배리어가 훨씬 가까워 SL률이 높음 → 즉 "SL 예측 ≈ *상방 5% 전에 하방 1%를 칠* 단기 하방변동성 예측". + +두 사전등록 스냅샷: + +| 스냅샷 | 피처 시점 | 관련 실험 | survival 조건 | +|---|---|---|---| +| **entry** | 진입봉~첫 5초 | ① skip-gate("진입 시 스킵?") | 없음(전 5,173, SL률 59.4%) | +| **path30** | 첫 30초 | ④ 상태청산("30초 보유 중, SL로 끝날까?") | 30초 시점 *미청산*만(4,218, SL률 57.4%) | + +피처 = 기존 29개 인과 microstructure 벡터(`causal_feature_vector`: 추세/실현변동성/signed-flow/체결강도 slope/호가 imbalance/microprice/근사 OFI). 누수 없음 — 피처는 t시점(≤5s 또는 ≤30s)까지만, 라벨은 그 이후 결과. 검증 = purged walk-forward(이전 세션 train→이후 세션 test, logit+GBM) + 세션 부트스트랩 AUC CI + symbol-disjoint AUC(미관측 종목). + +--- + +## 2. 사전등록 판정 기준 (결과 *전* 고정) + +스냅샷이 **PREDICTABLE**이려면 어떤 모델이든 셋 다 충족: +1. walk-forward test AUC의 세션-부트스트랩 95% CI **하한 > 0.5** (통계적으로 chance 초과) +2. point AUC **≥ 0.55** (실용적 의미 최소선) +3. symbol-disjoint AUC **≥ 0.53** (per-ticker 암기에 강건) + +하나라도 PREDICTABLE이면 ①④ GO, 아니면 STOP. **positive control**(심은 신호 AUC 0.92→PREDICTABLE)·**negative control**(노이즈→AT-CHANCE) 단위테스트로 게이트 검출력 검증 완료(8 passed). + +--- + +## 3. 결과 (full universe, N=5,173 instances, 결정론적) + +| 스냅샷 | 모델 | AUC | CI95 | symbol-disjoint AUC | 판정 | +|---|---|---:|---|---:|---| +| entry (n=5,173) | logit | **0.603** | [0.577, 0.629] | 0.607 | PREDICTABLE | +| entry | gbm | 0.600 | [0.574, 0.627] | 0.614 | PREDICTABLE | +| path30 (n=4,218) | logit | 0.661 | [0.634, 0.687] | 0.656 | PREDICTABLE | +| path30 | gbm | **0.677** | [0.650, 0.703] | 0.669 | **PREDICTABLE** | + +(walk-forward 분할: entry train 3,230 / test 1,938; path30 train 2,633 / test 1,582. boundary 0.7.) + +- **4개 모델 전부** point AUC≥0.55·CI 하한>0.5·symbol-disjoint≥0.53 통과. symbol-disjoint(미관측 종목)서도 0.61–0.67 유지 → per-ticker 암기 아닌 **일반화되는 리스크 신호**. +- **path30(0.66–0.68) > entry(0.60).** 첫 30초 경로가 진입 시점보다 SL을 *유의하게 더* 잘 예측한다(0.60 → 0.68). 즉 "보유 중 추가정보"가 실재 → ④(상태청산)의 조건변수가 비어있지 않음. (entry는 logit≈gbm, path30은 gbm이 약간 우수 — 경로에 약한 비선형 존재.) +- **FINAL: GO.** + +--- + +## 4. 함의 (정직하게) + +1. **① skip-gate: 살아남음 → 빌드 정당화.** 진입 microstructure가 SL(하방변동성)을 AUC 0.60·강건하게 가른다면, "예측-최악 슬라이스를 스킵해 트레이드당 net을 올린다"는 ①의 전제가 *기각되지 않았다*. 데이터레이어 평가가 ①에 매긴 ~20–30% 사전확률이 **상향**된다. +2. **④ 상태청산: 살아남음(생각보다 강).** 30초 경로가 SL을 0.66–0.68로 예측(진입 0.60보다 +0.06–0.08) → "보유 중 들어오는 경로 정보"가 진짜 있다. 데이터레이어 §4가 ④에 매긴 ~20%는 이 path30 lift 때문에 소폭 **상향**될 여지. 단 갭상승 평균회귀라는 구조적 반대(SL 종목을 더 들면 −1%가 −3% 될 위험)는 그대로 → ① 다음 후순위 유지. +3. **그러나 "예측 가능 ≠ 수익".** 핵심 함정(데이터레이어 평가가 P1을 GO→NO-GO로 태운 **드리프트 트랩**): forward 드리프트가 대체로 양수라, SL로 끝나는 트레이드조차 *비용차감 net이 음수가 아닐* 수 있다. AUC 0.60–0.68 SL예측은 "SL 많은 슬라이스를 식별"할 뿐, 그 슬라이스를 스킵해 **돈이 남는지**는 ①이 직접 검정해야 함. SL 트레이드도 일부는 −1% 손절 전 +α 먹고 시간청산됐을 수 있음(라벨은 *최종* 이유라 net 부호와 1:1 아님). +4. **방향성 알파 아님(재확인).** 선택(shuffle 무알파)·타이밍(P1b NO-GO)은 여전히 음성. SL=리스크 예측만 양성. 즉 **1초봉은 방향이 아니라 리스크를 안다** — 데이터레이어 평가 결론(1초봉=비용/리스크 진실레이어)의 *직접 증거*. + +--- + +## 5. 정직성 캐비엇 + +1. entry AUC 0.60은 **modest**(완벽 1.0·동전 0.5 사이 하단), path30 0.68도 강하지 않다. 통계적으론 견고(CI·symbol-disjoint·4모델 일치)하나 실거래 분리력은 중간 — "강한 예측"이 아니라 "0이 아닌 예측". +2. SL 라벨은 *최종 청산 이유*. net 손익과 1:1 대응 아님(시간청산 SL-미달도 음수 가능, SL도 도중 +α 후 반전 가능). 따라서 ③의 GO는 "리스크 식별 가능"이지 "수익 식별 가능"이 아님 — ①에서 *비용차감 net 부호*로 재검정 필수. +3. path30은 30초 *미청산* 트레이드만(survivorship 조건). ④에 정확한 모집단이나, entry와 직접 비교 시 모집단이 다름(4,218 vs 5,173)을 유의. +4. ts_imb 트리거 모집단 한정·L2 없음. positive/negative control이 게이트 검출력 보증. +5. 사전등록 0.55 바는 임의성 최소화 선택. 결과는 그 바를 명확히 통과(0.600–0.677)하므로 바 민감도 낮음. +6. RULE not RL. 본 실험은 수익 산출이 아니라 "조건 걸 신호 존재" 측정. 모든 후속 양수치는 in-sample/triggered-subset/라이브 없음. + +--- + +## 6. 재현 + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.sl_predictor # full universe entry+path30 게이트(추출 ~88분) +py -3.11 -m pytest tests/test_stom_rl_sl_predictor.py -q # 8 passed (planted->PREDICTABLE, noise->AT-CHANCE) +``` +산출: `.omx/artifacts/sl_predictor/summary.json`. 핵심(2026-05-31): N=5,173, SL률 59.4%, open_at_30s=4,218, entry AUC 0.600–0.603(sd 0.607–0.614)·path30 AUC 0.661–0.677(sd 0.656–0.669), 4모델 PREDICTABLE, FINAL GO. + +--- + +## 7. 실험 트랙 갱신 (데이터 레이어 평가 §4) + +| # | 실험 | 상태 | +|---|---|---| +| ② 초당흐름 재구성 (용량 정직화) | ✅ 완료 — 용량 −98% 정정, 조건부 PASS | +| **③ SL예측 선행 분류기** | ✅ **완료 — PREDICTABLE(entry 0.60·path30 0.66–0.68, 강건), FINAL GO** | +| **① skip-gate** | ⬜ **다음 — ③가 전제 통과, 빌드 정당화(드리프트 트랩 가드 사전등록 필수)** | +| ④ 상태조건 청산 | ⬜ ① 이후(path30 lift로 사전확률 소폭↑, 단 평균회귀 반대 그대로) | + +→ **③의 GO로 ①(skip-gate)이 처음으로 "지을 가치"를 얻었다.** 단 이는 *리스크 예측*의 통과이지 *수익*의 통과가 아니며, ①은 비용차감 baseline-relative net으로 드리프트 트랩을 정면 검정해야 한다. diff --git a/docs/stom_state_exit_prereg_2026-06-01.md b/docs/stom_state_exit_prereg_2026-06-01.md new file mode 100644 index 000000000..0d77354a0 --- /dev/null +++ b/docs/stom_state_exit_prereg_2026-06-01.md @@ -0,0 +1,63 @@ +# STOM state-conditioned early-exit gate preregistration — 2026-06-01 + +## Hypothesis + +This is **not reinforcement learning**. It is a supervised RULE/risk-control gate for trades that have already entered through the `ts_imb` opening-gap rule. + +At the 30-second checkpoint, evaluate whether `exit_now` by marketable sell is better than `continue_baseline` through the original TP5/SL1/09:25 baseline exit. + +## Experiment contract + +| Item | Preregistered value | +|---|---| +| Universe | STOM full-universe `ts_imb` triggered subset | +| Baseline | Original TP5% / SL1% / 09:25 exit rule | +| Fill/cost | Entry buy@ask, exit sell@bid, 23bp round-trip cost | +| Checkpoint | 30 seconds after open for trades still open at the checkpoint | +| Denominator | Primary metric per original trade; trades already closed before 30 seconds get incremental delta `0` | +| Action | `exit_now` vs `continue_baseline` | +| Target | `early_exit_now_net_pct - baseline_continue_net_pct` | +| Primary model | `gbm` only for final GO | +| Diagnostic model | `ridge` diagnostic-only | +| Walk-forward | Date-purged split, primary boundary `0.7` | +| Robustness boundaries | `[0.5, 0.6, 0.7, 0.8, 0.9]` | +| Exit fraction grid | `[0.10, 0.20, 0.30, 0.40]`, selected on train only | +| Controls | Planted positive control, 5 deterministic shuffled-feature negatives, SL-proxy trap, leakage-invariance audit | + +## GO criteria + +A `GO` requires all of the following: + +1. Primary incremental mean CI lower bound is greater than `0` per original trade. +2. DSR is at least `0.95`. +3. Exited-slice paired delta mean is greater than `0`. +4. Policy mean net is greater than baseline mean net. +5. At least 3 of 5 robustness boundaries have positive incremental mean for the primary model family. +6. All 5 shuffled-feature negative controls remain `NO-GO`. +7. `n_checkpoint_eligible_test_trades >= 500` and `n_policy_exits >= 50`. +8. Post-checkpoint leakage-invariance test passes. +9. The report states the boundary plainly: local backtest only, triggered subset only, no live forward result, no RL alpha claim. + +Any failed criterion is `NO-GO` or `INCONCLUSIVE`. + +## Implementation targets + +- `stom_rl/state_exit_gate.py` +- `tests/test_stom_rl_state_exit_gate.py` +- `docs/stom_state_exit_result_2026-06-02.md` + +## Planning references + +- PRD: `.omx/plans/prd-stom-state-exit-2026-06-01.md` +- Test spec: `.omx/plans/test-spec-stom-state-exit-2026-06-01.md` + +## Review notes + +- `gbm` is the only primary model family that can produce a final GO; `ridge` remains diagnostic-only. +- Negative controls must use a deterministic 5-shuffle protocol. +- Outputs must report `n_original_test_trades`, `n_checkpoint_eligible_test_trades`, and `n_policy_exits` separately. +- Leakage checks must prove that post-checkpoint rows cannot alter checkpoint features. + +## Later result link + +The full-universe follow-up result is recorded in `docs/stom_state_exit_result_2026-06-02.md` and closed this gate as `NO-GO`. diff --git a/docs/stom_state_exit_result_2026-06-02.md b/docs/stom_state_exit_result_2026-06-02.md new file mode 100644 index 000000000..03cc8b01e --- /dev/null +++ b/docs/stom_state_exit_result_2026-06-02.md @@ -0,0 +1,180 @@ +# STOM ④ State-Conditioned Early-Exit Gate 결과 — 2026-06-02 + +## 결론 + +`ts_imb` 시초 갭상승 규칙 진입 후 **30초 시점에 아직 열려 있는 거래**에 대해 +“지금 시장가 청산”이 “기존 TP5/SL1/09:25까지 계속 보유”보다 나은지를 검증했다. + +**Full-universe verdict: `NO-GO`.** + +핵심 이유는 primary 모델(`gbm`)에서 선택된 조기청산 정책이 baseline보다 오히려 낮은 평균 수익을 냈고, +CI/DSR/positive-boundary 조건을 모두 통과하지 못했기 때문이다. 5개 deterministic shuffled-feature +negative control은 모두 `NO-GO`로 정상 통과했지만, primary 자체가 GO 조건을 만족하지 못했다. + +> 구현 완료 / synthetic 회계·누수 테스트 통과 / DB smoke 통과 / full-data verdict NO-GO + +## 중요한 해석 경계 + +- 이 실험은 **강화학습(RL)이 아니라 RULE/supervised risk-control gate**이다. +- 목표는 방향 예측이 아니라, 이미 진입한 `ts_imb` 거래 중 30초 이후 계속 보유할지 조기청산할지를 평가하는 것이다. +- target은 `early_exit_now_net_pct - baseline_continue_net_pct`이며, 23bp 비용과 marketable fill을 반영했다. +- 30초 전에 이미 TP/SL/시간 조건으로 닫힌 거래는 원거래 분모에 포함하되 incremental delta는 0으로 처리했다. +- 최종 GO는 primary `gbm`만 가능하며, `ridge`는 diagnostic-only로 유지했다. + +## 구현 파일 + +| 파일 | 내용 | +|---|---| +| `stom_rl/state_exit_gate.py` | 30초 state-conditioned early-exit gate, DB extractor, CLI, negative controls | +| `tests/test_stom_rl_state_exit_gate.py` | 합성 데이터 회계/누수/게이트/negative-control 회귀 테스트 | +| `docs/stom_state_exit_result_2026-06-02.md` | 본 결과 문서 | + +## 사전등록 기준 요약 + +| 항목 | 값 | +|---|---| +| checkpoint | 30초 | +| baseline | 기존 TP5/SL1/09:25 계속 보유 | +| candidate action | 30초 시장가 조기청산 | +| primary model | `gbm` | +| diagnostic model | `ridge` | +| primary boundary | `0.7` | +| robustness boundaries | `[0.5, 0.6, 0.7, 0.8, 0.9]` | +| exit fraction grid | `[0.10, 0.20, 0.30, 0.40]` | +| negative controls | deterministic shuffled-feature 5개, 모두 NO-GO 필요 | + +## Full-universe 실행 + +```powershell +$env:PYTHONIOENCODING='utf-8' +py -3.11 -X utf8 -m stom_rl.state_exit_gate --db-path _database/stock_tick_back.db --output-dir .omx/artifacts/state_exit --n-bootstrap 1000 --n-negative-shuffles 5 +``` + +결과: + +```text +instances=5173 eligible=4123 eligible_rate=0.797 baseline_mean=0.4197% eligible_delta_mean=-0.5105% +-- primary boundary 0.7 -- + ridge inc=-0.0031% CI95=[-0.0488,0.0439] DSR=0.000 delta=-0.0194% exits=310 pos_bounds=1 -> no diagnostic + gbm inc=-0.0175% CI95=[-0.0842,0.0492] DSR=0.000 delta=-0.0546% exits=619 pos_bounds=0 -> no +negative controls: ['NO-GO', 'NO-GO', 'NO-GO', 'NO-GO', 'NO-GO'] +VERDICT: NO-GO +wrote -> .omx\artifacts\state_exit\summary.json +``` + +핵심 수치: + +| 항목 | ridge diagnostic | gbm primary | +|---|---:|---:| +| selected exit fraction | 0.20 | 0.40 | +| policy exits | 310 | 619 | +| incremental mean / original trade | -0.0031% | -0.0175% | +| CI95 | [-0.0488, 0.0439] | [-0.0842, 0.0492] | +| DSR | ~0.000 | ~0.000 | +| exited delta mean | -0.0194% | -0.0546% | +| positive boundary count | 1 | 0 | +| model GO | False | False | + +## Smoke 실행 + +### 20종목 smoke + +```powershell +py -3.11 -X utf8 -m stom_rl.state_exit_gate --db-path _database/stock_tick_back.db --max-symbols 20 --output-dir .omx/artifacts/state_exit_smoke --n-bootstrap 100 --n-negative-shuffles 5 +``` + +```text +instances=48 eligible=44 eligible_rate=0.917 baseline_mean=-0.2044% eligible_delta_mean=0.1627% +VERDICT: INCONCLUSIVE +``` + +표본 부족으로 모델 gate 전까지의 추출/저장 경로만 확인했다. + +### 100종목 smoke + +```powershell +py -3.11 -X utf8 -m stom_rl.state_exit_gate --db-path _database/stock_tick_back.db --max-symbols 100 --output-dir .omx/artifacts/state_exit_smoke_100 --n-bootstrap 100 --n-negative-shuffles 5 +``` + +```text +instances=204 eligible=175 eligible_rate=0.858 baseline_mean=0.5039% eligible_delta_mean=-0.5847% +ridge inc=-0.0776% CI95=[-0.4607,0.2782] DSR=0.054 delta=-0.2364% exits=22 pos_bounds=1 -> no diagnostic +gbm inc=-0.1126% CI95=[-0.5100,0.1968] DSR=0.037 delta=-0.3431% exits=22 pos_bounds=0 -> no +negative controls: ['INCONCLUSIVE', 'INCONCLUSIVE', 'INCONCLUSIVE', 'INCONCLUSIVE', 'INCONCLUSIVE'] +VERDICT: INCONCLUSIVE +``` + +100종목도 사전등록 최소 count에는 부족하지만 모델/negative-control 경로는 실행됐다. + +## 테스트/검증 + +### 신규 단독 테스트 + +```powershell +py -3.11 -m pytest tests/test_stom_rl_state_exit_gate.py -q +``` + +```text +12 passed +``` + +검증 내용: + +- top-fraction ranking +- eligible-only prediction mapping +- 전체 원거래 분모 회계 +- train-only exit fraction 선택 +- planted positive edge GO +- noise NO-GO +- primary count 부족 시 INCONCLUSIVE +- negative-control hard blocker +- 30초 전 종료 거래 incremental=0 +- 30초 checkpoint 조기청산 수익 계산 +- checkpoint 이후 row 변경 시 feature 불변 + +### 관련 회귀 묶음 + +```powershell +py -3.11 -m pytest tests/test_stom_rl_state_exit_gate.py tests/test_stom_rl_sl_predictor.py tests/test_stom_rl_marketable_fill.py tests/test_stom_rl_exit_baselines.py tests/test_stom_rl_skip_gate.py -q +``` + +```text +59 passed +``` + +### `tests/` 전체 + +```powershell +py -3.11 -m pytest tests -q +``` + +```text +471 passed, 2 skipped, 351 warnings +``` + +### 전체 repo pytest 시도 + +```powershell +py -3.11 -m pytest -q +``` + +결과: `finetune/qlib_test.py` collection 중 `torch` DLL 로딩 오류로 중단. + +```text +OSError: [WinError 1114] DLL 초기화 루틴을 실행할 수 없습니다. +Error loading ... torch\lib\c10.dll +``` + +이는 이번 변경 파일의 테스트 실패가 아니라 로컬 torch 환경 collection 문제로 보인다. + +## 다음 결정 + +④ state-conditioned early-exit gate는 full-universe 기준 **NO-GO로 닫는다.** + +현재까지의 실험 흐름상: + +1. entry skip-gate: NO-GO +2. PPO/RL candidate: NO-GO_USABLE_MODEL +3. 30초 state-conditioned early-exit gate: NO-GO + +다음 단계는 새 모델을 더 복잡하게 만드는 것보다, 먼저 STOM `ts_imb` edge를 훼손하지 않는 **운영 리스크 제어** 또는 **체결/유동성 기반 필터**를 작은 사전등록 실험으로 분리하는 것이 더 안전하다. diff --git a/docs/stom_tick_ohlcv_kronos_finetuning_research.md b/docs/stom_tick_ohlcv_kronos_finetuning_research.md new file mode 100644 index 000000000..e909fa7b3 --- /dev/null +++ b/docs/stom_tick_ohlcv_kronos_finetuning_research.md @@ -0,0 +1,749 @@ +# STOM Tick DB 기반 Kronos 기본 OHLCV 파인튜닝 연구 문서 + +작성일: 2026-05-06 +대상 DB: `D:\Chanil_Park\Project\Programming\Kronos\_database\stock_tick_back.db` +범위: **Kronos 기본 OHLCV 입력만 사용**. STOM의 체결강도, 호가, 잔량, VI 등 확장 컬럼 학습은 이번 문서의 직접 범위가 아니다. + +--- + +## 1. 결론 요약 + +### 1.1 학습 가능한가? + +가능하다. + +단, “STOM tick DB를 Kronos에 그대로 직접 넣는다”는 의미는 아니다. Kronos 기본 fine-tuning 코드는 기본적으로 다음 형태의 K-line/OHLCV 시퀀스를 기대한다. + +```text +timestamps, open, high, low, close, volume, amount +``` + +따라서 STOM tick DB의 각 종목 테이블에서 Kronos가 이해할 수 있는 OHLCV만 추출해 **종목/날짜 경계를 유지한 grouped 학습 데이터**로 변환한 뒤 파인튜닝하는 방식이 현실적이다. + +### 1.2 모든 주식 테이블을 사용할 수 있는가? + +대체로 가능하다. + +실제 DB 전체를 읽기 전용으로 검사한 결과는 다음과 같다. + +| 항목 | 결과 | +|---|---:| +| 전체 테이블 수 | 2,427 | +| Kronos OHLCV 변환 호환 테이블 | 2,425 | +| 비호환 테이블 | `moneytop`, `stockinfo` | +| 고유 주식 테이블 schema 수 | 1 | +| 전체 주식 rows | 122,522,300 | +| 전체 symbol/session 그룹 수 | 73,968 | +| `lookback=300`, `predict=60` 기준 학습 가능 그룹 | 73,650 | +| 예상 학습 window 수 | 95,946,764 | + +즉, `moneytop`, `stockinfo`는 종목 tick 테이블이 아니라 metadata 성격이라 제외하고, 나머지 2,425개 주식 테이블은 같은 스키마로 OHLCV 변환이 가능하다. + +### 1.3 파인튜닝은 직접 가능한가? + +가능하다. 다만 권장 경로는 다음이다. + +```text +STOM SQLite DB +→ read-only inspect +→ OHLCV grouped CSV/Parquet cache 생성 +→ Kronos grouped dataset +→ tokenizer는 보통 pretrained 유지 +→ predictor/base model fine-tuning +→ holdout 예측 검증 +→ 웹 대시보드에서 실제값 vs 예측값 시각화 +``` + +현재 구현 기준으로는 `finetune_csv/prepare_stom_1tick.py`가 DB를 읽어 학습용 CSV를 만들고, `GroupedKlineDataset`이 `symbol + session` 단위로 window를 생성한다. + +--- + +## 2. STOM Tick DB 구조 이해 + +### 2.1 테이블 구조 + +DB는 SQLite이며 각 종목 코드가 하나의 테이블이다. + +예: + +```text +000020 +000040 +000050 +000060 +... +066970 +... +``` + +두 개의 비주식/metadata 테이블도 존재한다. + +```text +moneytop +stockinfo +``` + +이 두 테이블은 `현재가` 같은 가격 컬럼이 없어 Kronos OHLCV 학습 대상에서 제외한다. + +### 2.2 주식 테이블의 핵심 컬럼 + +주식 테이블에는 약 54개 컬럼이 존재한다. 그중 Kronos 기본 OHLCV 학습에 필요한 최소 컬럼은 다음이다. + +| Kronos 입력 | STOM 컬럼 | 설명 | +|---|---|---| +| `timestamps` | `index` | `YYYYMMDDHHMMSS` 형태의 tick/초 단위 시각 | +| `close` | `현재가` | 현재 체결/마지막 가격. DB에는 `종가` 컬럼이 없음 | +| `open` | `현재가` 또는 `시가` | 기본 권장값은 `현재가` | +| `high` | `현재가` 또는 `고가` | 기본 권장값은 `현재가` | +| `low` | `현재가` 또는 `저가` | 기본 권장값은 `현재가` | +| `volume` | `초당매수수량 + 초당매도수량` | 1초 단위 거래량 proxy | +| `amount` | `초당거래대금` | 1초 단위 거래대금 | + +주의할 점은 STOM의 `시가`, `고가`, `저가`가 “해당 1초봉 자체의 open/high/low”인지, 아니면 “당일 누적 시가/고가/저가”인지 확인이 필요하다는 점이다. 실제 샘플상 누적 일중 값일 가능성이 있으므로, Kronos 기본 OHLCV 학습에서는 `--price-mode close_only`가 더 안전하다. + +`close_only` 모드는 다음처럼 매핑한다. + +```text +open = 현재가 +high = 현재가 +low = 현재가 +close = 현재가 +``` + +이 방식은 진짜 1초봉 OHLC 폭은 잃지만, “가격 시퀀스 + 거래량/거래대금”을 누수 없이 안정적으로 학습하기 좋다. + +--- + +## 3. 왜 단순 CSV 합치기는 안 되는가? + +여러 종목을 단순히 하나의 CSV로 이어붙이면 안 된다. + +잘못된 예: + +```text +000020 마지막 tick +000040 첫 tick +``` + +이런 식으로 연결되면 학습 window가 종목 경계를 넘어가며, 모델은 존재하지 않는 가격 연속성을 학습한다. + +올바른 방식은 다음처럼 `symbol`, `session` 경계를 유지하는 것이다. + +```text +symbol, session, timestamps, open, high, low, close, volume, amount +000020, 20221212, 2022-12-12 09:00:05, ... +000020, 20221212, 2022-12-12 09:00:06, ... +000040, 20230906, 2023-09-06 09:02:25, ... +``` + +그리고 Dataset은 반드시 다음 단위 안에서만 window를 만든다. + +```text +symbol + session +``` + +현재 구현된 `GroupedKlineDataset`은 이 경계를 지킨다. 따라서 여러 종목을 하나의 파일로 담더라도 “단순 연결 CSV”가 아니라 “grouped CSV”로 동작한다. + +--- + +## 4. 전체 테이블 기준 학습 가능성 판단 + +### 4.1 검사 조건 + +검사 조건: + +```text +lookback_window = 300 +predict_window = 60 +필요 최소 rows = 300 + 60 + 1 = 361 +price_mode = close_only +``` + +즉, 한 종목의 하루 session에 최소 361개 row가 있어야 하나의 학습 window가 만들어진다. + +### 4.2 전체 검사 결과 + +읽기 전용 검사 결과: + +```text +전체 테이블: 2,427 +호환 주식 테이블: 2,425 +비호환 테이블: 2 +전체 주식 rows: 122,522,300 +전체 symbol/session 그룹: 73,968 +학습 가능 그룹: 73,650 +예상 학습 window: 95,946,764 +``` + +비호환 테이블: + +```text +moneytop +stockinfo +``` + +두 테이블은 가격 시계열 테이블이 아니므로 학습 대상에서 제외하는 것이 맞다. + +### 4.3 “모든 주식 테이블 사용”의 정확한 의미 + +이 문서에서 “모든 주식 테이블 사용”은 다음을 의미한다. + +```text +전체 2,427개 테이블 +→ metadata 2개 제외 +→ 주식 tick 테이블 2,425개 사용 +→ 단, session rows가 window보다 짧은 날짜 그룹은 자동 제외 +``` + +따라서 2,425개 주식 테이블을 대상으로 export를 걸 수 있고, 실제 학습 sample은 window 생성이 가능한 symbol/session에서 만들어진다. + +--- + +## 5. 실제 준비/학습 절차 + +### 5.1 1단계: 전체 DB 검사 + +```powershell +python finetune_csv/prepare_stom_1tick.py inspect ` + --db _database/stock_tick_back.db ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 0 ` + --price-mode close_only ` + --json-output finetune_csv/data/stom_1tick_all_inspect.json +``` + +설명: + +- `--max-tables 0`: 전체 테이블 검사 +- `--price-mode close_only`: `현재가`를 OHLC 모두에 사용 +- 결과 JSON에서 학습 가능 테이블/그룹/경고 확인 + +### 5.2 2단계: 파일럿 CSV 생성 + +처음부터 전체 122M rows를 변환하지 말고 일부 테이블로 smoke test를 먼저 한다. + +```powershell +python finetune_csv/prepare_stom_1tick.py export ` + --db _database/stock_tick_back.db ` + --output finetune_csv/data/stom_1tick_kline_pilot.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 100 ` + --price-mode close_only ` + --json-output finetune_csv/data/stom_1tick_export_pilot.json +``` + +### 5.3 3단계: 전체 주식 테이블 CSV 생성 + +파일럿이 정상이라면 전체 주식 테이블을 대상으로 export한다. + +```powershell +python finetune_csv/prepare_stom_1tick.py export ` + --db _database/stock_tick_back.db ` + --output finetune_csv/data/stom_1tick_kline_all.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 0 ` + --price-mode close_only ` + --json-output finetune_csv/data/stom_1tick_export_all.json +``` + +주의: + +- 전체 rows가 `122,522,300`개 수준이므로 CSV 파일이 매우 커질 수 있다. +- 운영 단계에서는 CSV보다 Parquet/Arrow cache가 더 적합하다. +- 현재 구현은 CSV 중심이므로, 첫 실험은 `--max-tables 100~300`으로 시작하는 것을 권장한다. + +### 5.4 4단계: config 설정 + +기본 템플릿: + +```text +finetune_csv/configs/config_stom_1tick.yaml +``` + +핵심 설정: + +```yaml +data: + data_path: "finetune_csv/data/stom_1tick_kline_all.csv" + dataset_type: "stom_tick" + group_columns: ["symbol", "session"] + lookback_window: 300 + predict_window: 60 + normalize_using: "lookback" + sample_stride: 1 + max_samples: null + +experiment: + train_tokenizer: false + train_basemodel: true + pre_trained_tokenizer: true + pre_trained_predictor: true +``` + +중요: + +- `normalize_using: "lookback"`은 미래 예측 구간을 정규화 통계에 포함하지 않기 위해 필요하다. +- `sample_stride`를 2, 5, 10으로 키우면 학습 샘플 수와 시간이 줄어든다. +- `max_samples`를 지정하면 파일럿 학습이 쉬워진다. + +예: + +```yaml +sample_stride: 5 +max_samples: 1000000 +``` + +### 5.5 5단계: 학습 실행 + +```powershell +python finetune_csv/train_sequential.py --config finetune_csv/configs/config_stom_1tick.yaml +``` + +RTX 4080 Super 16GB 기준 권장 시작값: + +```yaml +training: + basemodel_epochs: 1 + batch_size: 16 + num_workers: 4 + +data: + max_samples: 200000 + sample_stride: 5 +``` + +처음에는 다음만 확인하면 된다. + +1. DataLoader가 정상 생성되는가? +2. GPU 메모리가 터지지 않는가? +3. train loss / validation loss가 계산되는가? +4. 저장 경로에 best model이 생성되는가? + +그 후 점진적으로: + +```text +max_samples 20만 +→ 100만 +→ 500만 +→ 전체 또는 stride 기반 전체 +``` + +순서로 확대하는 것이 안전하다. + +--- + +## 6. 파인튜닝 모델 사용 방법 + +### 6.1 입력 window + +`lookback_window=300`, `predict_window=60`이면 모델 사용 시점마다 최근 300초를 입력으로 넣고, 다음 60초 구간을 예측한다. + +입력 예: + +```text +09:05:00 ~ 09:09:59 실제 OHLCV +→ 09:10:00 ~ 09:10:59 예측 +``` + +### 6.2 예측값을 점수화하는 방법 + +Kronos 예측 sequence에서 가장 단순한 점수는 예상 등락률이다. + +```text +현재가 = 입력 마지막 close +예측가 = 예측 horizon 마지막 close +예상등락률 = (예측가 / 현재가 - 1) * 100 +``` + +추가로 다음을 계산할 수 있다. + +| 지표 | 설명 | +|---|---| +| `pred_return_60s` | 60초 후 예상 등락률 | +| `pred_max_return_60s` | 예측 60초 안의 최고 예상 수익률 | +| `pred_min_return_60s` | 예측 60초 안의 최저 예상 수익률 | +| `pred_volatility_60s` | 예측 경로 변동성 | +| `confidence_proxy` | 예측 경로의 방향 일관성 또는 변동 대비 기대수익 | + +예: + +```text +score = pred_return_60s / max(pred_volatility_60s, epsilon) +``` + +다만 이것은 모델 확률이 아니라 예측 경로 기반 proxy 점수다. 실전 조건식과 결합하려면 별도 검증이 필요하다. + +--- + +## 7. 학습 후 실제값 vs 예측값 검증 + +### 7.1 Offline validation + +가장 먼저 해야 할 것은 과거 holdout 데이터에서 예측값과 실제값을 비교하는 것이다. + +절차: + +```text +1. validation symbol/session 선택 +2. 각 시점 t에서 최근 lookback_window 입력 +3. 모델로 t+predict_window까지 예측 +4. 실제 t+predict_window 가격과 비교 +5. 예측 row를 저장 +6. MAE/RMSE/방향정확도/수익률 hit ratio 계산 +``` + +추천 저장 schema: + +```text +prediction_id +model_version +symbol +session +asof_timestamp +horizon_seconds +actual_close_t0 +pred_close_t_horizon +actual_close_t_horizon +pred_return +actual_return +error +abs_error +direction_hit +created_at +``` + +### 7.2 주요 검증 지표 + +| 지표 | 의미 | +|---|---| +| MAE | 예측 가격 평균 절대 오차 | +| RMSE | 큰 오차에 더 민감한 가격 오차 | +| MAPE | 가격 대비 오차율 | +| Directional Accuracy | 상승/하락 방향 적중률 | +| Top-K Hit Ratio | 예측 점수 상위 종목의 실제 상승 비율 | +| Return IC | 예측 등락률과 실제 등락률의 상관 | + +단순 가격 예측 MAE보다 실제 매매에는 다음이 더 중요하다. + +```text +예측 등락률 순위가 실제 등락률 순위와 관련이 있는가? +상위 점수 종목이 평균적으로 유리한가? +예측값이 기존 STOM 조건식과 결합될 때 성능이 개선되는가? +``` + +--- + +## 8. 웹 대시보드 설계 + +### 8.1 목적 + +웹 대시보드는 “모델이 맞았는지”를 눈으로 검증하기 위한 도구다. + +핵심 질문: + +```text +특정 종목/특정 시각에서 +모델이 예측한 1분 뒤 가격 경로가 +실제 1분 뒤 움직임과 얼마나 비슷했는가? +``` + +### 8.2 권장 화면 구성 + +#### 화면 1: 모델/데이터 개요 + +표시 항목: + +- model path +- tokenizer path +- 학습 데이터 기간 +- 사용 종목 수 +- validation session 수 +- lookback/predict window +- MAE/RMSE/방향정확도 + +#### 화면 2: 종목별 실제값 vs 예측값 차트 + +필터: + +- `symbol` +- `session` +- `asof_timestamp` +- horizon: 30초/60초/180초 등 + +차트: + +```text +실제 close line +예측 close line +입력 lookback 구간 +예측 horizon 구간 +``` + +시각적 표현: + +- 입력 구간: 회색 배경 +- 예측 구간: 파란색 배경 +- 실제값: 검정 선 +- 예측값: 빨간 선 +- asof 시점: 세로선 + +#### 화면 3: 오차 분석 + +차트: + +- 시간대별 error +- symbol별 평균 error +- 예측 등락률 bin별 실제 평균 수익률 +- predicted return vs actual return scatter + +#### 화면 4: Top-K 후보 검증 + +예: + +```text +09:10:00 기준 예측 점수 상위 20개 +→ 60초 후 실제 상승률 +→ hit/miss 표시 +``` + +테이블 컬럼: + +```text +rank +symbol +asof_timestamp +pred_return +actual_return +direction_hit +score +``` + +#### 화면 5: 실시간 모니터링 + +실시간 모드에서는 다음 흐름이 필요하다. + +```text +STOM 실시간 tick 수신 +→ 최근 300초 OHLCV buffer 구성 +→ Kronos 예측 +→ prediction DB 저장 +→ 대시보드 갱신 +→ 60초 후 실제값 도착 시 정답 채움 +``` + +이때 처음에는 1초마다 모든 종목을 예측하지 말고, 후보 종목만 예측하는 것이 현실적이다. + +예: + +```text +거래대금 상위 100개 +조건식 통과 종목 +관심종목 +``` + +### 8.3 대시보드 기술 선택 + +#### Option A: Streamlit + +장점: + +- 빠르게 만들 수 있음 +- 연구/검증용에 적합 +- Plotly 차트 연동 쉬움 + +단점: + +- 실시간 고빈도 운영 UI로는 한계 + +#### Option B: FastAPI + React/Plotly + +장점: + +- 운영형 대시보드에 적합 +- 실시간 WebSocket 구성 가능 +- 기존 프로그램과 API 연결 쉬움 + +단점: + +- 구현량 증가 + +#### Option C: 기존 `webui/` 확장 + +장점: + +- Kronos repo 내부 구조 활용 + +단점: + +- 현재 webui가 STOM DB/학습 결과 검증용으로 바로 맞춰져 있는지 추가 분석 필요 + +권장: + +```text +1차: Streamlit으로 빠르게 검증용 대시보드 +2차: 검증 지표가 의미 있으면 FastAPI + React로 운영형 전환 +``` + +--- + +## 9. 실제 운영 파이프라인 제안 + +### 9.1 연구 단계 + +```text +1. 전체 DB inspect +2. 100개 테이블 export +3. max_samples 20만으로 학습 smoke +4. validation 예측값 저장 +5. Streamlit 대시보드로 실제값/예측값 확인 +``` + +### 9.2 파일럿 단계 + +```text +1. 300~500개 테이블 export +2. sample_stride 5 또는 10 +3. 1~3 epochs 학습 +4. Top-K 예측 점수 검증 +5. 기존 조건식과 결합 검증 +``` + +### 9.3 전체 학습 단계 + +```text +1. 전체 2,425개 주식 테이블 export +2. Parquet cache 또는 shard CSV 구성 +3. symbol/session 단위 train/validation split +4. GPU fine-tuning +5. dashboard 기반 holdout 검증 +``` + +--- + +## 10. 현재 한계와 주의점 + +### 10.1 모든 STOM 컬럼을 쓰는 것이 아니다 + +이번 방식은 Kronos 기본 OHLCV만 사용한다. + +사용하지 않는 예: + +- 체결강도 +- 등락율 +- 거래대금증감 +- 회전율 +- 시가총액 +- 호가/잔량 +- VI 정보 +- 관심종목 + +이 컬럼들을 쓰려면 Kronos tokenizer 입력 차원을 바꾸거나, 별도 조건식/점수화 모델과 결합해야 한다. + +### 10.2 `시가/고가/저가` 해석 문제 + +현재 안전한 기본값은 `close_only`다. + +만약 STOM DB의 `시가/고가/저가`가 진짜 1초봉 OHLC로 확정되면 다음 모드를 사용할 수 있다. + +```powershell +--price-mode db_ohlc +``` + +하지만 누적 일중 시고저라면 모델 입력에 왜곡이 생길 수 있다. + +### 10.3 전체 학습 규모가 크다 + +예상 window 수가 `95,946,764`개다. + +처음부터 전체 학습을 시도하면: + +- CSV 파일 크기 증가 +- DataLoader 시간 증가 +- 학습 시간 증가 +- validation 반복 시간 증가 + +문제가 발생할 수 있다. + +따라서 반드시 pilot → staged scale-up 순서가 필요하다. + +### 10.4 현재 환경의 GPU 확인 필요 + +이전 환경 확인에서는 Python의 torch가 CPU build로 보였다. + +```text +torch 2.9.0+cpu +cuda_available=False +``` + +RTX 4080 Super를 실제로 쓰려면 CUDA 지원 PyTorch 설치가 필요하다. + +--- + +## 11. 권장 다음 작업 + +### 11.1 바로 실행 가능한 다음 단계 + +1. 전체 export 전 pilot export: + +```powershell +python finetune_csv/prepare_stom_1tick.py export ` + --db _database/stock_tick_back.db ` + --output finetune_csv/data/stom_1tick_kline_pilot.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 100 ` + --price-mode close_only +``` + +2. config의 `data_path`를 pilot CSV로 변경 +3. `max_samples: 200000`, `sample_stride: 5`로 smoke training +4. validation 예측 저장 스크립트 작성 +5. Streamlit dashboard 작성 + +### 11.2 이후 구현하면 좋은 기능 + +- Parquet export 지원 +- export resume/checkpoint +- validation prediction writer +- Streamlit dashboard +- 실시간 prediction log DB +- 조건식 + Kronos score 결합 리포트 + +--- + +## 12. 최종 판단 + +Kronos 기본 OHLCV만 사용한다는 조건에서는 STOM tick DB 기반 파인튜닝은 가능하다. + +정확한 표현은 다음과 같다. + +```text +STOM DB의 모든 주식 테이블을 읽어 +index/현재가/초당매수수량/초당매도수량/초당거래대금 기반 OHLCV로 변환하고, +symbol + session 경계를 유지한 grouped dataset으로 Kronos predictor를 파인튜닝할 수 있다. +``` + +다만: + +```text +원본 SQLite DB를 Kronos에 그대로 직접 넣는 방식은 아니다. +모든 STOM 컬럼을 사용하는 것도 아니다. +metadata 테이블 moneytop/stockinfo는 제외한다. +session 길이가 부족한 그룹은 window 생성에서 제외된다. +``` + +따라서 현재 가장 현실적인 진행 방향은: + +```text +close_only OHLCV 변환 +→ grouped CSV/Parquet cache +→ pretrained tokenizer 유지 +→ Kronos-small/base predictor fine-tuning +→ holdout 예측 로그 생성 +→ 웹 대시보드에서 실제값/예측값 비교 +``` + +이다. diff --git a/docs/stom_tick_training_scope_status.md b/docs/stom_tick_training_scope_status.md new file mode 100644 index 000000000..6949a9f84 --- /dev/null +++ b/docs/stom_tick_training_scope_status.md @@ -0,0 +1,119 @@ +# STOM tick 학습 범위 및 external trading-program 제외 상태 + +작성일: 2026-05-08 + +## 1. external trading-program 연동 상태 + +external trading-program 전용 연동은 현재 범위에서 제거/제외한다. + +현재 유지하는 것은 다음뿐이다. + +- STOM 대시보드 내부 검증용 Kronos score 표시 +- CSV/JSON score export +- 예측값/실제값/Top-K/filter-search/rolling-validation 확인 + +현재 제거/제외한 것은 다음이다. + +- `external trading-program repository` repository 직접 수정 +- external trading-program import 예시 구현 +- external trading-program 실전 추천 로직 연결 +- external trading-program 전용 adapter라는 문구 + +따라서 앞으로 이 프로젝트의 현재 목표는 **STOM tick → Kronos 학습/평가/대시보드 검증**이며, 외부 프로그램 연동은 하지 않는다. + +## 2. STOM tick 전체 학습 여부 + +결론부터 말하면 다음과 같다. + +```text +전체 DB를 학습 가능한 dataset으로 변환: 완료 +전체 DB 기반 학습 루프 연결: 완료 +전체 가능한 window를 빠짐없이 모두 학습: 아직 아님 +``` + +## 3. 완료된 전체 데이터 작업 + +`stock_tick_back.db`의 전체 주식 테이블을 대상으로 1초봉 QlibDataset pickle을 생성했다. + +| 항목 | pred30 | pred60 | +| --- | ---: | ---: | +| stock table | 2,425 | 2,425 | +| 제외 table | 2 (`moneytop`, `stockinfo`) | 2 (`moneytop`, `stockinfo`) | +| export group | 73,900 | 73,900 | +| export row | 131,470,857 | 131,470,857 | +| session split overlap | 0 | 0 | + +즉, **모든 주식 tick table의 OHLCV를 Kronos fine-tuning에 사용할 수 있는 형태로 만드는 작업은 완료**했다. + +## 4. 실제 학습에 사용한 범위 + +현재 실제 fine-tuning은 전체 dataset을 연결했지만, 시간/검증 비용 때문에 budgeted sample로 실행했다. + +| 항목 | pred30 | pred60 | +| --- | ---: | ---: | +| possible train samples | 75,277,195 | 73,718,875 | +| possible val samples | 16,275,307 | 15,938,107 | +| 실제 사용 train samples | 20,000 | 20,000 | +| 실제 사용 val samples | 4,000 | 4,000 | +| best val loss | 2.1549 | 2.1302 | +| 학습 시간 | 약 551초 | 약 549초 | + +따라서 `STOM tick 전체를 모두 학습했느냐`에 대한 정확한 답은 다음이다. + +```text +아니오. 전체 DB를 학습 가능한 데이터셋으로 만들고, 그 전체 데이터셋을 학습 루프에 연결한 뒤 budgeted sample 학습을 수행했다. +하지만 7천만 개 이상의 가능한 train window 전체를 전량 epoch 학습한 것은 아니다. +``` + +## 5. 왜 아직 전체 window 전량 학습이 아닌가 + +현재 workstation은 3990X + RTX 4080 SUPER로 학습 가능하지만, 가능한 window가 horizon별 7천만 개 이상이다. +이를 모두 여러 epoch 학습하면 장시간이 필요하고, 먼저 다음 검증이 필요하다. + +1. 현재 조건식이 큰 walk-forward에서도 유지되는지 확인 +2. random/persistence baseline 대비 우위가 반복되는지 확인 +3. 비용 후 net return이 양수로 전환되는지 확인 +4. 그 이후 full/bigger training budget을 늘릴지 결정 + +## 6. 다음 권장 단계 + +다음은 학습량을 늘리기 전에 평가 표본을 먼저 키우는 것이 맞다. + +```text +pred60 checkpoint +max_sessions 100 +max_asofs 5 +max_symbols 50 +large walk-forward +rolling filter validation 재실행 +``` + +이 결과가 좋아야 train sample을 20,000에서 200,000, 1,000,000 이상으로 확대하는 것이 합리적이다. + +## 7. 2026-05-09 staged full-training 계획 반영 + +전체 window 전량 학습은 `docs/stom_1s_staged_full_training_plan.md`에 단계형 로드맵으로 반영했다. + +현재 실행 순서는 다음과 같다. + +```text +budget_20k 완료 +→ expand_200k +→ expand_1m +→ expand_5m +→ full_window 후보 +``` + +`finetune/run_stom_1s_finetune.py`에는 `--sample-stage` 옵션을 추가해 각 단계의 train/val sample budget이 실행 manifest에 명확히 남도록 한다. + +예시: + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --sample-stage expand_200k ` + --output-root finetune\outputs +``` + +단, 아직 바로 전량 학습으로 가지 않는다. 먼저 pred60 대형 walk-forward와 rolling validation에서 baseline 대비 개선이 유지되는지 확인한 뒤 `expand_200k`부터 실행한다. diff --git a/docs/stom_tokenizer_998_recovery.md b/docs/stom_tokenizer_998_recovery.md new file mode 100644 index 000000000..38ba92258 --- /dev/null +++ b/docs/stom_tokenizer_998_recovery.md @@ -0,0 +1,83 @@ +# STOM 1초봉 Kronos tokenizer 99.98% 정체 복구 기록 + +작성일: 2026-05-21 KST +대상 run: `stom_1s_grid_pred60_2025_full_small` + +## 결론 + +`tokenizer` 학습은 실패로 끝난 것이 아니라, 학습 루프가 거의 끝난 뒤 `latest_train_model` 사전 검증 체크포인트를 저장한 상태에서 대규모 validation 단계로 들어가 멈춘 것처럼 보인 상황이다. 기존 자동 handoff는 validation 완료 후 생성되는 `best_model`만 predictor 입력으로 사용했기 때문에, validation이 장시간 진행되거나 정체되면 predictor 단계가 시작되지 못했다. + +## 확인 증거 + +| 항목 | 확인값 | +| --- | --- | +| tokenizer progress | `293800 / 293858`, stage `99.9803%`, overall `49.9901%` | +| 마지막 로그 | `Tokenizer checkpoint saved ... latest_train_model (pre-validation epoch 1)` | +| validation 규모 | `3,925,397` steps, 기존 `tokenizer-val-batch-size=1` | +| predictor progress | `predictor.progress.json` 미생성 상태 | +| 사용 가능한 checkpoint | `finetune_tokenizer/checkpoints/latest_train_model/model.safetensors` 존재 | +| 기존 handoff 문제 | `--train-stage both` predictor가 `best_model` 경로만 사용 | + +## 원인 + +1. full STOM run은 tokenizer validation OOM을 피하려고 validation batch size를 1로 낮췄다. +2. 이 설정에서 validation step 수가 약 392만 개가 되어, 학습보다 긴 validation 병목이 생길 수 있다. +3. 기존 `train_tokenizer.py`는 validation 루프 내부 progress 로그를 남기지 않아 대시보드가 마지막 train step인 99.98%에서 멈춘 것처럼 보였다. +4. 기존 `run_stom_1s_finetune.py`는 `best_model`만 predictor handoff로 사용했다. validation이 끝나지 않으면 `best_model`이 없어서 predictor로 넘어갈 수 없다. + +## 적용한 복구/개선 + +| 변경 | 목적 | +| --- | --- | +| `--start-stage predictor` 추가 | `--train-stage both`의 2단계 진행률을 유지하면서 predictor 단계만 재개 | +| `--tokenizer-checkpoint-policy best_then_latest` 추가 | `best_model`이 있으면 공식 best를 쓰고, 없으면 `latest_train_model`로 안전하게 fallback | +| tokenizer validation progress 로그 추가 | 다음 장기 run에서 validation 단계가 숨지 않도록 `validation_step`, `validation_fraction` 기록 | +| progress sidecar validation 필드 추가 | 웹 대시보드/API에서 validation 상태를 표현 가능하게 함 | +| 웹 monitor stage 요약에 validation 필드 추가 | `/api/training/status`가 validation phase/step 정보를 전달 | +| UI done status에 `ok/recovered` 반영 | 복구된 tokenizer checkpoint를 완료/복구 단계로 표시 가능하게 함 | + +## 복구 실행 원칙 + +이번 run은 `latest_train_model`이 이미 저장되어 있으므로, 오래 멈춘 tokenizer process를 정리한 뒤 predictor를 다음 명령으로 재개한다. + +```powershell +python finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage both ` + --start-stage predictor ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --output-root finetune\outputs ` + --run-name stom_1s_grid_pred60_2025_full_small ` + --dataset-sample-mode full_sequential ` + --n-train-iter 18806883 ` + --n-val-iter 3925397 ` + --predictor-batch-size 16 ` + --predictor-num-workers 2 ` + --epochs 1 ` + --num-workers 12 ` + --persistent-workers ` + --prefetch-factor 6 +``` + +`best_then_latest`가 기본값이므로 위 명령은 `best_model`이 없을 때 자동으로 `latest_train_model`을 predictor tokenizer로 사용한다. + +## 주의사항 + +- 이 복구는 “validation loss 기준 best tokenizer”가 아니라 “학습 완료 직전 checkpoint”를 사용하는 방식이다. +- 목적은 장시간 validation 병목 때문에 전체 pipeline이 영구 정지되는 것을 막고 predictor 학습 성과를 먼저 확보하는 것이다. +- 최종 연구/검증에서는 별도 validation-only 또는 validation max-step/cap 전략으로 tokenizer 품질을 다시 측정해야 한다. + +## 실제 복구 실행 결과 (2026-05-21 07:32 KST) + +| 항목 | 결과 | +| --- | --- | +| 기존 tokenizer process | `81208`, `82888` 종료 | +| tokenizer progress 상태 | `recovered`, stage `100%`, overall `50%`로 복구 표시 | +| predictor launcher PID | `107684` | +| predictor child PID | `96764` | +| predictor handoff source | `latest_train_model` | +| predictor 시작 확인 | `train_predictor.py` 실행, `Step 500/1175431` 이후 진행 확인 | +| GPU 확인 | RTX 4080 SUPER, 학습 중 약 95% 사용 / VRAM 약 6.4GB 사용 확인 | + +이제 장기 학습 감시는 `http://127.0.0.1:5070/training` 또는 `/api/training/status`에서 predictor 단계 기준으로 보면 된다. diff --git a/docs/stom_training_dashboard_autorefresh_code_review.md b/docs/stom_training_dashboard_autorefresh_code_review.md new file mode 100644 index 000000000..982094c45 --- /dev/null +++ b/docs/stom_training_dashboard_autorefresh_code_review.md @@ -0,0 +1,76 @@ +# 학습 대시보드 자동 새로고침/전체 웹 통합 코드 리뷰 + +작성일: 2026-05-12 KST +Autopilot phase: code-review + +## 리뷰 범위 + +- `webui/app.py` +- `webui/templates/training_dashboard.html` +- `webui/templates/index.html` +- `webui/templates/stom_dashboard.html` +- `tests/test_training_monitor.py` +- 관련 문서 + +## 검토 결과 + +| 항목 | 결과 | +|---|---| +| 최종 recommendation | APPROVE | +| Architectural status | CLEAR | +| Critical | 0 | +| High | 0 | +| Medium | 0 | +| Low | 0 | + +## 리뷰 중 발견/보완한 내용 + +초기 구현 검토 중 `/training`에서 progress JSON/log에서 온 일부 값을 `innerHTML`에 직접 넣는 부분이 있었다. 기존 코드 흐름에서도 존재하던 패턴이지만, 이번 기능이 자동 갱신을 강화하므로 방어적으로 `escapeHtml`을 추가하고 badge class도 안전한 문자만 쓰도록 보완했다. + +보완 내용: + +- `badge(status)`가 status 텍스트를 HTML escape하고 CSS class를 안전 문자로 제한 +- `metric(label, value)`가 label/value를 HTML escape +- stage name, last log line, API error, GPU error를 HTML escape +- GPU table value도 HTML escape + +## 검증 증거 + +~~~text +python -m pytest tests/test_training_monitor.py tests/test_training_progress.py -q +8 passed in 1.99s +~~~ + +Live HTTP 확인: + +~~~text +/training?refresh_interval=17: autoRefreshEnabled, refreshIntervalSeconds, escapeHtml, default 17 확인 +/?refresh_interval=11: trainingInlinePanel, interval default 11 확인 +/stom?refresh_interval=13: stomTrainingStrip, interval default 13 확인 +~~~ + +Git 검증: + +~~~text +git diff --check: 통과 +~~~ + +## Autopilot 결론 + +Autopilot의 `ralplan -> ralph -> code-review` 루프는 clean 상태로 종료 가능하다. + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +Autopilot 계획 단계: ████████████████████ 100% +Autopilot 구현 단계: ████████████████████ 100% +Autopilot 리뷰 단계: ████████████████████ 100% +~~~ + +## 남은 장기 학습 단계 + +- tokenizer 전체 학습 완료 대기 +- tokenizer checkpoint 생성 확인 +- predictor 자동 전환 확인 +- predictor checkpoint 생성 확인 +- 예측 CSV 생성 +- 실제값 vs 예측값 대시보드 성과 검증 \ No newline at end of file diff --git a/docs/stom_training_dashboard_autorefresh_implementation.md b/docs/stom_training_dashboard_autorefresh_implementation.md new file mode 100644 index 000000000..df1cae8a1 --- /dev/null +++ b/docs/stom_training_dashboard_autorefresh_implementation.md @@ -0,0 +1,60 @@ +# 학습 대시보드 자동 새로고침/전체 웹 통합 구현 보고서 + +작성일: 2026-05-12 KST +Autopilot phase: ralph + +## 구현 결과 + +| 영역 | 구현 내용 | 상태 | +|---|---|---| +| `/training` | 자동 새로고침 ON/OFF, 초 단위 interval 입력, localStorage 저장, running 상태 기반 재스케줄 | 완료 | +| `/` | 실시간 학습 상태 요약 카드, 자동 갱신 interval 입력, `/training` 링크 추가 | 완료 | +| `/stom` | 예측/성과 대시보드 상단에 실시간 학습 상태 strip, 자동 갱신 interval 입력, `/training` 링크 추가 | 완료 | +| Flask 설정 | `refresh_interval` query parameter와 env 기본값 clamp 지원 | 완료 | +| 테스트 | route HTML/refresh interval clamp/API 회귀 테스트 | 완료 | + +## 동작 방식 + +- `/training?refresh_interval=17`처럼 URL query로 기본 갱신 주기를 지정할 수 있다. +- UI에서 초 단위 값을 바꾸면 즉시 다음 자동 갱신에 적용된다. +- 최소 2초, 최대 3600초로 clamp하여 과도한 polling을 막는다. +- `/training`은 running 또는 초기 unknown 상태에서만 자동 갱신을 예약하고, 완료/실패/중지 상태에서는 불필요한 polling을 줄인다. +- `/`, `/stom`은 가벼운 `/api/training/status`만 사용해 학습 요약을 보여준다. + +## 검증 증거 + +~~~text +python -m pytest tests/test_training_monitor.py tests/test_training_progress.py -q +8 passed in 2.06s +~~~ + +Live HTTP 확인: + +| URL | 확인 | +|---|---| +| `/training?refresh_interval=17` | autoRefreshEnabled, refreshIntervalSeconds, default 17 확인 | +| `/?refresh_interval=11` | trainingInlinePanel, interval default 11 확인 | +| `/stom?refresh_interval=13` | stomTrainingStrip, interval default 13 확인 | + +현재 live 학습 API 최종 확인: + +~~~text +status=running, stage=tokenizer, step=708000, overall_percent=7.5292 +~~~ + +## 현재 진행률 + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +Autopilot 구현 단계: ████████████████████ 100% +검증 단계: ████████████████████ 100% +code-review 단계: ░░░░░░░░░░░░░░░░░░░░ 0% +실제 학습 진행률: █░░░░░░░░░░░░░░░░░░░ 7.5292% +~~~ + +## 남은 단계 + +1. code-review 단계에서 변경 diff를 자체 검토한다. +2. 리뷰 지적이 있으면 보완 후 재검증한다. +3. 리뷰가 clean이면 Autopilot 완료 상태를 기록한다. +4. 학습 자체는 계속 tokenizer 완료, checkpoint 생성, predictor 전환을 기다린다. \ No newline at end of file diff --git a/docs/stom_training_dashboard_autorefresh_plan.md b/docs/stom_training_dashboard_autorefresh_plan.md new file mode 100644 index 000000000..e0fae2bbf --- /dev/null +++ b/docs/stom_training_dashboard_autorefresh_plan.md @@ -0,0 +1,76 @@ +# 학습 대시보드 자동 새로고침/웹 통합 개발 계획 + +작성일: 2026-05-12 KST +Autopilot phase: ralplan + +## 목표 + +STOM tick 전체 데이터 기반 Kronos 파인튜닝의 목적을 유지하면서, 사용자가 CMD를 보지 않아도 웹 전체에서 학습 상태를 확인할 수 있게 한다. + +핵심 목표: + +1. `/training` 실시간 학습 대시보드에 설정 가능한 자동 새로고침을 제공한다. +2. 학습 상태가 `running`일 때 설정 간격마다 runs/status/log/gpu를 다시 읽는다. +3. `/` 메인 Kronos UI와 `/stom` 예측/성과 대시보드에서도 현재 학습 상태 요약과 `/training` 링크를 보여준다. +4. 장기 학습 프로세스는 절대 종료하지 않고 관측만 한다. +5. 테스트와 code-review까지 통과한 상태로 commit한다. + +## 현재 확인된 구현 상태 + +| 영역 | 현재 상태 | 개선 필요 | +|---|---|---| +| `/training` | 진행률/log/GPU 표시 가능 | 5초 고정 `setInterval`, 사용자 설정 없음 | +| `/` | 기본 Kronos 예측 UI | 학습 상태 요약/학습 대시보드 링크 부족 | +| `/stom` | 실제값 vs 예측값 성과 대시보드 | 학습 진행 상태 요약/학습 대시보드 링크 부족 | +| API | `/api/training/*` 존재 | UI에서 설정 가능한 refresh 제어 필요 | + +## 구현 계획 + +### 1. Flask 설정값 추가 + +- `webui/app.py`에 refresh interval 파서 추가 +- query parameter `refresh_interval` 또는 기본값을 받아 template에 전달 +- 너무 빠른 polling 방지를 위해 최소/최대 초 단위 clamp 적용 + +### 2. `/training` 대시보드 개선 + +- 자동 새로고침 ON/OFF checkbox +- 새로고침 간격 seconds input +- 설정 저장: `localStorage` +- running 상태에서만 자동 반복 새로고침 +- 완료/실패/중단 상태면 자동 새로고침 대기 또는 정지 메시지 표시 +- 마지막 갱신 시각/다음 갱신 예정 표시 + +### 3. 전체 웹 통합 + +- `/` 메인 UI에 실시간 학습 상태 요약 카드 추가 +- `/stom` 성과 대시보드에 실시간 학습 상태 요약 카드 추가 +- 두 페이지 모두 `/training` 링크 제공 +- 작은 위젯은 과도한 로그까지 가져오지 않고 `/api/training/status`만 사용한다 + +### 4. 테스트 계획 + +- Flask route test에서 `/training?refresh_interval=...` 기본값/clamp 확인 +- `/`, `/stom`, `/training` HTML에 학습 상태 위젯/자동 새로고침 컨트롤이 포함되는지 확인 +- 기존 `/api/training/*` 테스트 유지 +- 최소 실행: `python -m pytest tests/test_training_monitor.py` +- 가능하면 관련 progress test도 실행: `python -m pytest tests/test_training_monitor.py tests/test_training_progress.py` + +## 완료 기준 + +| 기준 | 완료 판단 | +|---|---| +| 자동 새로고침 설정 | `/training`에서 UI로 초 단위 변경 가능 | +| running 조건 | running 중인 run이 있으면 설정 간격마다 갱신 | +| 전체 웹 통합 | `/`, `/stom`에서 학습 요약과 `/training` 링크 확인 가능 | +| 테스트 | 관련 pytest 통과 | +| code-review | 자체 리뷰에서 BLOCK/REQUEST CHANGES 없음 | +| commit | 계획/구현/리뷰가 Lore commit으로 남음 | + +## 현재 진행률 + +~~~text +전체 프로젝트 진행률: ███████████████████░ 97% +Autopilot 계획 단계: ████████████████████ 100% +구현 단계: ░░░░░░░░░░░░░░░░░░░░ 0% +~~~ \ No newline at end of file diff --git a/docs/stom_training_dashboard_browser_verification.md b/docs/stom_training_dashboard_browser_verification.md new file mode 100644 index 000000000..e6236d5a5 --- /dev/null +++ b/docs/stom_training_dashboard_browser_verification.md @@ -0,0 +1,161 @@ +# STOM Kronos 실시간 학습 모니터 브라우저 검증 보고서 + +작성일: 2026-05-11 KST +검증 대상: `http://127.0.0.1:5070/training` +검증 목적: 2025년 STOM tick pred60 Kronos-small 전체 학습을 시작하기 전에 `/training` 대시보드가 실제 서버/API/브라우저에서 동작하는지 확인한다. + +## 1. 이번 단계의 위치 + +이번 단계는 **본 학습 실행 전 최종 모니터링 검증 단계**다. + +```text +전체 진행률: ███████████████████░ 96% +현재 단계: ████████████████████ 100% /training 서버·API·브라우저 검증 완료 +남은 단계: █░░░░░░░░░░░░░░░░░░░ 4% 2025 full training → checkpoint → 예측/성과 검증 +``` + +목적을 잃지 않기 위한 판단: + +- 지금 목표는 대시보드를 꾸미는 것이 아니라 **8~9일짜리 전체 학습을 안전하게 관측 가능한 상태로 시작하는 것**이다. +- 따라서 이번 단계에서는 실시간 모니터의 실제 표시와 API 응답을 먼저 검증했다. +- 본 학습은 아직 시작하지 않았다. + +## 2. 검증용 run artifact + +대시보드가 즉시 읽을 수 있도록 2025 processed dataset 기반 dry-run artifact를 생성했다. + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode smoke ` + --train-stage both ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --run-name stom_training_dashboard_visual_smoke ` + --dataset-sample-mode full_sequential ` + --batch-size 1 ` + --num-workers 0 ` + --n-train-iter 2 ` + --n-val-iter 1 ` + --dry-run +``` + +검증 결과: + +| 항목 | 값 | +|---|---:| +| run | `stom_training_dashboard_visual_smoke` | +| status | `dry_run` | +| stage_count | 2 | +| tokenizer progress | 생성됨 | +| predictor progress | 생성됨 | +| dry-run overall_percent | 0.0 | +| dry-run log tail | `dry-run only; tokenizer command was not executed.` | + +이번 검증 중 발견한 UX 문제와 수정: + +1. dry-run이면 실제 학습을 하지 않았는데 stage index 때문에 전체 진행률이 50%처럼 보일 수 있었다. +2. dry-run에서는 stdout 파일이 없어 log API가 에러처럼 보일 수 있었다. + +수정 내용: + +- dry-run progress는 전체 진행률을 0%로 유지한다. +- dry-run도 placeholder stdout/stderr log를 생성한다. + +## 3. 서버 검증 + +실행 서버: + +```text +http://127.0.0.1:5070/training +``` + +서버 상태: + +| 항목 | 결과 | +|---|---| +| `/api/training/runs` | 200 OK | +| `/api/training/status?run=stom_training_dashboard_visual_smoke` | 200 OK | +| `/api/training/logs?run=stom_training_dashboard_visual_smoke&lines=5` | 200 OK | +| `/api/training/gpu` | 200 OK | + +GPU API 확인: + +| 항목 | 값 | +|---|---| +| GPU | NVIDIA GeForce RTX 4080 SUPER | +| VRAM total | 16,376 MiB | +| 온도 | 약 47~48°C | +| 전력 | 이 환경의 `nvidia-smi` power.draw가 N/A라 `-`로 표시 | + +## 4. 브라우저 렌더링 검증 + +Playwright headless 브라우저로 `/training` 페이지를 열어 확인했다. browser-use skill은 로드했지만 이 세션에서 Node REPL browser tool이 노출되지 않아, 검증은 Playwright fallback으로 수행했다. + +확인 항목: + +| 체크 | 결과 | +|---|---| +| 페이지 title | `STOM Kronos Training Monitor` | +| run 목록 표시 | 통과 | +| `stom_training_dashboard_visual_smoke` 표시 | 통과 | +| `dry_run` 표시 | 통과 | +| 전체 진행률 0.00% 표시 | 통과 | +| tokenizer/predictor stage 카드 표시 | 통과 | +| GPU 영역 표시 | 통과 | +| 실시간 로그 tail 표시 | 통과 | +| console error | 0개 | +| page error | 0개 | + +브라우저 검증 artifact: + +```text +.omx/browser-check/training_dashboard_browser_check.json +.omx/browser-check/training_dashboard_5070.png +``` + +위 파일은 런타임 검증 산출물이므로 커밋하지 않는다. + +## 5. 테스트 검증 + +실행: + +```powershell +C:\Python\64\Python3119\python.exe -m compileall -q finetune webui tests + +C:\Python\64\Python3119\python.exe -m pytest ` + tests/test_training_progress.py ` + tests/test_training_monitor.py ` + tests/test_stom_1s_finetune_runner.py::test_runner_dry_run_records_reproducible_env ` + tests/test_stom_1s_finetune_runner.py::test_sample_stage_sets_staged_full_training_budget ` + tests/test_stom_1s_finetune_runner.py::test_runner_can_build_tokenizer_stage_and_both_stage_handoff ` + tests/test_stom_2025_preflight.py ` + tests/test_stom_qlib_pipeline.py ` + tests/test_stom_tick_dataset.py ` + tests/test_stom_dashboard_helpers.py ` + -q +``` + +결과: + +```text +33 passed, 3 warnings +``` + +## 6. 현재 결론 + +`/training` 대시보드는 본 학습 시작 전 모니터링 용도로 사용할 수 있다. + +다음 단계는 **실제 2025년 Kronos-small 전체 학습 실행**이다. 단, 실행 전에는 아래 조건을 다시 확인해야 한다. + +1. Windows 절전/재부팅 방지 +2. 충분한 냉각 +3. D: 여유 공간 +4. `/training` 페이지 접속 가능 +5. 시작 후 tokenizer 로그가 `tokenizer.stdout.log`와 `tokenizer.progress.json`에 갱신되는지 1차 확인 + +## 7. 다음 권장 OMX 명령 + +```text +$ralph 2025년 STOM tick pred60 Kronos-small 전체 학습을 시작하고, /training 대시보드에서 tokenizer 단계의 progress/log/GPU 상태가 실제로 갱신되는지 1차 확인한 뒤 장기 학습 상태로 전환하세요. 완료 전까지 checkpoint와 progress를 주기적으로 점검하고 단계별 문서와 commit을 남기세요. +``` + diff --git a/docs/stom_training_monitor_dashboard.md b/docs/stom_training_monitor_dashboard.md new file mode 100644 index 000000000..80c123bbe --- /dev/null +++ b/docs/stom_training_monitor_dashboard.md @@ -0,0 +1,172 @@ +# STOM Kronos 실시간 학습 모니터링 대시보드 + +작성일: 2026-05-11 KST +목적: 2025년 STOM tick pred60 Kronos-small 전체 학습을 시작하기 전에, 며칠 동안 진행되는 학습 상태를 웹에서 확인할 수 있게 한다. + +## 1. 왜 이 단계가 먼저 필요한가 + +2025년 전체 학습은 RTX 4080 SUPER 기준 약 8~9일이 예상된다. 기존 `finetune/run_stom_1s_finetune.py`는 `subprocess.run(..., capture_output=True)` 방식이라 학습 프로세스가 끝나기 전까지 stdout/stderr가 로그 파일로 저장되지 않았다. 따라서 절전, 오류, GPU 사용률 저하, loss 정체를 중간에 확인하기 어려웠다. + +이번 단계는 본 학습 자체가 아니라 **본 학습을 안전하게 관측하기 위한 선행 단계**다. + +## 2. 이번 단계에서 추가된 기능 + +| 영역 | 추가 내용 | +|---|---| +| runner | 학습 child process stdout을 실시간 파일 기록 및 현재 터미널에 mirror 출력 | +| progress sidecar | `finetune/outputs//logs/.progress.json` 실시간 갱신 | +| web page | `/training` 실시간 학습 모니터 페이지 추가 | +| API | `/api/training/runs`, `/api/training/status`, `/api/training/logs`, `/api/training/gpu` 추가 | +| GPU 상태 | `nvidia-smi` 기반 GPU utilization, VRAM, 전력, 온도 표시 | +| 안전성 | run 이름은 `finetune/outputs`의 직접 하위 폴더만 허용하여 임의 파일 읽기 방지 | +| 테스트 | progress parser/tracker, dashboard helper, Flask route smoke 테스트 추가 | + +## 3. 표시되는 정보 + +`/training` 페이지에서 다음 정보를 확인할 수 있다. + +1. 학습 run 목록과 상태 +2. 전체 진행률 +3. tokenizer/predictor 단계별 진행률 +4. epoch, step, total step +5. 최근 train loss +6. validation loss, best validation loss +7. 경과 시간, ETA, samples/sec +8. 실시간 로그 tail +9. GPU 사용률, VRAM 사용량, 전력, 온도 +10. 다음 본 학습 권장 명령 + +## 4. 생성되는 파일 구조 + +예시 run 이름: `stom_1s_grid_pred60_2025_full_small` + +```text +finetune/outputs/stom_1s_grid_pred60_2025_full_small/ + tokenizer_run_manifest.json + run_manifest.json + logs/ + tokenizer.stdout.log + tokenizer.stderr.log + tokenizer.progress.json + predictor.stdout.log + predictor.stderr.log + predictor.progress.json +``` + +`*.progress.json`은 학습 로그가 한 줄 출력될 때마다 갱신된다. 웹 대시보드는 이 파일을 읽어서 진행률을 계산한다. + +## 5. 실제 학습 시작 전 권장 확인 순서 + +```text +전체 진행률: ███████████████████░ 95% +현재 단계: ████████████████████ 100% 실시간 학습 모니터 구현 완료 +남은 단계: █░░░░░░░░░░░░░░░░░░░ 5% 본 학습 실행 후 결과 검증 +``` + +| 단계 | 내용 | 상태 | +|---:|---|---| +| 1 | STOM tick DB 이해 및 OHLCV 변환 | 완료 | +| 2 | 2025년 processed dataset export | 완료 | +| 3 | 공식 tokenizer→predictor 학습 명령 dry-run | 완료 | +| 4 | 실시간 학습 모니터링 구현 | 완료 | +| 5 | 2025년 Kronos-small 전체 학습 실행 | 다음 | +| 6 | checkpoint 검증 및 예측 CSV 생성 | 남음 | +| 7 | `/stom`에서 실제값/예측값/종목별 통계 검증 | 남음 | +| 8 | 성과 개선 시 Kronos-base 또는 전체 연도 확대 판단 | 남음 | + +## 6. 웹 대시보드 실행 방법 + +기존 Flask webui를 실행한다. + +```powershell +C:\Python\64\Python3119\python.exe webui\run.py +``` + +브라우저: + +```text +http://127.0.0.1:7070/training +``` + +예측 결과 검증 페이지: + +```text +http://127.0.0.1:7070/stom +``` + +참고: 별도 검증 서버를 5000 포트로 띄운 경우에는 같은 경로를 `http://127.0.0.1:5000/training`처럼 열면 된다. + +## 7. 2025년 전체 학습 권장 명령 + +```powershell +C:\Python\64\Python3119\python.exe finetune\run_stom_1s_finetune.py ` + --horizon 60 ` + --mode full ` + --train-stage both ` + --dataset-dir finetune\qlib_exports\stom_1s_grid_pred60_2025\processed_datasets ` + --run-name stom_1s_grid_pred60_2025_full_small ` + --dataset-sample-mode full_sequential ` + --batch-size 4 ` + --num-workers 0 ` + --n-train-iter 18806883 ` + --n-val-iter 3925397 ` + --log-interval 1000 +``` + +이 명령을 실행하면 `/training`에서 `tokenizer` → `predictor` 진행이 순서대로 표시된다. + +## 8. 검증 결과 + +실행한 테스트: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest ` + tests/test_training_progress.py ` + tests/test_training_monitor.py ` + tests/test_stom_1s_finetune_runner.py::test_runner_dry_run_records_reproducible_env ` + -q +``` + +결과: + +```text +7 passed +``` + +추가 전체 회귀 검증: + +```powershell +C:\Python\64\Python3119\python.exe -m pytest ` + tests/test_training_progress.py ` + tests/test_training_monitor.py ` + tests/test_stom_1s_finetune_runner.py::test_runner_dry_run_records_reproducible_env ` + tests/test_stom_1s_finetune_runner.py::test_sample_stage_sets_staged_full_training_budget ` + tests/test_stom_1s_finetune_runner.py::test_runner_can_build_tokenizer_stage_and_both_stage_handoff ` + tests/test_stom_2025_preflight.py ` + tests/test_stom_qlib_pipeline.py ` + tests/test_stom_tick_dataset.py ` + tests/test_stom_dashboard_helpers.py ` + -q +``` + +결과: + +```text +33 passed, 3 warnings +``` + +## 9. 브라우저 검증 상태 + +2026-05-11 KST에 /training 실제 서버/API/브라우저 검증을 완료했다. + +- 상세 보고서: docs/stom_training_dashboard_browser_verification.md +- 검증 URL: http://127.0.0.1:5070/training +- 검증 결과: run 목록, 전체 진행률, tokenizer/predictor 단계, GPU, log tail 표시 통과 +- 추가 수정: dry-run progress는 0%로 표시하고 dry-run log placeholder를 생성한다. + +## 10. 주의사항 + +- 이 기능은 학습 진행률을 관측하는 기능이지 정확도 개선 기능은 아니다. +- checkpoint resume 자동화는 아직 별도 단계다. 현재는 로그/진행률/상태 확인이 목적이다. +- 전력 수치는 `nvidia-smi`가 제공하는 GPU 전력만 표시한다. 전체 워크스테이션 소비전력은 별도 전력계가 필요하다. +- 학습 중 Windows 절전/재부팅이 발생하면 프로세스가 중단될 수 있으므로 본 학습 전 절전 방지를 별도로 확인해야 한다. diff --git a/docs/stom_training_monitoring_checkpoint_20260512_1420.md b/docs/stom_training_monitoring_checkpoint_20260512_1420.md new file mode 100644 index 000000000..721f6d393 --- /dev/null +++ b/docs/stom_training_monitoring_checkpoint_20260512_1420.md @@ -0,0 +1,91 @@ +# STOM full training ???? ????? 2026-05-12 14:20 KST + +?? ??: ?? code-review ???? ??? ?? ??? ?? ??? ?? ??? read-only? ????, predictor ?? ??? ?? ???? ???? ?? gate? ??????. + +## 1. ?? ?? + +```text +??: ?? ?? / ???? +?? ?? ?? ??: ?? ?? ?? +??: ?? ??? tokenizer running?? predictor? ???? ??? checkpoint/model weight? 0????. +``` + +???? ??? ? ????? ? ??? ???? `?? ??`, `LOCKED`, `checkpoint ??`? ???? ???. + +## 2. Live training status + +?? ??: 2026-05-12 14:20 KST + +```text +run: stom_1s_grid_pred60_2025_full_small +status: running +latest stage: tokenizer +step: 1,442,000 / 4,701,721 +tokenizer ???: 30.6696% +?? both-stage ???: 15.3348% +?? loss: -0.0239 +learning rate: 0.000162 +samples/sec: ? 65.07 +ETA: ? 200,393? +``` + +## 3. Predictor / artifact gate + +```text +readiness.label: ?? ??: tokenizer ?? ? +readiness.performance_ready: false +readiness.predictor_started: false +readiness.predictor_complete: false +checkpoint_file_count: 0 +model_weight_file_count: 0 +tokenizer_checkpoint_ready: false +predictor_checkpoint_ready: false +``` + +??? ?? ??? **?? ?? ??? ??? ???? ??**???. + +## 4. GPU ?? + +```text +GPU: NVIDIA GeForce RTX 4080 SUPER +average util: ? 37.0% +VRAM: 3,275 / 16,376 MiB +VRAM ???: 20.0% +power draw: ?? ?? +power limit: 320W +temperature: 49 C +``` + +## 5. ?? ?? ?? + +```text +Tokenizer ?? [??????????] 30.67% +?? tokenizer+predictor ?? [??????????] 15.33% +Predictor ?? [??????????] 0% +Checkpoint/model ?? [??????????] 0% +?? ?? ?? [??????????] 0% +``` + +## 6. ???? ???? ?? + +?? ?? ? ??? 1?? 2?? ????? ?? ??/?? ?? ??? ??? ? ????. + +1. `/api/training/status`?? `latest_stage.train_stage == predictor` ?? predictor stage running/completed ?? +2. `/api/training/artifacts`?? predictor checkpoint ?? model weight ?? ?? +3. predictor ?? ? ??? vs ??? ?? artifact ?? ?? ?? ?? +4. `/stom` gate? `LOCKED`?? training/ready ???? ????? live ?? + +## 7. ?? ????? ?? ?? + +```powershell +Invoke-RestMethod http://127.0.0.1:5070/api/training/status +Invoke-RestMethod http://127.0.0.1:5070/api/training/history?limit=5 +Invoke-RestMethod http://127.0.0.1:5070/api/training/artifacts +Invoke-RestMethod http://127.0.0.1:5070/api/training/gpu +``` + +## 8. ?? ?? OMX ?? + +```text +$ralph ?? ?? ?? STOM full training? ???? ??, 1~2?? ? ?? tokenizer ???? ?? ?? ? /api/training/status, /api/training/history, /api/training/artifacts? ?? ?????. predictor? ?? ???? ???? ???? ?????? ???, predictor? ?????? /training ? /stom gate? training ??? ????? live HTTP? ??? ? commit?? ?????. +``` diff --git a/docs/stom_training_progress.md b/docs/stom_training_progress.md new file mode 100644 index 000000000..618c65db2 --- /dev/null +++ b/docs/stom_training_progress.md @@ -0,0 +1,1881 @@ +# STOM Kronos 학습 진행 현황 + +이 문서는 STOM 1tick DB를 Kronos OHLCV 학습 데이터로 변환하고, GPU 학습/예측/웹 대시보드 검증까지 이어가기 위한 **진행 관리 문서**이다. + +생성/갱신 기준일: 2026-05-07 KST + +## 전체 진행률 + +```text +████████████████████ 학습 인프라 9C / 9 완료, 100% +████████████░░░░░░░░ 실전 활용 확장 3 / 5 완료, 60% +``` + +현재 단계: + +```text +현재 단계: 활용 3단계 — STOM ?? ??용 CSV/JSON score export 완료 +직전 완료: score 성능 분해 및 조건식 필터 백테스트 리포트 완료 +다음 목표: export 결과를 외부 추천 프로그램 import/실전 조건식과 연결한다. +``` + +## 단계별 현황 + +| 단계 | 내용 | 상태 | 산출물/증거 | +|---:|---|---|---| +| 1 | STOM DB 구조 분석 | 완료 | 전체 2,427 테이블, 학습 가능 주식 테이블 2,425개 확인 | +| 2 | Kronos OHLCV 학습 데이터 파이프라인 구현 | 완료 | `finetune_csv/stom_tick_dataset.py`, `prepare_stom_1tick.py` | +| 3 | 예측 검증 CLI 및 웹 대시보드 구현 | 완료 | `stom_prediction_eval.py`, `/stom` 대시보드 | +| 4 | CUDA PyTorch 세팅 및 RTX 4080 SUPER 검증 | 완료 | `torch 2.9.0+cu128`, `cuda_available=True` | +| 5 | 파일럿 데이터 export | 완료 | `finetune_csv/data/stom_1tick_kline.csv` 생성 | +| 6 | GPU 파일럿 학습 실행 | 완료 | `finetune_csv/finetuned/stom_1tick_gpu_pilot_lookback300_pred60/basemodel/best_model` 생성 | +| 7 | 학습 모델 예측 CSV 생성 | 완료 | `webui/stom_predictions/kronos_gpu_pilot_predictions.csv` 생성 | +| 8 | 웹 대시보드 실제값/예측값 검증 | 완료 | `http://127.0.0.1:7071/stom` API/HTML/headless screenshot 검증 | +| 9 | 전체 2,425개 학습 가능 테이블 학습으로 확대 | 완료 | 9A 300개 완료, 9B 1,000개 완료, 9C 전체 테이블 bounded 학습/예측/대시보드 검증 완료 | +| 10 | Kronos 예측값 score/ranking 및 Top-K 추천 | 완료 | `/api/stom/recommendations`, 대시보드 Score Top-K 추천 표, 테스트 15개 통과 | +| 11 | score 성능 분해 및 조건식 필터 백테스트 | 완료 | `/api/stom/backtest-report`, 조건식/score band/종목/시간대 성능 리포트, 테스트 16개 통과 | +| 12 | STOM ?? ??용 score export | 완료 | `/api/stom/recommendation-export`, CSV/JSON export, 대시보드 export preview/download, 테스트 17개 통과 | + +## 5단계 완료 상세: 파일럿 데이터 export + +실행 명령: + +```powershell +python finetune_csv\prepare_stom_1tick.py export ` + --db _database\stock_tick_back.db ` + --output finetune_csv\data\stom_1tick_kline.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 100 ` + --price-mode close_only +``` + +결과: + +```text +selected_table_count: 100 +written_rows: 6,164,390 +written_groups: 3,704 +skipped_groups: 22 +min_rows_per_group: 361 +price_mode: close_only +trainable_csv_created: true +CSV size: 약 460.3 MB +``` + +CSV 헤더: + +```text +symbol,session,timestamps,open,high,low,close,volume,amount +``` + +주의: + +- STOM DB에 `종가` 컬럼이 없어 `현재가`를 close로 사용한다. +- `close_only` 모드에서는 `현재가`를 open/high/low/close 모두에 매핑한다. +- `시가/고가/저가`가 실제 1초봉 OHLC인지 확정되기 전까지는 `close_only`가 안전하다. + +## 5단계 검증 + +파일 존재/크기 확인: + +```text +exists: True +size_mb: 460.3 +``` + +baseline 예측 smoke test: + +```powershell +python finetune_csv\stom_prediction_eval.py ` + --data finetune_csv\data\stom_1tick_kline.csv ` + --output webui\stom_predictions\pilot_export_smoke.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 3 ` + --stride 120 ` + --mode baseline +``` + +결과: + +```text +mae: 6.6722222222222225 +rmse: 8.617488677747776 +mape: 0.6785394903231754 +direction_accuracy: 0.3333333333333333 +windows: 3 +rows: 180 +``` + +이 smoke test는 모델 품질 측정이 아니라 **생성된 CSV가 예측 검증 파이프라인 입력으로 정상 사용 가능한지 확인**하기 위한 검증이다. + +## 6단계 완료 상세: GPU 파일럿 학습 + +6단계는 기본 config를 바로 사용하지 않고, 런타임을 제한한 별도 파일럿 config로 진행했다. + +파일럿 config: + +```text +finetune_csv/configs/config_stom_1tick_pilot.yaml +``` + +핵심 설정: + +```text +data_path: finetune_csv/data/stom_1tick_kline.csv +sample_stride: 20 +max_samples: 4096 +basemodel_epochs: 1 +batch_size: 8 +num_workers: 0 +device: cuda:0 +pretrained_tokenizer: NeoQuasar/Kronos-Tokenizer-base +pretrained_predictor: NeoQuasar/Kronos-small +``` + +실행 명령: + +```powershell +python finetune_csv\train_sequential.py --config finetune_csv\configs\config_stom_1tick_pilot.yaml +``` + +학습 결과: + +```text +device: cuda:0 +model parameters: 24,741,376 +train groups: 3,148 +validation groups: 556 +train samples: 4,096 +validation samples: 4,096 +epoch: 1 / 1 +steps: 512 +training loss: 2.4667 +validation loss: 2.4311 +epoch time: 41.82 seconds +basemodel training time: 1.40 minutes +total training time: 1.47 minutes +exit code: 0 +``` + +생성 checkpoint: + +```text +finetune_csv/finetuned/stom_1tick_gpu_pilot_lookback300_pred60/basemodel/best_model/config.json +finetune_csv/finetuned/stom_1tick_gpu_pilot_lookback300_pred60/basemodel/best_model/model.safetensors +finetune_csv/finetuned/stom_1tick_gpu_pilot_lookback300_pred60/basemodel/best_model/README.md +``` + +주의: + +- checkpoint는 대용량/재생성 가능 산출물이므로 commit하지 않는다. +- `finetune_csv/finetuned/`는 `.gitignore`에 추가했다. + +## 6단계 중 발견/수정한 문제 + +문제: + +```text +python finetune_csv\train_sequential.py --config ... +ModuleNotFoundError: No module named 'model' +``` + +원인: + +```text +Python 파일 경로 실행 시 sys.path[0]이 finetune_csv로 잡히고, 기존 sys.path.append('../')는 현재 작업 디렉터리 기준이라 프로젝트 루트를 안정적으로 가리키지 않았다. +``` + +수정: + +```text +finetune_csv/train_sequential.py +finetune_csv/finetune_tokenizer.py +finetune_csv/finetune_base_model.py +finetune_csv/stom_prediction_eval.py +``` + +위 파일들이 `__file__` 기준으로 프로젝트 루트를 계산해 `sys.path`에 추가하도록 수정했다. + +회귀 테스트: + +```text +tests/test_cli_import_paths.py +``` + +## 6단계 검증 + +실행한 검증: + +```powershell +python -m compileall -q finetune_csv webui tests docs +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py tests/test_cli_import_paths.py -q +``` + +결과: + +```text +12 passed, 1 warning +``` + +## 7단계 완료 상세: 학습 모델 예측 CSV 생성 + +실행 명령: + +```powershell +python finetune_csv\stom_prediction_eval.py ` + --data finetune_csv\data\stom_1tick_kline.csv ` + --output webui\stom_predictions\kronos_gpu_pilot_predictions.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 20 ` + --stride 120 ` + --mode kronos ` + --model-path finetune_csv\finetuned\stom_1tick_gpu_pilot_lookback300_pred60\basemodel\best_model ` + --tokenizer-path NeoQuasar/Kronos-Tokenizer-base ` + --device cuda:0 +``` + +첫 실행에서 발견한 명령어 문제: + +```text +--tokenizer-path NeoQuasar\Kronos-Tokenizer-base +``` + +PowerShell에서 백슬래시를 사용하면 HuggingFace repo id가 잘못 해석된다. 반드시 `/`를 사용한다. + +```text +--tokenizer-path NeoQuasar/Kronos-Tokenizer-base +``` + +결과: + +```text +mode: kronos +windows: 20 +rows: 1,200 +mae: 5.330419387817383 +rmse: 10.139999867404422 +mape: 0.8288770468725435 +direction_accuracy: 0.5 +avg_pred_return: -0.28066592835209236 +avg_actual_return: 0.15233856575055293 +exit code: 0 +``` + +생성 산출물: + +```text +webui/stom_predictions/kronos_gpu_pilot_predictions.csv +webui/stom_predictions/kronos_gpu_pilot_predictions.metrics.json +``` + +CSV 구조: + +```text +window_id,symbol,session,asof_timestamp,target_timestamp,horizon_step,horizon_seconds,actual_close_t0,pred_close,actual_close,error,abs_error,pred_return_window,actual_return_window,direction_hit_window,mode +``` + +주의: + +- 예측 CSV와 metrics JSON은 대용량/재생성 산출물이므로 commit하지 않는다. +- 이번 결과는 작은 파일럿 학습 모델의 검증용 결과이며, 실제 매매 정확도 판단용 최종 모델이 아니다. + +## 7단계 중 발견/수정한 문제 + +문제: + +```text +from webui.app import app +/api/stom/prediction-files -> 500 +Warning: STOM dashboard helpers cannot be imported (No module named 'stom_dashboard') +``` + +원인: + +```text +webui.app을 패키지로 import할 때 webui/stom_dashboard.py 상대 import가 처리되지 않았다. +``` + +수정: + +```text +webui/app.py +tests/test_stom_dashboard_helpers.py +``` + +`webui.app`에서 `.stom_dashboard` 상대 import를 우선 사용하고, 단독 실행 호환을 위해 기존 `stom_dashboard` import를 fallback으로 유지했다. + +## 7단계 검증 + +대시보드 helper 검증: + +```text +has_kronos_file: True +rows: 1,200 +windows: 20 +symbols: 2 +chart_json 생성: 성공 +topk_count: 5 +``` + +Flask route 검증: + +```text +/stom -> 200 +/api/stom/prediction-files -> 200 +/api/stom/prediction?file=kronos_gpu_pilot_predictions.csv -> 200 +``` + +회귀 검증: + +```powershell +python -m compileall -q finetune_csv webui tests docs +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py tests/test_cli_import_paths.py -q +python -m pip check +``` + +결과: + +```text +13 passed, 1 warning +No broken requirements found +``` + +## 8단계 완료 상세: 웹 대시보드 실제값/예측값 검증 + +사전 발견: + +```text +7070 포트는 AnyDesk 서비스가 사용 중이었다. +``` + +대응: + +```text +webui/run.py가 환경변수로 host/port/browser-open 여부를 받을 수 있도록 보완했다. +KRONOS_WEBUI_HOST +KRONOS_WEBUI_PORT 또는 PORT +KRONOS_WEBUI_OPEN_BROWSER +``` + +추가 발견: + +```text +Windows PowerShell 리다이렉션 환경에서 이모지 로그가 cp949 인코딩으로 실패했다. +``` + +대응: + +```text +webui/run.py에서 stdout/stderr를 UTF-8, errors=replace로 재설정했다. +``` + +검증 서버 실행 명령: + +```powershell +$env:PYTHONIOENCODING='utf-8' +$env:KRONOS_WEBUI_HOST='127.0.0.1' +$env:KRONOS_WEBUI_PORT='7071' +$env:KRONOS_WEBUI_OPEN_BROWSER='0' +python webui\run.py +``` + +검증 URL: + +```text +http://127.0.0.1:7071/stom +``` + +서버 실행 증거: + +```text +Access URL: http://127.0.0.1:7071 +Running on http://127.0.0.1:7071 +``` + +HTTP/API 검증: + +```text +/stom -> 200 text/html; charset=utf-8 +/api/stom/summary -> 200 application/json +/api/stom/prediction-files -> 200 application/json +/api/stom/prediction?file=kronos_gpu_pilot_predictions.csv -> 200 application/json +``` + +예측 파일 API 검증: + +```text +file_count: 3 +has_kronos_file: True +names: + - kronos_gpu_pilot_predictions.csv + - pilot_export_smoke.csv + - pilot_predictions.csv +``` + +예측 payload 검증: + +```text +metrics.rows: 1,200 +metrics.windows: 20 +metrics.symbols: 2 +metrics.mae: 5.330419387817383 +metrics.rmse: 10.139999867404422 +metrics.mape: 0.8288770468725435 +metrics.direction_accuracy: 0.5 +windows_count: 20 +topk_count: 20 +chart_len: 12,328 +``` + +Headless browser fallback 검증: + +```text +Browser Use plugin skill은 로드했지만 이 세션에는 Node REPL js 실행 도구가 노출되지 않았다. +대신 Chrome headless로 실제 페이지 screenshot 생성을 검증했다. +``` + +생성된 screenshot 증거: + +```text +.omx/specs/stom-dashboard-stage8/stom_dashboard_stage8.png +format: PNG +size: 1500 x 1200 +file size: 206,607 bytes +non_blank: True +``` + +검증 후 서버는 종료했고 `7071` 포트가 해제되었다. + +## 9A 완료 상세: 300개 테이블 학습 확대 + +9단계는 전체 2,425개 테이블로 바로 확대하지 않고 다음 순서로 진행한다. + +```text +9A: 300개 테이블 검증 +9B: 1,000개 테이블 검증 +9C: 전체 2,425개 테이블 검증 +``` + +이번 9A에서는 300개 테이블 기준으로 export, GPU 학습, 예측 CSV 생성, 대시보드 API 검증까지 완료했다. + +### 9A config + +추가한 config: + +```text +finetune_csv/configs/config_stom_1tick_300.yaml +``` + +핵심 설정: + +```yaml +data: + data_path: "finetune_csv/data/stom_1tick_kline_300.csv" + sample_stride: 10 + max_samples: 50000 + +training: + basemodel_epochs: 1 + batch_size: 8 + num_workers: 0 + +model_paths: + exp_name: "stom_1tick_300_lookback300_pred60" +``` + +### 9A-1. 300개 테이블 export + +실행 명령: + +```powershell +python finetune_csv\prepare_stom_1tick.py export ` + --db _database\stock_tick_back.db ` + --output finetune_csv\data\stom_1tick_kline_300.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 300 ` + --price-mode close_only +``` + +결과: + +```text +selected_table_count: 300 +written_rows: 15,895,090 +written_groups: 9,590 +skipped_groups: 76 +CSV size: 약 1,181.11 MB +trainable_csv_created: true +exit code: 0 +``` + +### 9A-2. 300개 테이블 GPU 학습 + +실행 명령: + +```powershell +python finetune_csv\train_sequential.py --config finetune_csv\configs\config_stom_1tick_300.yaml +``` + +결과: + +```text +device: cuda:0 +model parameters: 24,741,376 +train groups: 8,151 +validation groups: 1,439 +train samples: 50,000 +validation samples: 50,000 +epoch: 1 / 1 +steps: 6,250 +training loss: 2.3893 +validation loss: 2.3258 +epoch time: 460.48 seconds +total training time: 9.34 minutes +exit code: 0 +``` + +생성 checkpoint: + +```text +finetune_csv/finetuned/stom_1tick_300_lookback300_pred60/basemodel/best_model +``` + +checkpoint는 대용량 산출물이므로 commit하지 않는다. + +### 9A-3. 300개 모델 예측 CSV 생성 + +실행 명령: + +```powershell +python finetune_csv\stom_prediction_eval.py ` + --data finetune_csv\data\stom_1tick_kline_300.csv ` + --output webui\stom_predictions\kronos_300_predictions.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 100 ` + --stride 120 ` + --mode kronos ` + --model-path finetune_csv\finetuned\stom_1tick_300_lookback300_pred60\basemodel\best_model ` + --tokenizer-path NeoQuasar/Kronos-Tokenizer-base ` + --device cuda:0 +``` + +결과: + +```text +mode: kronos +windows: 100 +rows: 6,000 +symbols: 3 +mae: 35.33297971089681 +rmse: 74.68674727395761 +mape: 0.668662828066495 +direction_accuracy: 0.58 +avg_pred_return: -0.1966529845790984 +avg_actual_return: 0.02022996986114153 +exit code: 0 +``` + +생성 산출물: + +```text +webui/stom_predictions/kronos_300_predictions.csv +webui/stom_predictions/kronos_300_predictions.metrics.json +``` + +예측 CSV와 metrics JSON은 재생성 가능 산출물이므로 commit하지 않는다. + +### 9A-4. 대시보드/API 검증 + +대시보드 helper 검증: + +```text +has_kronos_300: True +rows: 6,000 +windows: 100 +symbols: 3 +chart_len: 12,311 +topk_count: 20 +``` + +Flask route 검증: + +```text +/stom -> 200 +/api/stom/prediction-files -> 200 +/api/stom/prediction?file=kronos_300_predictions.csv -> 200 +``` + +회귀 검증: + +```powershell +python -m compileall -q finetune_csv webui tests docs +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py tests/test_cli_import_paths.py -q +python -m pip check +``` + +결과: + +```text +13 passed, 1 warning +No broken requirements found +``` + +## 9B 완료 상세: 1,000개 테이블 학습 확대 + +이번 9B에서는 300개 검증 결과를 기준으로 1,000개 테이블까지 확대했다. export, GPU 학습, 예측 CSV 생성, Flask helper/API, 실제 웹 화면 headless screenshot, 회귀 테스트까지 모두 완료했다. + +### 9B config + +추가한 config: + +```text +finetune_csv/configs/config_stom_1tick_1000.yaml +``` + +핵심 설정: + +```yaml +data: + data_path: "finetune_csv/data/stom_1tick_kline_1000.csv" + sample_stride: 10 + max_samples: 200000 + +training: + basemodel_epochs: 1 + batch_size: 8 + num_workers: 0 + +model_paths: + exp_name: "stom_1tick_1000_lookback300_pred60" +``` + +### 9B-1. 1,000개 테이블 export + +실행 명령: + +```powershell +python finetune_csv\prepare_stom_1tick.py export ` + --db _database\stock_tick_back.db ` + --output finetune_csv\data\stom_1tick_kline_1000.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 1000 ` + --price-mode close_only +``` + +결과: + +```text +selected_table_count: 1,000 +written_rows: 52,547,096 +written_groups: 31,641 +skipped_groups: 154 +CSV size: 약 3,891.05 MB +trainable_csv_created: true +exit code: 0 +``` + +CSV 헤더: + +```text +symbol,session,timestamps,open,high,low,close,volume,amount +``` + +### 9B-2. 1,000개 테이블 GPU 학습 + +실행 명령: + +```powershell +python finetune_csv\train_sequential.py --config finetune_csv\configs\config_stom_1tick_1000.yaml +``` + +결과: + +```text +device: cuda:0 +steps: 25,000 +training loss: 2.3204 +validation loss: 2.3030 +epoch time: 1,800.01 seconds +basemodel training time: 35.22 minutes +total training time: 35.25 minutes +exit code: 0 +``` + +생성 checkpoint: + +```text +finetune_csv/finetuned/stom_1tick_1000_lookback300_pred60/basemodel/best_model/config.json +finetune_csv/finetuned/stom_1tick_1000_lookback300_pred60/basemodel/best_model/model.safetensors +finetune_csv/finetuned/stom_1tick_1000_lookback300_pred60/basemodel/best_model/README.md +``` + +checkpoint는 대용량 재생성 산출물이므로 commit하지 않는다. + +### 9B-3. 1,000개 모델 예측 CSV 생성 + +실행 명령: + +```powershell +python finetune_csv\stom_prediction_eval.py ` + --data finetune_csv\data\stom_1tick_kline_1000.csv ` + --output webui\stom_predictions\kronos_1000_predictions.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 200 ` + --stride 120 ` + --mode kronos ` + --model-path finetune_csv\finetuned\stom_1tick_1000_lookback300_pred60\basemodel\best_model ` + --tokenizer-path NeoQuasar/Kronos-Tokenizer-base ` + --device cuda:0 +``` + +결과: + +```text +mode: kronos +windows: 200 +rows: 12,000 +symbols: 6 +mae: 91.56418504842122 +rmse: 239.203680163673 +mape: 0.5583030424351193 +direction_accuracy: 0.49 +avg_pred_return: -0.18787922462498305 +avg_actual_return: -0.04811741245771569 +exit code: 0 +``` + +생성 산출물: + +```text +webui/stom_predictions/kronos_1000_predictions.csv +webui/stom_predictions/kronos_1000_predictions.metrics.json +``` + +예측 CSV와 metrics JSON은 재생성 가능 산출물이므로 commit하지 않는다. + +해석 메모: + +```text +9B의 validation loss는 9A 대비 낮아졌다. +direction_accuracy는 9A 0.58에서 9B 0.49로 하락했다. +따라서 다음 9C에서는 데이터 규모 확대만으로 판단하지 말고 종목/시간대별 편차, 예측 score화, 조건식 필터 결합을 별도 평가해야 한다. +``` + +### 9B-4. 대시보드/API/headless 검증 + +대시보드 helper 검증: + +```text +has_kronos_1000: True +rows: 12,000 +windows: 200 +symbols: 6 +chart_keys: data, layout +chart_data_traces: 2 +topk_count: 20 +``` + +Flask route 검증: + +```text +/stom -> 200 text/html; charset=utf-8 +/api/stom/summary -> 200 application/json +/api/stom/prediction-files -> 200 application/json +/api/stom/prediction?file=kronos_1000_predictions.csv -> 200 application/json +``` + +실제 웹 화면 검증: + +```text +검증 URL: http://127.0.0.1:7072/stom?file=kronos_1000_predictions.csv +screenshot: .omx/specs/stom-dashboard-stage9b/stom_dashboard_stage9b_1000.png +file size: 160,973 bytes +browser: Chrome headless +검증 후 7072 포트 해제 +``` + +회귀 검증: + +```powershell +python -m compileall -q finetune_csv webui tests docs +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py tests/test_cli_import_paths.py -q +python -m pip check +``` + +결과: + +```text +13 passed, 1 warning +No broken requirements found +``` + +## 9C 완료 상세: 전체 테이블 bounded 학습 확대 + +9C에서는 전체 DB를 스캔해 모든 학습 가능 종목/세션을 포함하는 방향으로 확대했다. 단, 전체 9GB CSV를 그대로 학습에 넣으면 현재 `GroupedKlineDataset`이 CSV 전체를 `pandas.read_csv`로 읽는 구조라 7시간 이상 로딩 단계에서 정체되었다. 따라서 전체 테이블/전체 세션 다양성은 유지하되, 각 symbol/session group을 연속 420 row로 제한하는 bounded export 기능을 추가하고 그 bounded CSV로 학습을 완료했다. + +### 9C-0. 직접 전체 CSV 학습 병목 + +직접 전체 CSV: + +```text +finetune_csv/data/stom_1tick_kline_all.csv +size: 약 9,079.60 MB +rows: 122,345,828 +groups: 73,582 +``` + +문제: + +```text +전체 9GB CSV를 직접 학습에 넣으면 데이터 로딩 단계에서 7시간 이상 진행 로그/체크포인트가 없었다. +GPU 사용률은 0%였고, python 프로세스는 약 28GB 메모리를 사용 중이었다. +``` + +대응: + +```text +학습 프로세스를 안전하게 중지하고, export 단계에서 각 그룹을 연속 row 단위로 제한하는 --max-rows-per-group 옵션을 추가했다. +``` + +추가/수정 파일: + +```text +finetune_csv/stom_tick_dataset.py +tests/test_stom_tick_dataset.py +finetune_csv/configs/config_stom_1tick_all.yaml +``` + +추가된 옵션: + +```powershell +--max-rows-per-group 420 +``` + +이 옵션은 각 symbol/session group의 앞쪽 연속 row만 유지한다. `lookback_window=300`, `predict_window=60` 기준 최소 필요 row는 361개이므로 420개는 학습 window를 만들 수 있는 안전한 하한 이상의 bounded 값이다. + +### 9C-1. 전체 원본 export + +실행 명령: + +```powershell +python finetune_csv\prepare_stom_1tick.py export ` + --db _database\stock_tick_back.db ` + --output finetune_csv\data\stom_1tick_kline_all.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 0 ` + --price-mode close_only +``` + +결과: + +```text +selected_table_count: 2,427 +written_rows: 122,345,828 +written_groups: 73,582 +skipped_groups: 318 +CSV size: 약 9,079.60 MB +trainable_csv_created: true +exit code: 0 +``` + +참고: + +```text +2,427개 테이블을 스캔했지만 moneytop, stockinfo 같은 비학습 테이블은 필수 가격 column이 없어 export report에 error로 기록된다. +실제 학습 가능 주식 테이블 기준은 기존 분석의 2,425개다. +``` + +### 9C-2. 전체 bounded export + +실행 명령: + +```powershell +python finetune_csv\prepare_stom_1tick.py export ` + --db _database\stock_tick_back.db ` + --output finetune_csv\data\stom_1tick_kline_all_bounded.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 0 ` + --price-mode close_only ` + --max-rows-per-group 420 +``` + +결과: + +```text +selected_table_count: 2,427 +written_rows: 30,902,629 +written_groups: 73,582 +skipped_groups: 318 +clipped_groups: 73,520 +max_rows_per_group: 420 +CSV size: 약 2,303.72 MB +trainable_csv_created: true +exit code: 0 +``` + +### 9C-3. 전체 bounded 학습 config + +추가한 config: + +```text +finetune_csv/configs/config_stom_1tick_all.yaml +``` + +핵심 설정: + +```yaml +data: + data_path: "finetune_csv/data/stom_1tick_kline_all_bounded.csv" + sample_stride: 10 + max_samples: 300000 + +training: + basemodel_epochs: 1 + batch_size: 8 + num_workers: 0 + +model_paths: + exp_name: "stom_1tick_all_lookback300_pred60" +``` + +### 9C-4. 전체 bounded GPU 학습 + +실행 명령: + +```powershell +python finetune_csv\train_sequential.py --config finetune_csv\configs\config_stom_1tick_all.yaml +``` + +결과: + +```text +device: cuda:0 +steps: 37,500 +training loss: 2.4891 +validation loss: 2.3983 +epoch time: 3,030.47 seconds +total training time: 55.97 minutes +exit status: success +``` + +생성 checkpoint: + +```text +finetune_csv/finetuned/stom_1tick_all_lookback300_pred60/basemodel/best_model/config.json +finetune_csv/finetuned/stom_1tick_all_lookback300_pred60/basemodel/best_model/model.safetensors +finetune_csv/finetuned/stom_1tick_all_lookback300_pred60/basemodel/best_model/README.md +``` + +checkpoint는 대용량 재생성 산출물이므로 commit하지 않는다. + +### 9C-5. 전체 bounded 모델 예측 CSV 생성 + +실행 명령: + +```powershell +python finetune_csv\stom_prediction_eval.py ` + --data finetune_csv\data\stom_1tick_kline_all_bounded.csv ` + --output webui\stom_predictions\kronos_all_predictions.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 300 ` + --stride 120 ` + --mode kronos ` + --model-path finetune_csv\finetuned\stom_1tick_all_lookback300_pred60\basemodel\best_model ` + --tokenizer-path NeoQuasar/Kronos-Tokenizer-base ` + --device cuda:0 +``` + +결과: + +```text +mode: kronos +windows: 300 +rows: 18,000 +symbols: 8 +mae: 204.28732000901965 +rmse: 302.94789265301404 +mape: 0.27204721411459937 +direction_accuracy: 0.4 +avg_pred_return: -0.07505238605388899 +avg_actual_return: 0.009420187285990114 +exit code: 0 +``` + +생성 산출물: + +```text +webui/stom_predictions/kronos_all_predictions.csv +webui/stom_predictions/kronos_all_predictions.metrics.json +``` + +예측 CSV와 metrics JSON은 재생성 가능 산출물이므로 commit하지 않는다. + +해석 메모: + +```text +9C는 전체 테이블 다양성을 포함하는 데 성공했지만, 방향정확도는 0.40으로 낮다. +단독 예측값을 매매 신호로 쓰기보다는 예상 등락률/오차/방향/조건식 필터를 조합한 점수화 검증이 다음 단계다. +MAPE는 9B 0.5583에서 9C 0.2720으로 낮아졌지만, MAE/RMSE는 표본 종목 가격대 차이 영향을 받는다. +``` + +### 9C-6. 대시보드/API/headless 검증 + +대시보드 helper 검증: + +```text +has_kronos_all: True +rows: 18,000 +windows: 300 +symbols: 8 +chart_keys: data, layout +chart_data_traces: 2 +topk_count: 20 +``` + +Flask route 검증: + +```text +/stom -> 200 text/html; charset=utf-8 +/api/stom/summary -> 200 application/json +/api/stom/prediction-files -> 200 application/json +/api/stom/prediction?file=kronos_all_predictions.csv -> 200 application/json +``` + +실제 웹 화면 검증: + +```text +검증 URL: http://127.0.0.1:7073/stom?file=kronos_all_predictions.csv +screenshot: .omx/specs/stom-dashboard-stage9c/stom_dashboard_stage9c_all.png +file size: 160,973 bytes +browser: Chrome headless +검증 후 7073 포트 해제 +``` + +회귀 검증: + +```powershell +python -m compileall -q finetune_csv webui tests docs +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py tests/test_cli_import_paths.py -q +python -m pip check +``` + +결과: + +```text +14 passed, 1 warning +No broken requirements found +``` + +## 다음 단계: 예측 점수화/조건식/추천 프로그램 연계 + +학습 인프라 구축 1차 목표는 완료했다. 다음 단계는 모델 정확도 자체를 단독으로 올리는 것보다, 예측값을 실제 매매 판단에 쓰기 위한 score layer를 추가하는 것이다. + +남은 활용 단계: + +```text +1. 예측 등락률, 방향 hit, 오차, 변동성 기준으로 Kronos score 산식 설계 +2. 종목별/가격대별/시간대별 성능 분해 리포트 생성 +3. 조건식 필터와 Kronos score 결합 방식 실험 +4. STOM 또는 ?? ?? ?? 종가추천 프로그램 입력/출력 score export 설계 +5. 웹 대시보드에 score ranking, top-k 추천, 실제/예측 비교표 추가 +``` + +다음 OMX 명령 예시: + +```text +$autopilot Kronos 예측 CSV를 점수화하여 종목 추천 score/ranking을 만들고 웹 대시보드에 top-k 추천 화면까지 추가 후 commit +``` + +## 10단계 완료 상세: Kronos score/ranking 및 Top-K 추천 + +이번 단계에서는 기존 `pred_return_window` 단순 정렬을 넘어서, 실제 매매 판단에 더 가까운 **Kronos score/ranking layer**를 추가했다. + +### 10-1. Score 산식 개요 + +Score는 live 환경에서 사용할 수 있어야 하므로 실제값을 직접 넣지 않는다. 실제값은 백테스트 검증 표시용으로만 사용한다. + +```text +사용 입력: +1. pred_return_window: Kronos 예상 등락률 +2. prediction_consistency: 예측 경로가 기준가 위/아래 방향을 얼마나 일관되게 유지하는지 +3. pred_range_pct: 예측 경로 자체의 변동성/불안정성 + +백테스트 표시 전용: +1. actual_return_window +2. direction_hit_window +3. realized_mape +``` + +추천 signal: + +```text +BUY_CANDIDATE: score >= 60 이고 예측 등락률이 양수 +WATCH: score >= 45 +AVOID: 그 외 +``` + +### 10-2. 추가/수정 파일 + +```text +webui/stom_dashboard.py +webui/app.py +webui/templates/stom_dashboard.html +tests/test_stom_dashboard_helpers.py +docs/stom_training_progress.md +``` + +### 10-3. 추가 API + +기존 예측 API 응답에 score/ranking 결과를 포함했다. + +```text +GET /api/stom/prediction?file=kronos_all_predictions.csv +``` + +추가 전용 API: + +```text +GET /api/stom/recommendations?file=kronos_all_predictions.csv&k=10 +``` + +응답 핵심: + +```text +recommendations: + - window_id + - symbol + - session + - kronos_score + - signal + - pred_return_window + - actual_return_window + - direction_hit_window + - prediction_consistency + - pred_range_pct + - realized_mape + +summary: + - count + - avg_score + - top_score + - avg_pred_return + - avg_actual_return + - hit_rate + - buy_candidates +``` + +### 10-4. 실제 `kronos_all_predictions.csv` 기준 점검 + +Top-10 score/ranking 결과: + +```text +recommendation_count: 10 +avg_score: 67.68529410937185 +top_score: 79.72477997015723 +buy_candidates: 10 +avg_pred_return: 0.8389049188007235 +avg_actual_return: 0.1411525010482977 +hit_rate: 0.5 +``` + +Top-20 score/ranking 결과: + +```text +avg_score: 66.38696508097838 +top_score: 79.72477997015723 +buy_candidates: 20 +avg_pred_return: 0.4793409192456964 +avg_actual_return: 0.07572824490288654 +hit_rate: 0.4 +``` + +해석: + +```text +Score Top-10은 평균 실제 등락률이 양수였고 hit rate는 50%였다. +Top-20은 평균 실제 등락률이 양수였지만 hit rate는 40%로 낮다. +따라서 score 자체는 후보 압축에는 사용할 수 있으나, 아직 단독 매수 신호로 쓰기에는 부족하다. +다음 단계에서는 조건식/거래대금/변동성/시간대 필터를 결합해야 한다. +``` + +### 10-5. 대시보드 변경 + +대시보드에 다음 영역을 추가했다. + +```text +Kronos Score Top-K 추천 +Score Summary +추천 수 / 매수 후보 +평균 Score / Top Score +Top-K 평균 예측/실제 등락률 +Top-K 방향 Hit +``` + +추천 표 column: + +```text +Rank +Symbol +Score +Signal +Pred % +Consistency +Actual % +Hit +``` + +### 10-6. 검증 + +단위/route 검증: + +```powershell +python -m pytest tests/test_stom_dashboard_helpers.py -q +``` + +결과: + +```text +5 passed, 1 warning +``` + +추가 확인: +실제 파일 API 검증: + +```text +/api/stom/prediction?file=kronos_all_predictions.csv -> 200 +/api/stom/recommendations?file=kronos_all_predictions.csv&k=10 -> 200 +``` + +실제 웹 화면 검증: + +```text +검증 URL: http://127.0.0.1:7074/stom?file=kronos_all_predictions.csv +screenshot: .omx/specs/stom-score-ranking-stage10/stom_score_ranking_stage10.png +file size: 183,206 bytes +검증 후 7074 포트 해제 +``` + +전체 회귀 검증: + +```powershell +python -m compileall -q finetune_csv webui tests docs +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py tests/test_cli_import_paths.py -q +python -m pip check +``` + +결과: + +```text +15 passed, 1 warning +No broken requirements found +``` + +## 11단계 완료 상세: score 성능 분해 및 조건식 필터 백테스트 + +이번 단계에서는 score/ranking 결과를 실제 매매 후보 필터로 확장하기 전에, 조건식별·score 구간별·종목별·시간대별 성과를 분해하는 백테스트 리포트를 추가했다. + +### 11-1. 추가/수정 파일 + +```text +webui/stom_dashboard.py +webui/app.py +webui/templates/stom_dashboard.html +tests/test_stom_dashboard_helpers.py +docs/stom_training_progress.md +``` + +### 11-2. 추가 API + +```text +GET /api/stom/backtest-report?file=kronos_all_predictions.csv +``` + +응답 핵심: + +```text +filters: + - all_scored + - buy_candidate_score60 + - score65_consistency80 + - score70_pred_return_0_5 + - stable_positive_filter + - early_session_score60 + +segments: + - score_band + - symbol + - asof_minute_bucket +``` + +각 항목은 다음 지표를 포함한다. + +```text +count +avg_score +avg_pred_return +avg_actual_return +hit_rate +win_rate +avg_realized_mape +profit_factor +``` + +주의: + +```text +profit_factor는 수수료/슬리피지를 반영하지 않은 actual_return 합산 기반 진단값이다. +실전 수익률로 해석하지 말고 조건식 후보 비교용으로 사용한다. +``` + +### 11-3. 실제 `kronos_all_predictions.csv` 기준 조건식 리포트 + +전체 score window: + +```text +window_count: 300 +scored_count: 300 +avg_actual_return: 0.009420187285990119 +hit_rate: 0.4 +win_rate: 0.45666666666666667 +profit_factor: 1.0515874052804386 +``` + +주요 조건식: + +```text +buy_candidate_score60: + count: 61 + avg_score: 63.95274509784309 + avg_pred_return: 0.24310991685402727 + avg_actual_return: 0.032525486174057365 + hit_rate: 0.3770491803278688 + win_rate: 0.3770491803278688 + profit_factor: 1.263161159235278 + +score65_consistency80: + count: 17 + avg_score: 66.64469616447495 + avg_pred_return: 0.5307497687798453 + avg_actual_return: 0.06817071944519162 + hit_rate: 0.4117647058823529 + win_rate: 0.4117647058823529 + profit_factor: 1.4871745581504239 + +stable_positive_filter: + count: 7 + avg_score: 65.45998227102734 + avg_pred_return: 0.677440695032279 + avg_actual_return: 0.004440193675991562 + hit_rate: 0.5714285714285714 + win_rate: 0.5714285714285714 + profit_factor: 1.024478527562666 +``` + +score band 성과: + +```text +70+: + count: 1 + avg_actual_return: 2.898550724637681 + hit_rate: 1.0 + win_rate: 1.0 + +60-70: + count: 147 + avg_actual_return: 0.06514489156519303 + hit_rate: 0.36054421768707484 + win_rate: 0.46938775510204084 + +45-60: + count: 130 + avg_actual_return: -0.01803253875671562 + hit_rate: 0.4076923076923077 + win_rate: 0.43846153846153846 +``` + +해석: + +```text +score 60 이상 구간은 평균 실제 등락률이 양수였지만 방향 hit는 아직 낮다. +score65_consistency80 조건은 표본은 작지만 profit_factor와 평균 실제 등락률이 더 좋다. +stable_positive_filter는 hit/win이 높아졌지만 평균 실제 등락률 개선은 약하다. +따라서 다음 단계에서는 거래대금/거래량/변동성/가격대 조건을 추가해야 한다. +``` + +### 11-4. 대시보드 변경 + +대시보드에 다음 영역을 추가했다. + +```text +조건식 필터 백테스트 리포트 +조건식 필터별 성과 +Score 구간별 성과 +종목/시간대 상위 성과 +``` + +### 11-5. 검증 + +단위/route 검증: + +```powershell +python -m pytest tests/test_stom_dashboard_helpers.py -q +``` + +결과: + +```text +6 passed, 1 warning +``` + +추가 확인: + +```text +알 수 없는 asof_timestamp는 unknown 시간대 bucket으로 분류하고 early_session_score60 조건에서 제외한다. +``` + +실제 파일 API 검증: + +```text +/api/stom/backtest-report?file=kronos_all_predictions.csv -> 200 +/api/stom/prediction?file=kronos_all_predictions.csv -> 200 +``` + +전체 회귀 검증: + +```powershell +python -m compileall -q finetune_csv webui tests docs +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py tests/test_cli_import_paths.py -q +python -m pip check +``` + +결과: + +```text +16 passed, 1 warning +No broken requirements found. +``` + +웹 화면 검증: + +```text +검증 URL: http://127.0.0.1:7075/stom?file=kronos_all_predictions.csv +screenshot: .omx/specs/stom-score-backtest-stage11/stom_score_backtest_stage11.png +file size: 212,694 bytes +검증 후 7075 포트 해제 +``` + +## 12단계 완료 상세: STOM ?? ??용 CSV/JSON score export + +이번 단계에서는 Kronos score/ranking 결과를 다른 종목 추천 프로그램에서 바로 읽을 수 있도록 **score export**를 추가했다. 이 기능은 대시보드 표시용 내부 자료구조를 외부 연동용 CSV/JSON schema로 고정하는 단계이다. + +### 12-1. 추가/수정 파일 + +```text +webui/stom_dashboard.py +webui/app.py +webui/templates/stom_dashboard.html +tests/test_stom_dashboard_helpers.py +docs/stom_training_progress.md +``` + +### 12-2. 추가 API + +```text +GET /api/stom/recommendation-export?file=kronos_all_predictions.csv&format=json&limit=20&filter=buy_candidate_score60 +GET /api/stom/recommendation-export?file=kronos_all_predictions.csv&format=csv&limit=20&filter=buy_candidate_score60 +``` + +지원 query: + +```text +file: prediction CSV 파일명 +format: json 또는 csv +limit: export 개수, 기본 20 +filter: all_scored, buy_candidate_score60, score65_consistency80, score70_pred_return_0_5, stable_positive_filter, early_session_score60 +min_score: 선택 입력 +long_only: 1/0, 기본 1 +``` + +### 12-3. Export schema + +CSV/JSON record는 다음 필드를 고정해서 제공한다. + +```text +rank +source_file +window_id +symbol +session +asof_timestamp +score export_action +signal +kronos_score +score_band +pred_return_pct +prediction_consistency +pred_range_pct +asof_minute_bucket +filter_labels +diagnostic_actual_return_pct +diagnostic_direction_hit +diagnostic_realized_mape +``` + +중요 원칙: + +```text +score export_action, filter_labels, kronos_score, pred_return_pct, prediction_consistency, pred_range_pct는 live 사용 가능한 예측 기반 필드이다. +diagnostic_* 필드는 실제값 기반 검증용이므로 live 조건식/실전 필터에는 사용하지 않는다. +``` + +### 12-4. 실제 파일 API 검증 + +`kronos_all_predictions.csv` 기준: + +```text +/api/stom/recommendation-export?file=kronos_all_predictions.csv&format=json&limit=5 -> 200 +record_count: 5 +first_symbol: 000040 + +/api/stom/recommendation-export?file=kronos_all_predictions.csv&format=csv&limit=5 -> 200 +mimetype: text/csv +header: rank,source_file,window_id,symbol,session,asof_timestamp,score export_action,... +``` + +### 12-5. 대시보드 변경 + +좌측 패널에 다음 export 영역을 추가했다. + +```text +Kronos Score Export +조건식 필터 선택 +Export limit 입력 +Min score 선택 입력 +CSV 다운로드 +JSON 새 창 열기 +JSON preview +``` + +사용 흐름: + +```text +1. /stom 접속 +2. 예측 파일 선택 +3. 조건식 필터 선택 +4. CSV 다운로드 또는 JSON 열기 +5. 외부 추천 프로그램에서 symbol, score, action, asof_timestamp 기준으로 후보를 읽는다. +``` + +### 12-6. 검증 + +단위/route 검증: + +```powershell +python -m pytest tests/test_stom_dashboard_helpers.py -q +``` + +결과: + +```text +7 passed, 1 warning +``` + +전체 회귀 검증: + +```powershell +python -m compileall -q finetune_csv webui tests docs +python -m pytest tests/test_stom_tick_dataset.py tests/test_stom_training_cli.py tests/test_stom_prediction_eval.py tests/test_stom_dashboard_helpers.py tests/test_cli_import_paths.py -q +python -m pip check +``` + +결과: + +```text +17 passed, 1 warning +No broken requirements found. +``` + +## 다음 단계: 외부 추천 프로그램 import 및 실전 조건식 강화 + +남은 활용 단계: + +```text +1. ?? ?? ?? 또는 다른 종가종목 추천 프로그램에서 score export CSV/JSON을 읽는 import 예시 추가 +2. 거래대금/거래량/변동성/가격대 조건식 추가 +3. 수수료/슬리피지 반영 백테스트 지표 추가 +4. 실제 장중/종가 후보 추천 워크플로우 연결 +``` + +다음 OMX 명령 예시: + +```text +$autopilot D:\Chanil_Park\Project\Programming\?? ?? ?? 프로그램에서 Kronos score export CSV/JSON을 읽어 종목 추천 점수에 반영하는 import 예시와 검증 코드를 추가하고 commit +``` + +### 대시보드 실행 + +```powershell +python webui\run.py +``` + +7070 충돌 시: + +```powershell +$env:KRONOS_WEBUI_PORT='7071' +python webui\run.py +``` + +접속: + +```text +http://localhost:7070/stom +또는 +http://localhost:7071/stom +``` + +9단계 완료 확인 항목: + +```text +1. export row/group 수가 예상 범위인지: 완료 +2. GPU 학습 시간이 감당 가능한지: bounded 기준 약 56분으로 완료 +3. OOM 없이 checkpoint가 저장되는지: 완료 +4. 예측 CSV의 MAE/RMSE/MAPE/방향정확도가 파일럿 대비 개선/악화되는지: 완료, 다음 단계에서 score화 필요 +5. 대시보드에서 신규 prediction 파일을 정상 표시하는지: 완료 +``` + +## OMX 사용 기록 + +이번 단계에서는 다음 OMX 흐름을 사용했다. + +```text +1. autopilot 성격의 자율 진행: 단계 산출물 생성, 검증, 문서화, commit까지 연결 +2. note 성격의 진행 기록을 .omx/notepad.md에 보존 +3. code-review 성격의 diff 자체 점검: 변경 범위가 config/문서 중심인지 확인 +4. explore 시도: Windows POSIX 래퍼 미지원으로 실패 +5. sparkshell 시도: 현재 세션에서 program not found로 실패 +6. 일반 PowerShell fallback으로 export/train/predict/dashboard/test 검증 지속 +7. 전체 9GB CSV 직접 학습 병목 발견 후 bounded export 기능으로 복구 +``` + +## Commit 관리 원칙 + +각 단계 완료 시 다음을 commit한다. + +```text +1. 코드 변경 +2. 테스트/검증 결과가 반영된 문서 +3. 현재 단계/다음 단계/남은 단계 진행표 +4. 실행 명령과 산출물 위치 +``` + +대용량 산출물은 commit하지 않는다. + +```text +_database/ +finetune_csv/data/stom_*.csv +finetune_csv/finetuned/ +webui/stom_predictions/*.csv +webui/stom_predictions/*.json +모델 checkpoint +``` + +## 2026-05-09 pred60 대형 walk-forward 게이트 중간 점검 + +상세 보고서: `docs/stom_1s_large_walkforward_gate_report.md` + +이번 단계에서 `expand_200k` 학습을 바로 실행하지 않고, 먼저 pred60 `budget_20k` checkpoint를 대형 walk-forward와 rolling validation으로 검증했다. + +결과 요약: + +| 항목 | 값 | +| --- | ---: | +| selected windows | 3,080 | +| periods | 500 | +| Kronos direction accuracy | 0.4312 | +| random direction accuracy | 0.4084 | +| Qlib Top-K avg net return | -0.1953% | +| robust filter avg net return | -0.1266% | +| rolling avg test net return | -0.1766% | +| rolling positive fold rate | 0.25 | + +결론은 `expand_200k` 실제 학습 보류다. 현재 모델은 random보다 방향성 신호가 조금 높지만, 비용 후 수익성과 rolling 안정성이 부족하다. 따라서 다음 단계는 학습량 확대가 아니라 score/filter 리디자인과 비용 민감도 분석이다. + +```text +전체 데이터셋 구축 [█████] 100% +학습 루프 연결 [█████] 100% +20k budgeted 학습 [█████] 100% +대형 walk-forward 게이트 검증 [█████] 95% +200k 확대 학습 실행 [░░░░░] 0% 게이트 미충족으로 보류 +전체 진행률 [█████░] 91% +``` + +## 2026-05-09 cost sensitivity gate 자동화 + +상세 보고서: `docs/stom_1s_cost_gate_analysis_report.md` + +이번 단계에서 기존 filter-search와 rolling-validation 결과를 재사용해 비용 민감도와 확대 학습 승인 여부를 자동 계산하는 gate를 추가했다. + +실행 명령: + +```powershell +python finetune\search_stom_1s_filters.py ` + --gate-analysis ` + --filter-report webui\qlib_backtests\stom_1s_pred60_walkforward100x5x50_eval_kronos.filter_search.json ` + --rolling-report webui\qlib_backtests\stom_1s_pred60_walkforward100x5x50_eval_kronos_rolling100x50.rolling_filter_validation.json ` + --total-cost-bps-grid 5,10,15,25 ` + --target-total-cost-bps 25 +``` + +결과: + +| total cost | rolling avg test net | positive fold rate | gate | +| ---: | ---: | ---: | --- | +| 5bp | +0.0234% | 0.500 | PASS | +| 10bp | -0.0266% | 0.375 | FAIL | +| 15bp | -0.0766% | 0.375 | FAIL | +| 25bp | -0.1766% | 0.250 | FAIL | + +핵심 판단: + +```text +target 25bp gate: FAIL +expand_200k: 보류 +다음 단계: score/filter 리디자인 또는 pred30/pred60 ensemble 후보 검증 +``` + +진행률: + +```text +전체 데이터셋 구축 [█████] 100% +학습 루프 연결 [█████] 100% +20k budgeted 학습 [█████] 100% +대형 walk-forward/rolling/gate 검증 [█████] 96% +웹 대시보드 gate artifact 표시 [████░] 88% +200k 확대 학습 실행 [░░░░░] 0% target gate 미충족 +전체 진행률 [█████░] 93% +``` + +## 2026-05-12 STOM 2025 full training live checkpoint 08 + +상세 보고서: `docs/stom_2025_full_training_progress_checkpoint_08.md` + +이번 단계는 자동 새로고침 대시보드 구현 이후, 실제 long-running full training이 계속 살아 있는지 검증하는 중간 점검이다. + +핵심 결과: + +- `tokenizer` 단계 running +- step `920000 / 4701721` +- tokenizer 진행률 `19.5673%` +- 전체 both-stage 진행률 `9.7837%` +- 70초 간격으로 `918000 -> 919000` step 증가 확인 +- checkpoint/model weight는 아직 없음 +- predictor 전환 전 +- `/training?refresh_interval=10`, `/?refresh_interval=10`, `/stom?refresh_interval=10` HTTP 응답 및 자동 갱신 UI 확인 + +진행률: + +```text +데이터/실행 준비 [████████████████████] 100% +웹 대시보드 자동 갱신 통합 [████████████████████] 100% +tokenizer 학습 [████░░░░░░░░░░░░░░░░] 19.57% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 9.78% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0.00% +예측 CSV/실제값 비교 대시보드 성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 2026-05-12 STOM 2025 full training live checkpoint 09 + +상세 보고서: `docs/stom_2025_full_training_progress_checkpoint_09.md` + +이번 단계는 checkpoint 08 이후 long-running 학습이 계속 진행되는지 다시 검증한 중간 점검이다. + +핵심 결과: + +- `tokenizer` 단계 running +- step `1,020,000 / 4,701,721` +- tokenizer 진행률 `21.6942%` +- 전체 both-stage 진행률 `10.8471%` +- checkpoint 08 대비 `+98,000 step` 증가 +- 75초 간격으로 `1,019,000 -> 1,020,000` step 증가 확인 +- checkpoint/model weight는 아직 없음 +- predictor 전환 전 +- `/training?refresh_interval=10`, `/?refresh_interval=10`, `/stom?refresh_interval=10` HTTP 응답 및 자동 갱신 UI 확인 + +진행률: + +```text +데이터/실행 준비 [████████████████████] 100% +웹 대시보드 자동 갱신 통합 [████████████████████] 100% +tokenizer 학습 [████░░░░░░░░░░░░░░░░] 21.69% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 10.85% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0.00% +예측 CSV/실제값 비교 대시보드 성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 2026-05-12 STOM 2025 full training live checkpoint 10 + +상세 보고서: `docs/stom_2025_full_training_progress_checkpoint_10.md` + +이번 단계는 checkpoint 09 이후 long-running 학습이 계속 진행되는지 다시 검증한 중간 점검이다. + +핵심 결과: + +- `tokenizer` 단계 running +- step `1,079,000 / 4,701,721` +- tokenizer 진행률 `22.9490%` +- 전체 both-stage 진행률 `11.4745%` +- checkpoint 09 대비 `+59,000 step` 증가 +- 75초 간격으로 `1,078,000 -> 1,079,000` step 증가 확인 +- checkpoint/model weight는 아직 없음 +- predictor 전환 전 +- `/training?refresh_interval=10`, `/?refresh_interval=10`, `/stom?refresh_interval=10` HTTP 응답 및 자동 갱신 UI 확인 + +진행률: + +```text +데이터/실행 준비 [████████████████████] 100% +웹 대시보드 자동 갱신 통합 [████████████████████] 100% +tokenizer 학습 [█████░░░░░░░░░░░░░░░] 22.95% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 11.47% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0.00% +예측 CSV/실제값 비교 대시보드 성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` + +## 2026-05-12 STOM 2025 full training live checkpoint 11 + +상세 보고서: `docs/stom_2025_full_training_progress_checkpoint_11.md` + +이번 단계는 checkpoint 10 이후 long-running 학습이 계속 진행되는지 다시 검증한 중간 점검이다. + +핵심 결과: + +- `tokenizer` 단계 running +- step `1,136,000 / 4,701,721` +- tokenizer 진행률 `24.1614%` +- 전체 both-stage 진행률 `12.0807%` +- checkpoint 10 대비 `+57,000 step` 증가 +- 75초 간격으로 `1,134,000 -> 1,136,000` step 증가 확인 +- checkpoint/model weight는 아직 없음 +- predictor 전환 전 +- `/training?refresh_interval=10`, `/?refresh_interval=10`, `/stom?refresh_interval=10` HTTP 응답 및 자동 갱신 UI 확인 + +진행률: + +```text +데이터/실행 준비 [████████████████████] 100% +웹 대시보드 자동 갱신 통합 [████████████████████] 100% +tokenizer 학습 [█████░░░░░░░░░░░░░░░] 24.16% +전체 both-stage 학습 [██░░░░░░░░░░░░░░░░░░] 12.08% +predictor 학습 [░░░░░░░░░░░░░░░░░░░░] 0.00% +예측 CSV/실제값 비교 대시보드 성과 검증 [░░░░░░░░░░░░░░░░░░░░] 0.00% +``` diff --git a/docs/wiki/00-index.md b/docs/wiki/00-index.md new file mode 100644 index 000000000..d7b4d68d8 --- /dev/null +++ b/docs/wiki/00-index.md @@ -0,0 +1,49 @@ +# Kronos Wiki Index + +This wiki is the current operator-facing documentation for Kronos. It is served +inside **Kronos 대시보드** from the Docs tab and loaded from `docs/wiki/` through +read-only `/api/docs/*` endpoints. + +## Categories + +### Basics +- [00-index](00-index.md) - this index +- [01-overview](01-overview) - project overview and route map +- [02-architecture](02-architecture) - system architecture + +### STOM data +- [03-stom-1tick](03-stom-1tick) - 1-tick data usage +- [04-stom-1min](04-stom-1min) - 1-minute data usage +- [05-stom-1day](05-stom-1day) - daily data usage + +### Operations +- [06-know-how](06-know-how) - operating notes +- [07-trial-and-error](07-trial-and-error) - trial/error log +- [08-setup](08-setup) - setup and run guide + +### Interface +- [09-api-reference](09-api-reference) - read-only API catalog +- [10-dashboard-guide](10-dashboard-guide) - official dashboard usage guide + +## Quick start + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos +$env:KRONOS_WEBUI_PORT = "5070" +$env:KRONOS_WEBUI_HOST = "127.0.0.1" +$env:KRONOS_WEBUI_OPEN_BROWSER = "0" +C:\Python\64\Python3119\python.exe webui\run.py +``` + +Open `http://127.0.0.1:5070/` for the official dashboard and +`http://127.0.0.1:5070/rl` for the RL evidence dashboard. + +Legacy `/v2*` and `/rl-lab` URLs are compatibility redirects only. + +## Editing guide + +- Use `NN-slug.md` filenames. +- Keep the first line as `# Title`. +- Prefer Korean operator-facing copy, with English terms where they are already + dashboard labels. +- Do not claim live-trading readiness or profitability from dashboard evidence. diff --git a/docs/wiki/01-overview.md b/docs/wiki/01-overview.md new file mode 100644 index 000000000..2af406c8c --- /dev/null +++ b/docs/wiki/01-overview.md @@ -0,0 +1,60 @@ +# Kronos Project Overview + +Kronos is an experimental research and operations workspace for market-data +modeling, STOM rule/RL experiments, and dashboard-based evidence review. Treat it +as a backtest/research platform, not as a live-trading product. + +## Core stack + +| Area | Stack | +|---|---| +| Backend | Python 3.11 + Flask + read-only dashboard APIs | +| Model | PyTorch + Kronos model/tokenizer code | +| Data | STOM SQLite data and generated research artifacts | +| Frontend | Svelte 5 + Vite + TypeScript official dashboard | +| Charts | ECharts; Plotly only where existing dynamic imports require it | +| Monitoring | `nvidia-smi` and local status files, read-only | + +## Route map + +| URL | Purpose | +|---|---| +| `/` | Official Kronos dashboard | +| `/rl` | Official RL trading/evidence dashboard | +| `/training`, `/dashboard` | Dashboard bookmarks for training view | +| `/v1/`, `/v1/training`, `/v1/stom` | Legacy archive pages | +| `/v2`, `/v2/` | Legacy compatibility redirect to `/` | +| `/v2/rl-trading`, `/v2/rl-lab`, `/rl-lab` | Legacy compatibility redirect to `/rl` | +| `/api/*` | Read-only APIs | + +## Current constraints + +- The built dashboard dist is served by default when present. +- SSR/Jinja fallback is for explicit fallback testing only. +- Internal paths can still contain `v2`; public UI and docs should not present + the dashboard as a versioned product. +- RL/orderbook screens must show cost, split, baseline, `NO-GO`, and + not-live-ready guardrails. + +## Repository map + +```text +Kronos/ ++-- webui/ # Flask backend and official dashboard adapters +| +-- app.py # API and blueprint registration +| +-- v2/ # official shell routing, legacy redirects +| +-- v2_src/ # Svelte/Vite source (internal path name) +| +-- static/v2/dist/ # generated dashboard dist +| +-- templates/ # fallback/legacy templates ++-- stom_rl/ # rule/RL experiments and backtests ++-- finetune/ # training code and outputs ++-- model/ # Kronos model core ++-- _database/ # local STOM data ++-- docs/wiki/ # current operator docs +``` + +## Related docs + +- [02-architecture](02-architecture) - detailed system architecture +- [08-setup](08-setup) - setup and run commands +- [10-dashboard-guide](10-dashboard-guide) - dashboard usage diff --git a/docs/wiki/02-architecture.md b/docs/wiki/02-architecture.md new file mode 100644 index 000000000..47b93b08b --- /dev/null +++ b/docs/wiki/02-architecture.md @@ -0,0 +1,72 @@ +# System Architecture + +## Runtime flow + +```text +Browser + -> Flask :5070 + -> / official dashboard dist or explicit SSR fallback + -> /rl same shell, RL tab selected + -> /v1/* legacy archive templates + -> /api/* read-only JSON APIs + -> finetune outputs, STOM DB, logs, RL artifacts +``` + +## Backend + +`webui/app.py` owns the Flask app and read-only API registration. The dashboard +shell routes are isolated in `webui/v2/__init__.py` for compatibility with the +existing internal path names. + +Route behavior: + +| Route | Behavior | +|---|---| +| `/` | Serve official dashboard shell | +| `/rl` | Serve official dashboard shell with RL route selected | +| `/training`, `/dashboard` | Serve official dashboard shell with training route selected | +| `/v2`, `/v2/` | 301 redirect to `/` | +| `/v2/rl-trading`, `/v2/rl-lab`, `/rl-lab` | 301 redirect to `/rl` | +| `/api/*` | Existing read-only APIs; no broker/live-order side effects | + +## Frontend + +`webui/v2_src/` contains the Svelte/Vite source. The name is internal; the +public product name is `Kronos 대시보드` / `Kronos Dashboard`. + +```text +index.html + -> src/main.ts + -> App.svelte + -> Sidebar/Header/HeroStrip + -> tabs/* + -> lib/polling.ts and lib/api.ts +``` + +## Shell marker contract + +Official shell responses contain: + +```html + +``` + +They must not expose `kronos-v2-version`, `p1-ssr`, or `p1-5-spa` as public +markers. + +## Environment knobs + +| Variable | Effect | +|---|---| +| `KRONOS_WEBUI_PORT` | Flask port; use `5070` for local dashboard work. | +| `KRONOS_WEBUI_OPEN_BROWSER` | Set `0` for agent/test runs. | +| `KRONOS_DASHBOARD_MODE=ssr` | Force SSR fallback for fallback testing only. | +| `KRONOS_DASHBOARD_SSR_FALLBACK=1` | Same fallback intent as above. | + +Normal startup does not need a dist mode flag. If the built dist exists, Flask +serves it by default. + +## Related docs + +- [08-setup](08-setup) - run guide +- [09-api-reference](09-api-reference) - API catalog diff --git a/docs/wiki/03-stom-1tick.md b/docs/wiki/03-stom-1tick.md new file mode 100644 index 000000000..9a6efe2a6 --- /dev/null +++ b/docs/wiki/03-stom-1tick.md @@ -0,0 +1,50 @@ +# STOM 1-tick (틱) 데이터 활용법 + +> **이 문서는 사용자가 직접 채워나가는 살아있는 노하우 문서입니다.** Claude 가 골격만 제공했고, 실제 1-tick 활용 경험은 사용자 본인이 가장 잘 알기 때문에 직접 작성해주세요. + +## 1-tick 데이터란 + +체결 단위(거래 한 건) 가 발생할 때마다 기록되는 가장 세밀한 시계열. 1초 미만 간격으로도 데이터가 발생할 수 있음. + +## 데이터 위치 / 스키마 + +- **DB**: `D:\Chanil_Park\Project\Programming\Kronos\_database\` 하위 SQLite +- **테이블 예시**: (사용자 채울 자리) +- **컬럼**: 시간 / 가격 / 수량 / 매수·매도 구분 등 (실제 컬럼명 채울 자리) + +### 알려진 컬럼 이슈 + +- `/api/stom/summary` 응답의 `warnings` 에 `close column not found; using 종가 as close` 와 유사한 한국어 컬럼명 → 영문 매핑 경고가 있음. 1-tick 데이터에서도 동일 컬럼명 사용 시 확인 필요. + +## 활용 패턴 (사용자 작성 영역) + +### A. 추세 분석 +- (사용자 채울 자리) + +### B. 변동성 측정 +- (사용자 채울 자리) + +### C. 학습 데이터 구성 +- (사용자 채울 자리) + +## 시행착오 (사용자 작성 영역) + +- (사용자 채울 자리: 1-tick 학습 시 메모리/속도 이슈, 노이즈 처리, 결측 처리 등) + +## 권장 하이퍼파라미터 (사용자 작성 영역) + +| 항목 | 값 | 사유 | +|---|---|---| +| lookback | (사용자 채울 자리) | | +| pred_len | (사용자 채울 자리) | | +| batch_size | (사용자 채울 자리) | | + +## 관련 문서 + +- [04-stom-1min](04-stom-1min) — 1분봉 비교 +- [05-stom-1day](05-stom-1day) — 1일봉 비교 +- [06-know-how](06-know-how) — 일반 학습 노하우 + +--- + +*이 문서는 초안 골격입니다. 사용자 본인의 1-tick 활용 경험을 직접 채워주세요.* diff --git a/docs/wiki/04-stom-1min.md b/docs/wiki/04-stom-1min.md new file mode 100644 index 000000000..685bce600 --- /dev/null +++ b/docs/wiki/04-stom-1min.md @@ -0,0 +1,65 @@ +# STOM 1-min (분봉) 데이터 활용법 + +> **이 문서는 사용자가 직접 채워나가는 살아있는 노하우 문서입니다.** + +## 1-min 데이터란 + +1분 단위로 OHLCV(시가/고가/저가/종가/거래량) 가 집계된 시계열. 주식 시장 일중 데이터의 가장 흔한 단위. + +## 데이터 위치 / 스키마 + +- **DB**: `_database/` 의 STOM SQLite (정확한 테이블명은 사용자 채움) +- **컬럼**: open, high, low, close, volume + timestamp +- **호환 테이블 수**: 2,425개 (`/api/stom/summary` 의 `compatible_stock_table_count`) +- **eligible 그룹 수**: 73,650개 (학습 가능 그룹) +- **추정 샘플 수**: 약 95.9M + +## STOM 데이터의 한국어 컬럼명 + +`/api/stom/summary` 의 `warnings`: +> close column not found; using 종가 as close. + +즉 일부 테이블은 영문 컬럼명(`close`) 이 없고 한국어(`종가`) 만 있음. 학습 코드는 이를 자동 매핑하지만 직접 SQL 조회 시 주의. + +## 활용 패턴 (사용자 작성 영역) + +### A. 일중 추세 학습 +- (사용자 채울 자리) + +### B. 갭(GAP) 처리 — 장 마감/개장 사이 +- (사용자 채울 자리: 09:00 시작 / 15:30 마감 사이의 갭을 어떻게 다루는지) + +### C. 거래량 가중 (Volume-weighted) +- (사용자 채울 자리) + +### D. 학습 윈도우 구성 +- lookback 400 + pred_len 120 = 520 분 ≈ 8.7 시간 (장 시간 6.5h 보다 길어 인접 일자 포함) +- 또는 lookback 200 + pred_len 60 = 260 분 ≈ 4.3 시간 (장중 일부) + +## 시행착오 (사용자 작성 영역) + +### 메모리 OOM (관련: 학습 실패 commit 7742cb8) +- **증상**: validation 단계에서 GPU VRAM 16 GiB 초과 → OOM crash +- **원인**: validation batch size 가 큼 +- **시도한 것**: (사용자 채울 자리) +- **해결**: (사용자 채울 자리) + +## 권장 하이퍼파라미터 (사용자 작성 영역) + +| 항목 | 값 | 사유 | +|---|---|---| +| lookback | (예: 400) | | +| pred_len | (예: 60 / 120) | | +| batch_size (train) | (사용자 채울 자리) | | +| batch_size (val) | (OOM 회피 위해 train 보다 작게) | | +| learning_rate | (사용자 채울 자리) | | + +## 관련 문서 + +- [03-stom-1tick](03-stom-1tick) — 1틱 비교 +- [05-stom-1day](05-stom-1day) — 1일봉 비교 +- [07-trial-and-error](07-trial-and-error) — 시행착오 기록 + +--- + +*이 문서는 초안 골격입니다. 사용자 본인의 1-min 활용 경험을 직접 채워주세요.* diff --git a/docs/wiki/05-stom-1day.md b/docs/wiki/05-stom-1day.md new file mode 100644 index 000000000..3ac018c6a --- /dev/null +++ b/docs/wiki/05-stom-1day.md @@ -0,0 +1,57 @@ +# STOM 1-day (일봉) 데이터 활용법 + +> **이 문서는 사용자가 직접 채워나가는 살아있는 노하우 문서입니다.** + +## 1-day 데이터란 + +하루 단위로 OHLCV가 집계된 시계열. 가장 거시적인 단위로 노이즈가 적고 추세 분석에 유리. + +## 데이터 위치 / 스키마 + +- **DB**: `_database/` 의 STOM SQLite +- **컬럼**: open, high, low, close, volume + date +- **연속 길이**: 한 종목당 영업일 기준 ~250일 ≈ 1년 +- 10년 데이터면 ~2,500 일 정도 + +## 활용 패턴 (사용자 작성 영역) + +### A. 장기 추세 / 사이클 +- (사용자 채울 자리) + +### B. 펀더멘털 + 일봉 결합 +- (사용자 채울 자리: 재무제표 + 일봉 같이 쓰는 경우) + +### C. 포트폴리오 백테스트 +- (사용자 채울 자리: top-k 추천 + 일별 리밸런싱) + +## 1-min vs 1-day 트레이드오프 + +| 항목 | 1-min | 1-day | +|---|---|---| +| 노이즈 | 높음 | 낮음 | +| 학습 데이터 양 | 거대함 (~95M) | 적음 (~2.5K/종목) | +| 메모리 부담 | 큼 | 작음 | +| 예측 윈도우 | 분 단위 | 일/주 단위 | +| 트레이딩 전략 | 데이트레이딩 | 스윙/포지션 | + +## 시행착오 (사용자 작성 영역) + +- (사용자 채울 자리: 1-day 학습 시 데이터 부족 문제, augmentation 시도 등) + +## 권장 하이퍼파라미터 (사용자 작성 영역) + +| 항목 | 값 | 사유 | +|---|---|---| +| lookback | (예: 60 일 = 약 3개월) | | +| pred_len | (예: 5 일 = 1주) | | +| batch_size | (사용자 채울 자리) | | + +## 관련 문서 + +- [03-stom-1tick](03-stom-1tick) — 1틱 (가장 세밀) +- [04-stom-1min](04-stom-1min) — 1분봉 (중간) +- [06-know-how](06-know-how) — 일반 학습 노하우 + +--- + +*이 문서는 초안 골격입니다. 사용자 본인의 1-day 활용 경험을 직접 채워주세요.* diff --git a/docs/wiki/06-know-how.md b/docs/wiki/06-know-how.md new file mode 100644 index 000000000..26f0b6603 --- /dev/null +++ b/docs/wiki/06-know-how.md @@ -0,0 +1,70 @@ +# Kronos Operating Notes + +These are current operating notes for training, dashboard inspection, and local +artifact review. Historical design notes live elsewhere; use this page for daily +operations. + +## Dashboard operations + +### Theme and refresh + +- Use the sun/moon toggle or Settings tab for light/dark mode. +- Use 5 seconds as the normal refresh interval during active monitoring. +- Use 30-60 seconds when the dashboard is background-only. + +### Live Training quick diagnosis + +1. Read the status badge and readiness card first. +2. Check loss trend and stage-aware progress. +3. Check GPU utilization, temperature, and VRAM. +4. If progress is stale, verify `/api/training/status` and the training process + before changing dashboard code. + +### RL evidence quick diagnosis + +- Check whether an item is a RULE baseline or an RL experiment. +- Check cost, split, seed, trade count, drawdown, and baseline delta. +- Treat `NO-GO` as a result to surface, not a problem to hide. +- Do not infer live profitability from a dashboard curve. + +## Build and deployment notes + +```powershell +cd webui\v2_src +npm run build +# updates webui/static/v2/dist/ +``` + +The built dist is served by default when present. Flask does not need a dist +feature flag for normal operation. + +If a browser appears stale, use `Ctrl+Shift+R` after rebuilding because generated +asset hashes can remain cached. + +## Explicit fallback testing + +Only use the fallback shell for route/fallback tests: + +```powershell +$env:KRONOS_DASHBOARD_MODE = "ssr" +# or +$env:KRONOS_DASHBOARD_SSR_FALLBACK = "1" +``` + +Clear those variables for normal dashboard use. + +## Debugging snippets + +```powershell +curl -s http://127.0.0.1:5070/api/training/status | py -3.11 -c "import sys,json; print(json.load(sys.stdin))" +curl -s http://127.0.0.1:5070/ | findstr kronos-dashboard-shell +``` + +For chart issues, inspect DevTools Console and Network responses for the +corresponding `/api/*` endpoint before changing frontend logic. + +## Browser shortcuts + +- `Ctrl+R` - refresh +- `Ctrl+Shift+R` - hard refresh, bypass cache +- `F12` - DevTools diff --git a/docs/wiki/07-trial-and-error.md b/docs/wiki/07-trial-and-error.md new file mode 100644 index 000000000..bdbdd78c0 --- /dev/null +++ b/docs/wiki/07-trial-and-error.md @@ -0,0 +1,86 @@ +# 시행착오 기록 (Trial and Error) + +> 실패한 시도와 그 원인, 그리고 (있다면) 해결책. 같은 실수 두 번 안 하기 위한 기록. + +## 학습 (Finetune) + +### TE-01 · validation OOM (2026-05-14, commit 7742cb8) +- **증상**: tokenizer 단계 약 75% 진행 후 validation 진입 시 GPU VRAM 16 GiB 초과 → CUDA OOM crash +- **원인**: validation batch size 가 학습 batch size 와 동일하게 큼 +- **해결책 (사용자 채울 자리)**: + - 시도 1: (사용자 채울 자리) + - 시도 2: (사용자 채울 자리) +- **교훈**: validation 도 별도 batch size 설정 필요 — 학습보다 작게 (메모리 헤드룸 확보) +- **현재 상태**: tokenizer 49% 시점 결과는 보존됨 (commit `7742cb8`). 재학습 미진행 + +### TE-02 · (사용자가 다른 시행착오 추가) +- ... + +## 데이터 + +### TE-D1 · (사용자 작성 영역) +- 한국어 컬럼명 처리 관련 경험 +- ... + +## 대시보드 / 프론트엔드 + +### TE-F1 · `dist/` 글로벌 .gitignore 충돌 +- **증상**: `webui/static/v2/dist/` 디렉토리가 자동으로 git ignore 됨 +- **원인**: 프로젝트 루트 `.gitignore` 의 Python 패키징용 `dist/` 패턴이 Vite 빌드 산출물에도 매칭 +- **해결**: `.gitignore` 에 `!webui/static/v2/dist/` 와 `!webui/static/v2/dist/**` 명시 예외 추가 (commit `2695151`) +- **교훈**: 다국어 프로젝트 (Python + JS) 의 `.gitignore` 는 충돌 가능성 항상 확인 + +### TE-F2 · `lib/` 패턴이 Svelte src/lib 충돌 +- **증상**: `webui/v2_src/src/lib/*.ts` 가 자동으로 ignore 됨 → 첫 P1.5 commit 에 누락 +- **원인**: 동일 — 루트 `.gitignore` 의 Python `lib/` 패턴 +- **해결**: `git add -f` 또는 `.gitignore` 에 negation 추가 — 본 프로젝트는 후자 선택 +- **교훈**: Svelte/React 등 `src/lib/` 구조의 프론트엔드를 추가할 때 항상 git status 확인 + +### TE-F3 · Svelte 5 strict HTML 규칙 — `` 단독 사용 불가 +- **증상**: `` 안에 `` 바로 두면 svelte-check 가 오류 보고 +- **원인**: Svelte 5 의 HTML structure 검증이 엄격해짐 — 브라우저가 `` 를 자동 삽입하므로 명시 권장 +- **해결**: 모든 `` 을 `` 로 감쌈 +- **교훈**: Svelte 5 마이그레이션 시 HTML structure 검증 강화 — 명시적 wrapping 필요 + +### TE-F4 · ECharts theme 전환 시 색상 안 바뀜 +- **증상**: light → dark 토글해도 차트 색이 그대로 +- **원인**: ECharts 옵션이 한 번 setOption 된 후 CSS 변수 변화를 인지하지 못함 +- **해결**: `theme` store 변화 시 `$derived.by(() => { void currentTheme; getComputedStyle(...) })` 패턴으로 palette 재계산 강제 +- **교훈**: 차트 라이브러리는 일반적으로 CSS 변수와 무관 — 명시적 setOption 재호출 필요 + +### TE-F5 · ECharts loss curve x축에 빈 공간이 95% +- **증상**: 데이터가 step 2.4M~2.5M 구간에만 있는데 x축이 0~3M 까지 표시되어 차트 빈 공간만 보임 +- **원인**: ECharts xAxis 의 `scale` 옵션 기본값이 `false` 라 0부터 시작 +- **해결**: `xAxis: { scale: true, min: 'dataMin', max: 'dataMax' }` 명시 +- **교훈**: 학습 곡선처럼 일부 구간만 데이터 있을 때는 자동 스케일 명시 필수 + +### TE-F6 · KST 표시가 `-` 만 나옴 +- **증상**: `/api/training/status` 응답에 `eta_seconds=178976` 있는데 W2/W4 가 `-` 표시 +- **원인**: JS 가 `d.eta_seconds` (top-level) 를 읽었는데 실제로는 `d.latest_stage.eta_seconds` (중첩) +- **해결**: `const latest = d.latest_stage || {}` 후 `latest.eta_seconds` 읽음 (commit `4e54f45`) +- **교훈**: API 응답 구조를 정확히 검증 — top-level 가정 금지 + +## 빌드 / 도구 + +### TE-B1 · CSS @import 순서 경고 +- **증상**: Vite 빌드 시 `@import must precede all other statements` 경고 → CSS 크기가 작아짐 (38KB → 11KB) +- **원인**: `@tailwind` 디렉티브 뒤에 `@import` 작성 +- **해결**: `@import` 를 파일 최상단에 위치 +- **교훈**: PostCSS/Vite 처리 순서는 @import 가 항상 먼저 + +### TE-B2 · TypeScript strict 모드와 Svelte 5 reactive 충돌 +- **증상**: `$state()` + subscribe 패턴이 `noImplicitAny` 오류 +- **해결**: `tsconfig.json` 에 `strict: false`, `noImplicitAny: false` 설정 +- **교훈**: 초기 프로토타입은 strict 비활성화 → 안정화 후 점진 활성화 + +## 라우팅 / 백엔드 + +### TE-R1 · Flask Blueprint catch-all 라우트 위험 +- **증상**: 글로벌 `/` catch-all 추가 시 모든 `/api/*` 가 그쪽으로 매칭 +- **원인**: Flask 라우트 매칭은 가장 구체적인 것 우선이지만 catch-all 은 만능 매치 +- **해결**: catch-all 은 반드시 prefix 내부에만 (`/v2/` 처럼) +- **교훈**: Plan 의 명시적 금지 사항 §8 [NEW] 두 번째 — 글로벌 catch-all 절대 금지 + +--- + +*기여 환영. 시행착오를 만나면 즉시 기록 → 다음 사람(또는 미래의 자신)이 같은 실수 반복 안 함.* diff --git a/docs/wiki/08-setup.md b/docs/wiki/08-setup.md new file mode 100644 index 000000000..e73a95718 --- /dev/null +++ b/docs/wiki/08-setup.md @@ -0,0 +1,79 @@ +# Setup and Run Guide + +## Python environment + +```powershell +C:\Python\64\Python3119\python.exe --version # Python 3.11.x +cd D:\Chanil_Park\Project\Programming\Kronos +C:\Python\64\Python3119\python.exe -m pip install -r requirements.txt +``` + +## Frontend build + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos\webui\v2_src +npm ci --prefer-offline --no-audit --no-fund +npm run build +``` + +The build writes `webui/static/v2/dist/index.html` and generated assets. The +internal output path is retained for compatibility. + +## Daily dashboard run + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos +$env:KRONOS_WEBUI_PORT = "5070" +$env:KRONOS_WEBUI_HOST = "127.0.0.1" +$env:KRONOS_WEBUI_OPEN_BROWSER = "0" +C:\Python\64\Python3119\python.exe webui\run.py +``` + +Open: + +- `http://127.0.0.1:5070/` - official dashboard +- `http://127.0.0.1:5070/rl` - RL trading/evidence dashboard +- `http://127.0.0.1:5070/v1/` - legacy archive + +## Environment reference + +| Variable | Default | Recommended local value | +|---|---:|---:| +| `KRONOS_WEBUI_PORT` | `7070` | `5070` | +| `KRONOS_WEBUI_HOST` | `0.0.0.0` | `127.0.0.1` | +| `KRONOS_WEBUI_OPEN_BROWSER` | `1` | `0` for agent/test runs | + +Fallback-only knobs: + +```powershell +$env:KRONOS_DASHBOARD_MODE = "ssr" +# or +$env:KRONOS_DASHBOARD_SSR_FALLBACK = "1" +``` + +Do not set these for normal dashboard use. The built dist is the default when it +exists. + +## Verification + +```powershell +cd D:\Chanil_Park\Project\Programming\Kronos +py -3.11 -m pytest tests/test_v2_route.py tests/test_v2_dist_marker.py tests/test_v2_blueprint_isolation.py -q +curl -s http://127.0.0.1:5070/ | findstr kronos-dashboard-shell +``` + +## Workflow for dashboard source changes + +1. Edit `webui/v2_src/src/**`. +2. Run `cd webui/v2_src && npm run check && npm run build`. +3. Run targeted pytest for route/static marker coverage. +4. Commit source and generated dist together if committing. + +## Troubleshooting + +| Symptom | Check | +|---|---| +| `/` is 404 | Flask is running and dashboard blueprint registered. | +| Shell is stale | Rebuild dist and hard refresh browser (`Ctrl+Shift+R`). | +| Chart data missing | Check `/api/training/*` or `/api/rl/*` returns 200 JSON. | +| Need fallback shell | Use `KRONOS_DASHBOARD_MODE=ssr` for that test only. | diff --git a/docs/wiki/09-api-reference.md b/docs/wiki/09-api-reference.md new file mode 100644 index 000000000..ffef4be69 --- /dev/null +++ b/docs/wiki/09-api-reference.md @@ -0,0 +1,202 @@ +# API 레퍼런스 + +총 **26개 read-only 엔드포인트** (24개 기존 + 2개 docs 신규). 모든 응답은 JSON. + +## 학습 모니터링 (`/api/training/*`) + +### `GET /api/training/status` +실시간 학습 상태 — v2 SPA 가 5초마다 폴링. + +**응답 핵심 필드**: +```json +{ + "status": "running" | "completed" | "failed" | "idle", + "latest_stage": { + "train_stage": "tokenizer" | "predictor", + "step": 3833000, + "total_steps": 4701721, + "overall_percent": 40.76, + "stage_percent": 81.52, + "eta_seconds": 54545, + "samples_per_second": 63.71, + "updated_at": "2026-05-13T23:34:31Z" + }, + "readiness": { + "level": "waiting" | "training" | "ready", + "label": "성과 대기: tokenizer 학습 중", + "message": "..." + }, + "stages": [ ... ] +} +``` + +### `GET /api/training/history?limit=N` +손실 히스토리 + 메트릭. v2 SPA W3 손실 곡선이 사용. + +**응답 핵심**: `points[{step, loss, learning_rate, epoch}]`, `latest_point`, `latest_progress`, `run_name`, `stage`. + +### `GET /api/training/artifacts` +checkpoint/model_weight 파일 카운트와 예측기 진행 상태. + +**응답 핵심**: +```json +{ + "checkpoint_file_count": 0, + "model_weight_file_count": 0, + "predictor_started": false, + "checkpoint_ready": false, + "label": "checkpoint 대기", + "message": "...", + "recent_checkpoint_files": [], + "recent_model_weight_files": [], + "stages": { "tokenizer": {...}, "predictor": {...} } +} +``` + +### `GET /api/training/gpu` +nvidia-smi 실측 — v2 SPA 5초 폴링. + +**응답 핵심**: +```json +{ + "gpus": [{ + "name": "NVIDIA GeForce RTX 4080 SUPER", + "utilization_gpu_percent": 40.0, + "temperature_c": 50.0, + "memory_used_mib": 3219, + "memory_total_mib": 16376, + "memory_used_percent": 19.7, + "power_limit_watts": 320, + "power_draw_watts": null, + "power_draw_available": false + }], + "total_memory_used_percent": 19.7, + "generated_at": "...", + "available": true +} +``` + +### `GET /api/training/runs` +finetune/outputs/ 의 모든 run 목록. + +**응답**: `{runs: [{name, path, overall_percent, status, stage_count, updated_at, updated_at_epoch}]}` + +### `GET /api/training/logs?stage=tokenizer&tail=N` +학습 stdout 마지막 N줄. + +## STOM 진단 (`/api/stom/*`) + +### `GET /api/stom/summary` +STOM SQLite DB 요약. + +**응답**: `compatible_stock_table_count`, `db_size_bytes`, `eligible_group_count`, `estimated_samples`, `table_count`, `total_rows_stock_groups`, `warnings[]`. + +### `GET /api/stom/prediction-files` +예측 결과 CSV 파일 목록. + +### `GET /api/stom/qlib-backtests` +QLib 백테스트 결과 JSON 파일 목록. + +### `GET /api/stom/filter-reports` +필터 리포트 (filter_search 등) 파일 목록. + +### `GET /api/stom/prediction?file=` +특정 예측 파일 내용. + +### `GET /api/stom/diagnostics?file=` +진단 지표 (MAE/RMSE/MAPE 등). v2 SPA STOM 탭의 우측 패널이 사용. + +### `GET /api/stom/recommendations?date=YYYY-MM-DD` +특정 날짜의 top-k 추천 종목. + +### `GET /api/stom/backtest-report?file=` +백테스트 상세 (P&L, drawdown 등). + +### `GET /api/stom/recommendation-export` +추천 결과 CSV 다운로드. + +## 예측 모델 (`/api/*`) + +### `GET /api/available-models` +사용 가능한 사전학습 모델 카탈로그. + +**응답**: +```json +{ + "model_available": true, + "model_import_error": null, + "models": { + "kronos-base": { "name": "Kronos-base", "context_length": 512, "params": "102.3M", ... }, + "kronos-mini": { ... }, + "kronos-tiny": { ... } + } +} +``` + +### `GET /api/data-files` +사용 가능한 데이터 파일 목록. + +### `POST /api/load-model` +모델을 메모리에 로드. + +**요청 body**: `{model_key: string, device: "cpu" | "cuda"}` + +### `POST /api/load-data` +데이터 파일을 메모리에 로드. + +**요청 body**: `{file_path: string}` + +### `POST /api/predict` +예측 실행. + +**요청 body**: +```json +{ + "lookback": 400, + "pred_len": 120, + "temperature": 1.0, + "top_p": 0.9, + "n_samples": 1, + "seed": 42 | null, + "device": "cpu" | "cuda" +} +``` + +### `GET /api/model-status` +현재 로드된 모델 상태. + +## 문서 (`/api/docs/*`) — 신규 + +### `GET /api/docs/list` +docs/wiki/ 의 마크다운 파일 목록. + +**응답**: +```json +{ + "available": true, + "docs": [ + {"slug": "00-index", "name": "00-index.md", "title": "Kronos Wiki — 통합 지식 베이스", "size_bytes": 1234, "modified_at": 1778000000, "order": 0}, + ... + ], + "root": "D:\\...\\docs\\wiki" +} +``` + +### `GET /api/docs/read?slug=` +특정 마크다운 파일 내용. + +**응답**: `{slug, name, content, size_bytes, modified_at}` + +**보안**: path traversal 방지 — `..`, `/`, `\`, `:` 차단 + abspath 가 docs/wiki/ 외부면 거부. + +## 공통 규칙 + +- 모든 엔드포인트는 **read-only**: GET, POST 는 데이터 가공/예측 트리거 (서버 상태 영구 변경 0) +- 학습 데이터/모델 파일을 수정/삭제하는 엔드포인트는 **없음** +- 에러 시: `{error: string}` + HTTP 400/404/500 +- 응답 시간: 보통 < 100ms (history 큰 N 제외) + +## 관련 문서 + +- [02-architecture](02-architecture) — 데이터 흐름 +- [10-dashboard-guide](10-dashboard-guide) — 어떤 탭이 어떤 API 를 쓰는지 diff --git a/docs/wiki/10-dashboard-guide.md b/docs/wiki/10-dashboard-guide.md new file mode 100644 index 000000000..c6215115b --- /dev/null +++ b/docs/wiki/10-dashboard-guide.md @@ -0,0 +1,84 @@ +# Official Dashboard Guide + +The official Kronos dashboard is served at `/`. The RL trading/evidence view is +served at `/rl`. Old versioned/lab URLs are kept only as redirects so existing +bookmarks do not break. + +## Main areas + +### Live Training + +- Stage-aware training progress and ETA. +- Loss curve and training-health cards. +- GPU utilization, temperature, and VRAM telemetry. +- APIs: `/api/training/{status,history,gpu,artifacts}`. + +### Forecast Workbench + +- Model/data selection for local inference experiments. +- Lookback, prediction length, temperature, and top-p controls. +- ECharts comparison of input, forecast, and observed series. +- APIs: `/api/{available-models,data-files,load-model,load-data,predict}`. + +### STOM Diagnostics + +- STOM data summary and prediction/backtest artifact browsers. +- Diagnostics panels for selected prediction files. +- APIs: `/api/stom/*`. + +### RL Trading / Evidence + +- Read-only RL/run artifact explorer. +- Must preserve `RULE MAINLINE`, `RL EXPERIMENT`, `NO-GO`, cost, split, seed, + baseline, and not-live-ready labels. +- Equity/reward curves are evidence views. They are not live-profit claims. +- APIs: `/api/rl/*`. + +### Artifacts & Models + +- Recent checkpoints, pretrained model weights, and predictor artifacts. +- API: `/api/training/artifacts`. + +### History & Runs + +- Training run list, status filters, and run cards. +- API: `/api/training/runs`. + +### System Health + +- GPU/CPU/RAM telemetry and polling status. +- APIs: `/api/training/gpu`, `/api/training/system`. + +### Settings + +- Theme, sidebar, refresh interval, and browser notification settings. +- Settings are client-local and do not modify model/trading state. + +### Docs + +- Reads Markdown from `docs/wiki/` through `/api/docs/{list,read}`. +- Editing a wiki file and refreshing the dashboard is enough for local docs. + +## Navigation and routes + +| Route | Behavior | +|---|---| +| `/` | Official dashboard | +| `/rl` | RL Trading / Evidence tab | +| `/training`, `/dashboard` | Training dashboard bookmarks | +| `/v2`, `/v2/` | Redirect to `/` | +| `/v2/rl-trading`, `/v2/rl-lab`, `/rl-lab` | Redirect to `/rl` | + +## Browser shortcuts + +| Key | Action | +|---|---| +| `Ctrl+R` | Refresh | +| `Ctrl+Shift+R` | Hard refresh, bypassing cache | +| `F12` | DevTools | + +## Guardrails + +- Do not hide failed episodes, skipped trades, costs, or baseline overlays. +- Do not label rule baselines as reinforcement learning. +- Do not claim live trading readiness or profitability from dashboard visuals. diff --git a/docs/wiki/11-reinforcement-learning.md b/docs/wiki/11-reinforcement-learning.md new file mode 100644 index 000000000..6b726cc96 --- /dev/null +++ b/docs/wiki/11-reinforcement-learning.md @@ -0,0 +1,137 @@ +# 강화학습 실험실 — 공부용 가이드 + +`stom_rl/` 에 실제 구현된 강화학습(RL)을 비전공자도 이해할 수 있게 정리한 자료입니다. 모든 설명은 실제 코드 기준이며, 함께 제공되는 `docs/stom_rl_study_guide.html` 을 브라우저로 열면 같은 내용을 더 보기 좋게 볼 수 있습니다. + +> 한 줄 요약: 1초봉 차트 위에서 에이전트가 **사라 / 팔라 / 기다려라**를 반복하며, **비용을 뺀 수익(=점수)** 을 최대화하는 매매 습관을 스스로 학습합니다. + +--- + +## 1. 강화학습이란? (비유) + +강화학습은 **같은 게임을 수천 번 다시 하며 고득점 비법을 익히는 것**과 같습니다. + +- **게임판** = 한 종목의 하루치 1초봉 차트 +- **플레이어** = 에이전트(신경망) +- **조작 버튼** = 사라(buy) / 팔라(sell) / 기다려라(hold) +- **점수** = 거래비용(25bp)을 뺀 수익률 + +처음엔 버튼을 거의 무작위로 누르지만, 점수가 높았던 행동 패턴을 점점 강화합니다. + +--- + +## 2. 핵심 4요소 — MDP(마르코프 결정 과정) + +모든 강화학습은 **상태 → 행동 → 보상 → 다음 상태**의 반복입니다. 실제 매핑(`stom_rl/trading_env.py`): + +| 요소 | 실제 코드 | +|---|---| +| **상태 State** | 최근 `lookback_window=300`초 × 9개 feature 행렬. **미래 정보는 절대 관측에 안 들어감**(lookahead 차단). | +| **행동 Action** | `Discrete(3)`: `0=hold`, `1=buy`, `2=sell`. 보유 중 매수·미보유 중 매도는 `invalid_action` → 패널티 `-0.001`. | +| **보상 Reward** | 보유 중이면 300초 뒤 수익률, 청산 시 실현 수익률, 거래마다 25bp 비용 차감. | +| **전이 Transition** | `current_idx += 1` (1초 전진). 마지막 horizon 확보 지점에서 episode 종료. | + +**에피소드(1판)** = 한 종목의 하루치(세션) 1초봉 시퀀스. 데이터는 train 13,256 / val 2,764 / test 2,730판으로 분리. + +--- + +## 3. 어떤 변수를 쓰나? (중요) + +자주 받는 질문: **"DB의 모든 변수를 다 쓰나요?"** → **아니요. 9개만 씁니다.** + +### 실제 쓰는 9개 + +- **시장 6개**: `open`, `high`, `low`, `close`, `volume`(거래량), `amount`(거래대금) +- **파생 3개**: `position`(보유 여부), `unrealized_return`(평가손익), `time_in_position`(보유 경과) + +### 안 쓰는 것 + +- 원본 CSV에 있지만 버리는 컬럼: `money`, `factor` +- STOM 틱DB의 **호가 / 체결강도 / 주문북** — episode CSV에 애초에 없음 +- **Kronos 모델의 예측값/피처** — 이 RL은 "Kronos 비의존"이라 전혀 안 씀 +- 기술적 지표(이동평균·RSI 등) — 미적용 + +### 원본 qlib CSV(10개 컬럼) 사용 현황 + +| 컬럼 | 사용? | 용도 | +|---|---|---| +| `open/high/low/close` | ✅ 사용 | 관측 feature (가격) | +| `volume` | ✅ 사용 | 관측 feature (거래량) | +| `amount` | ✅ 사용 | 관측 feature (거래대금) | +| `date` | 간접 | 시간 인덱싱·기간 분할용 (feature 아님) | +| `symbol` | 간접 | 종목 식별용 (feature 아님) | +| `money` | ❌ 미사용 | — | +| `factor` | ❌ 미사용 | — | + +> **왜 이렇게 적게 쓰나?** env 주석에 명시돼 있듯 "첫 RL 단계에서는 의도적으로 의존성을 가볍게" 설계했기 때문입니다. OHLCV만 쓰는 최소 에이전트이며, 이는 7장의 성능 한계와 직접 연결됩니다. + +--- + +## 4. 보상은 어떻게 주나? (`reward_mode="horizon"`) + +핵심 아이디어: **"지금 들고 있으면 5분(300초) 뒤에 이득일까?"** 를 보상으로 줍니다. + +| 상황 | 보상 | +|---|---| +| 보유 중 (계속 hold) | 300초 뒤 forward 수익률 `(close[t+300]-close[t])/close[t]` | +| 매도(청산) | 실현 수익률 (산 가격 대비) | +| 거래 발생 시 | **비용 차감** `-25bp` (= 0.25%) | +| 잘못된 행동 | 패널티 `-0.001` | + +**비유 — 우산 장수**: "지금 우산을 들고 있으면 5분 뒤 비가 와서 이득일까?"를 매 순간 점수로 환산. 비용(25bp)은 살 때·팔 때마다 까이므로, 너무 자주 사고팔면 비용에 갉아먹힙니다. → 정책은 사실상 **단기(5분) 추세 추종**을 학습합니다. + +--- + +## 5. 두 알고리즘 — DQN vs PPO + +둘 다 Stable-Baselines3 표준 구현, 신경망은 작은 `MLP [64, 32]`. + +| | DQN (가치 기반) | PPO (정책 기반) | +|---|---|---| +| 핵심 | 각 버튼의 **미래 가치**를 점수로 매겨 1등 선택 | 버튼 누를 **확률 자체**를 안전하게 다듬기 | +| 특징 | 경험 재생 버퍼, 타깃 네트워크, ε-greedy 탐험(40%→5%) | actor-critic, clipped objective, on-policy | + +**비유**: DQN은 메뉴마다 "예상 만족도"를 매겨 1등을 고르는 사람, PPO는 "이 메뉴를 시킬 확률"을 조금씩 조정하는 사람. + +--- + +## 6. 전체 프로세스 + +``` +① episode_manifest 1초봉 → 종목-세션 판으로 분할 (train/val/test) +② trading_env 위 MDP 정의 (상태/행동/보상/전이) +③ sb3_adapter Gymnasium 래퍼 → SB3 호환 +④ sb3_smoke check_env → DQN/PPO 학습 → 모델 저장 → 평가 → live event(JSONL) +⑤ leaderboard no-trade·buy-and-hold 기준선과 같은 비용 기준으로 비교 +⑥ eval-only / walk_forward 재학습 없이 episode·기간별 재검증 +``` + +- **학습**: `model.learn(total_timesteps)` → `{algo}_model.zip` 저장 +- **평가**: test 판들에서 **탐험 없이(deterministic)** 실행, 거래·자산곡선 기록 +- **실시간 화면**: step·action·reward·equity를 `rl_live_events.jsonl`로 남겨 대시보드에서 재생 + +--- + +## 7. 솔직한 한계 (꼭 알아야 할 것) + +이 RL은 아직 "실전 모델"이 아니라 **"실전 후보"** 입니다. 검증에서 드러난 점: + +- **학습량 확대가 무효** — 50k→100k로 늘려도 성능이 오히려 약간 하락(공정 100판 비교: dqn 50k +1.34% vs 100k +1.18%). +- **기간 민감(regime sensitive)** — 6개 기간 분할 walk-forward 결과 좋은 구간과 나쁜 구간이 섞였고, **가장 최근 구간(12/22~12/30)은 손실**. 종합 +1.34%는 좋은 초반이 약한 최근을 가린 결과. +- **정보가 9개뿐** — 호가·체결강도·Kronos 예측 등 풍부한 변수를 안 써서 학습 가능한 패턴이 제한적. +- **낙폭(MDD)이 큼** — 특정 기간 -38%까지. 실전 전 **위험관리 gate**가 필수. + +> 결론: 흥미로운 후보지만, 더 많은 변수·기간 검증·위험 차단 장치 없이 실거래에 쓰면 안 됩니다. + +--- + +## 8. 자가 점검 퀴즈 + +1. **버튼은 몇 개?** → 3개(hold/buy/sell). 무효 행동은 패널티. +2. **보상 핵심?** → "보유 중이면 300초 뒤 수익률" + 거래마다 25bp 비용 차감 → 단기 추세 추종. +3. **DB 모든 변수를 쓰나?** → 아니다. 9개(시장 6 + 파생 3)만. `money·factor`·호가·Kronos 예측은 미사용. +4. **DQN vs PPO?** → DQN은 행동 "가치"로 최고 선택(가치 기반), PPO는 행동 "확률"을 안전하게 조정(정책 기반). +5. **왜 100k가 50k를 못 이겼나?** → 적은 정보·작은 모델·짧은 horizon에서 평탄화 + 단일 split 반복의 기간 의존/경미한 과적합. 학습량만 늘리는 건 해법이 아니다. + +--- + +*소스: `stom_rl/trading_env.py`, `sb3_smoke.py`, `sb3_adapter.py`, `walk_forward.py` · 대시보드: `http://127.0.0.1:5070/rl`* diff --git a/docs/wiki/12-portfolio-rl-roadmap.md b/docs/wiki/12-portfolio-rl-roadmap.md new file mode 100644 index 000000000..994b59520 --- /dev/null +++ b/docs/wiki/12-portfolio-rl-roadmap.md @@ -0,0 +1,43 @@ +# 포트폴리오 RL 로드맵 — 조건식 기반 다종목 매매 + +> 목표: 단일 종목 OHLCV 강화학습을, **조건식으로 후보를 거른 뒤 자본금으로 여러 종목을 동시에 사고팔며 총자산(NAV)을 키우는 포트폴리오 RL**로 확장한다. +> 전체 설계·핸드오프 문서: `docs/stom_rl_portfolio_design_handoff_2026-05-25.md` +> 조건식 변수·문법 레퍼런스: `docs/reference/stom_ai_agent/` + +## 지금 vs 목표 + +| 항목 | 현재 | 목표 | +|---|---|---| +| 종목 | 1개씩 | 여러 개 동시 | +| 포지션 | 0/1 (전액) | 비중/사이징 | +| 자본금 | 없음(정규화 1.0) | 초기 자본금 → NAV | +| 변수 | 9개(OHLCV+파생) | 호가·체결강도·수급 추가 | +| 후보 선정 | 없음 | **조건식 필터** | +| 보상 | episode 수익률 | 총자산 성장 | + +## 핵심 통찰 + +- **조건식 = 종목 universe 필터**: STOM 매수 조건식이 "지금 살 만한 후보"를 골라주므로, RL은 수천 종목이 아니라 **후보 중에서만** 선택하면 된다(행동공간 축소). +- **매도전용 변수(보유시간·수익률)** 가 이미 있어 "시간 조절/동적청산"을 RL에 그대로 녹일 수 있다. +- STOM DB(`_database/stock_tick_back.db`)는 종목별 ~50개 변수(호가1~5·잔량1~5·체결강도·초당매수/매도 등)를 갖지만, 현재 export는 OHLCV+거래대금만 추린다 → 확장 여지 큼. + +## 단계 로드맵 + +| 단계 | 내용 | 난이도 | 순서 | +|:--:|---|:--:|:--:| +| 1 | **feature 확장**(호가불균형·체결강도·수급) | 낮음 | 지금 | +| 2 | 자본금 + 포지션 사이징 | 중 | 다음 | +| 3 | 조건식 후보 스크리너(DB 적용, AST 화이트리스트) | 중 | 3 | +| 4 | **PortfolioEnv + RL**(다종목·NAV 보상) | 높음 | 4 | +| 5 | walk-forward 검증 | 중 | 5 | +| 6 | risk gate + paper replay | 중 | 마지막 | + +## 권장 + +한 번에 포트폴리오로 점프하지 말 것. 현재 단일 종목조차 정보부족(9변수)·기간민감이므로, **단계 1(feature 확장)부터 신호를 강화한 뒤 4(PortfolioEnv)로 확장**한다. + +- **단계별 상세 실행 계획**(신규/수정 파일·작업 체크리스트·완료 기준·테스트·위험): 핸드오프 문서 **10장 부록 A** 참조. +- 명령·제약·새 대화 이어가기 프롬프트: 핸드오프 문서 **7장** 참조. + +### 단계 1 추가 feature (목표 +8, 총 ~17개) +호가불균형, 호가스프레드, 체결강도, 체결강도평균대비, 초당매수수량, 초당매도수량, 순매수수량, 거래대금각도. diff --git a/finetune/AGENTS.md b/finetune/AGENTS.md new file mode 100644 index 000000000..09ebe1ef8 --- /dev/null +++ b/finetune/AGENTS.md @@ -0,0 +1,43 @@ +# finetune Knowledge + +## Overview + +`finetune/` contains STOM/Qlib export, predictor/tokenizer training, checkpoint +evaluation, and staged full-training scripts. + +## Key Files + +| File | Role | +|---|---| +| `qlib_stom_pipeline.py` | Large STOM-to-Qlib pipeline. | +| `run_stom_1s_finetune.py` | Main STOM 1-second finetune runner. | +| `train_tokenizer.py` | Tokenizer training. | +| `train_predictor.py` | Predictor training. | +| `evaluate_stom_1s_checkpoint.py` | Checkpoint evaluation. | +| `preflight_stom_2025_full.py` | Full-training preflight. | +| `stom_rl_c0_feature_probe.py` | Feature probe feeding RL research. | + +## Rules + +- Separate source code from generated outputs/checkpoints. +- Keep CLI import paths working from repository root. +- Treat GPU/CUDA/torch failures as environment-sensitive until isolated. +- Avoid mixing training-result claims with trading-strategy claims. +- Document any data split, prediction horizon, and cost assumption in outputs. +- Do not overwrite large checkpoints or exported datasets unless explicitly asked. +- Keep stock-code strings zero-padded through export and training metadata. +- Prefer resumable/checkpointed long jobs; record command and output path in docs. + +## Verification + +```powershell +py -3.11 -m pytest tests/test_cli_import_paths.py tests/test_stom_1s_finetune_runner.py -q +``` + +Torch-heavy tests may be opt-in or environment-sensitive on Windows. + +## Gotchas + +- `finetune/outputs/` and nested checkpoint folders are generated artifacts. +- Qlib import paths can fail from the wrong working directory; test CLI imports + after moving runners. diff --git a/finetune/config.py b/finetune/config.py index 04cc3ee31..0f00769ed 100644 --- a/finetune/config.py +++ b/finetune/config.py @@ -1,131 +1,215 @@ -import os - -class Config: - """ - Configuration class for the entire project. - """ - - def __init__(self): - # ================================================================= - # Data & Feature Parameters - # ================================================================= - # TODO: Update this path to your Qlib data directory. - self.qlib_data_path = "~/.qlib/qlib_data/cn_data" - self.instrument = 'csi300' - - # Overall time range for data loading from Qlib. - self.dataset_begin_time = "2011-01-01" - self.dataset_end_time = '2025-06-05' - - # Sliding window parameters for creating samples. - self.lookback_window = 90 # Number of past time steps for input. - self.predict_window = 10 # Number of future time steps for prediction. - self.max_context = 512 # Maximum context length for the model. - - # Features to be used from the raw data. - self.feature_list = ['open', 'high', 'low', 'close', 'vol', 'amt'] - # Time-based features to be generated. - self.time_feature_list = ['minute', 'hour', 'weekday', 'day', 'month'] - - # ================================================================= - # Dataset Splitting & Paths - # ================================================================= - # Note: The validation/test set starts earlier than the training/validation set ends - # to account for the `lookback_window`. - self.train_time_range = ["2011-01-01", "2022-12-31"] - self.val_time_range = ["2022-09-01", "2024-06-30"] - self.test_time_range = ["2024-04-01", "2025-06-05"] - self.backtest_time_range = ["2024-07-01", "2025-06-05"] - - # TODO: Directory to save the processed, pickled datasets. - self.dataset_path = "./data/processed_datasets" - - # ================================================================= - # Training Hyperparameters - # ================================================================= - self.clip = 5.0 # Clipping value for normalized data to prevent outliers. - - self.epochs = 30 - self.log_interval = 100 # Log training status every N batches. - self.batch_size = 50 # Batch size per GPU. - - # Number of samples to draw for one "epoch" of training/validation. - # This is useful for large datasets where a true epoch is too long. - self.n_train_iter = 2000 * self.batch_size - self.n_val_iter = 400 * self.batch_size - - # Learning rates for different model components. - self.tokenizer_learning_rate = 2e-4 - self.predictor_learning_rate = 4e-5 - - # Gradient accumulation to simulate a larger batch size. - self.accumulation_steps = 1 - - # AdamW optimizer parameters. - self.adam_beta1 = 0.9 - self.adam_beta2 = 0.95 - self.adam_weight_decay = 0.1 - - # Miscellaneous - self.seed = 100 # Global random seed for reproducibility. - - # ================================================================= - # Experiment Logging & Saving - # ================================================================= - self.use_comet = True # Set to False if you don't want to use Comet ML - self.comet_config = { - # It is highly recommended to load secrets from environment variables - # for security purposes. Example: os.getenv("COMET_API_KEY") - "api_key": "YOUR_COMET_API_KEY", - "project_name": "Kronos-Finetune-Demo", - "workspace": "your_comet_workspace" # TODO: Change to your Comet ML workspace name - } - self.comet_tag = 'finetune_demo' - self.comet_name = 'finetune_demo' - - # Base directory for saving model checkpoints and results. - # Using a general 'outputs' directory is a common practice. - self.save_path = "./outputs/models" - self.tokenizer_save_folder_name = 'finetune_tokenizer_demo' - self.predictor_save_folder_name = 'finetune_predictor_demo' - self.backtest_save_folder_name = 'finetune_backtest_demo' - - # Path for backtesting results. - self.backtest_result_path = "./outputs/backtest_results" - - # ================================================================= - # Model & Checkpoint Paths - # ================================================================= - # TODO: Update these paths to your pretrained model locations. - # These can be local paths or Hugging Face Hub model identifiers. - self.pretrained_tokenizer_path = "path/to/your/Kronos-Tokenizer-base" - self.pretrained_predictor_path = "path/to/your/Kronos-small" - - # Paths to the fine-tuned models, derived from the save_path. - # These will be generated automatically during training. - self.finetuned_tokenizer_path = f"{self.save_path}/{self.tokenizer_save_folder_name}/checkpoints/best_model" - self.finetuned_predictor_path = f"{self.save_path}/{self.predictor_save_folder_name}/checkpoints/best_model" - - # ================================================================= - # Backtesting Parameters - # ================================================================= - self.backtest_n_symbol_hold = 50 # Number of symbols to hold in the portfolio. - self.backtest_n_symbol_drop = 5 # Number of symbols to drop from the pool. - self.backtest_hold_thresh = 5 # Minimum holding period for a stock. - self.inference_T = 0.6 - self.inference_top_p = 0.9 - self.inference_top_k = 0 - self.inference_sample_count = 5 - self.backtest_batch_size = 1000 - self.backtest_benchmark = self._set_benchmark(self.instrument) - - def _set_benchmark(self, instrument): - dt_benchmark = { - 'csi800': "SH000906", - 'csi1000': "SH000852", - 'csi300': "SH000300", - } - if instrument in dt_benchmark: - return dt_benchmark[instrument] - else: - raise ValueError(f"Benchmark not defined for instrument: {instrument}") +import os + + +def _env_int(name, default): + value = os.getenv(name) + return default if value in (None, "") else int(value) + + +def _env_float(name, default): + value = os.getenv(name) + return default if value in (None, "") else float(value) + + +def _env_bool(name, default): + value = os.getenv(name) + if value in (None, ""): + return default + return value.lower() not in {"0", "false", "no", "off"} + +class Config: + """ + Configuration class for the entire project. + """ + + def __init__(self): + # ================================================================= + # Data & Feature Parameters + # ================================================================= + # TODO: Update this path to your Qlib data directory. + self.qlib_data_path = os.getenv("KRONOS_QLIB_DATA_PATH", "~/.qlib/qlib_data/cn_data") + self.instrument = os.getenv("KRONOS_QLIB_INSTRUMENT", 'csi300') + + # Overall time range for data loading from Qlib. + self.dataset_begin_time = os.getenv("KRONOS_DATASET_BEGIN_TIME", "2011-01-01") + self.dataset_end_time = os.getenv("KRONOS_DATASET_END_TIME", '2025-06-05') + + # Sliding window parameters for creating samples. + self.lookback_window = _env_int("KRONOS_LOOKBACK_WINDOW", 90) # Number of past time steps for input. + self.predict_window = _env_int("KRONOS_PREDICT_WINDOW", 10) # Number of future time steps for prediction. + self.max_context = _env_int("KRONOS_MAX_CONTEXT", 512) # Maximum context length for the model. + + # Features to be used from the raw data. + self.feature_list = ['open', 'high', 'low', 'close', 'vol', 'amt'] + # Time-based features to be generated. + self.time_feature_list = ['minute', 'hour', 'weekday', 'day', 'month'] + + # ================================================================= + # Dataset Splitting & Paths + # ================================================================= + # Note: The validation/test set starts earlier than the training/validation set ends + # to account for the `lookback_window`. + self.train_time_range = [ + os.getenv("KRONOS_TRAIN_START", "2011-01-01"), + os.getenv("KRONOS_TRAIN_END", "2022-12-31"), + ] + self.val_time_range = [ + os.getenv("KRONOS_VAL_START", "2022-09-01"), + os.getenv("KRONOS_VAL_END", "2024-06-30"), + ] + self.test_time_range = [ + os.getenv("KRONOS_TEST_START", "2024-04-01"), + os.getenv("KRONOS_TEST_END", "2025-06-05"), + ] + self.backtest_time_range = [ + os.getenv("KRONOS_BACKTEST_START", "2024-07-01"), + os.getenv("KRONOS_BACKTEST_END", "2025-06-05"), + ] + + # TODO: Directory to save the processed, pickled datasets. + self.dataset_path = os.getenv("KRONOS_DATASET_PATH", "./data/processed_datasets") + + # ================================================================= + # Training Hyperparameters + # ================================================================= + self.clip = _env_float("KRONOS_CLIP", 5.0) # Clipping value for normalized data to prevent outliers. + + self.epochs = _env_int("KRONOS_EPOCHS", 30) + self.log_interval = _env_int("KRONOS_LOG_INTERVAL", 100) # Log training status every N batches. + self.batch_size = _env_int("KRONOS_BATCH_SIZE", 50) # Batch size per GPU. + self.tokenizer_validation_batch_size = _env_int( + "KRONOS_TOKENIZER_VAL_BATCH_SIZE", + self.batch_size, + ) + self.num_workers = _env_int("KRONOS_NUM_WORKERS", 2) + self.dataset_sample_mode = os.getenv("KRONOS_DATASET_SAMPLE_MODE", "sample_random") + + # ================================================================= + # GPU 최대 활용 최적화 (모두 opt-in — default 는 기존 동작 유지) + # ================================================================= + # DataLoader: persistent_workers 와 prefetch_factor 는 num_workers > 0 일 때만 효과. + self.persistent_workers = _env_bool("KRONOS_PERSISTENT_WORKERS", False) + self.prefetch_factor = _env_int("KRONOS_PREFETCH_FACTOR", 2) + + # Mixed precision (AMP): bf16 권장 (4080 SUPER 가속 + GradScaler 불필요). + # KRONOS_TOKENIZER_AMP=1 + KRONOS_TOKENIZER_AMP_DTYPE=bf16 으로 활성화. + self.tokenizer_enable_amp = _env_bool("KRONOS_TOKENIZER_AMP", False) + self.tokenizer_amp_dtype = os.getenv("KRONOS_TOKENIZER_AMP_DTYPE", "bf16") # bf16 / fp16 / fp32 + + # torch.compile: rotary attention 등 비표준 연산이 trace 실패할 수 있어 opt-in. + # 활성화 시 첫 epoch 컴파일 오버헤드 발생 — 학습 길수록 ROI 큼. + self.tokenizer_enable_compile = _env_bool("KRONOS_TOKENIZER_COMPILE", False) + self.tokenizer_compile_mode = os.getenv("KRONOS_TOKENIZER_COMPILE_MODE", "reduce-overhead") + self.tokenizer_compile_fullgraph = _env_bool("KRONOS_TOKENIZER_COMPILE_FULLGRAPH", False) + + # Number of samples to draw for one "epoch" of training/validation. + # This is useful for large datasets where a true epoch is too long. + self.n_train_iter = _env_int("KRONOS_N_TRAIN_ITER", 2000 * self.batch_size) + self.n_val_iter = _env_int("KRONOS_N_VAL_ITER", 400 * self.batch_size) + + # Learning rates for different model components. + self.tokenizer_learning_rate = _env_float("KRONOS_TOKENIZER_LR", 2e-4) + self.predictor_learning_rate = _env_float("KRONOS_PREDICTOR_LR", 4e-5) + + # Gradient accumulation to simulate a larger batch size. + self.accumulation_steps = _env_int("KRONOS_ACCUMULATION_STEPS", 1) + + # Tokenizer long-run safety. STOM full-window runs can spend days in + # training before validation starts, so preserve the trained weights + # before validation and release CUDA cache aggressively. + self.tokenizer_save_pre_validation_checkpoint = _env_bool( + "KRONOS_TOKENIZER_SAVE_PRE_VAL_CHECKPOINT", + True, + ) + self.tokenizer_pre_validation_checkpoint_name = os.getenv( + "KRONOS_TOKENIZER_PRE_VAL_CHECKPOINT_NAME", + "latest_train_model", + ) + self.tokenizer_empty_cache_before_validation = _env_bool( + "KRONOS_TOKENIZER_EMPTY_CACHE_BEFORE_VAL", + True, + ) + self.tokenizer_validation_log_interval = _env_int( + "KRONOS_TOKENIZER_VAL_LOG_INTERVAL", + max(1, self.log_interval), + ) + # 0 means "validate the full validation set". Non-zero values are a + # recovery/smoke-test guardrail for very large STOM validation pools. + self.tokenizer_validation_max_steps = _env_int("KRONOS_TOKENIZER_VAL_MAX_STEPS", 0) + + # AdamW optimizer parameters. + self.adam_beta1 = 0.9 + self.adam_beta2 = 0.95 + self.adam_weight_decay = 0.1 + + # Miscellaneous + self.seed = _env_int("KRONOS_SEED", 100) # Global random seed for reproducibility. + + # ================================================================= + # Experiment Logging & Saving + # ================================================================= + self.use_comet = _env_bool("KRONOS_USE_COMET", True) # Set to False if you don't want to use Comet ML + self.comet_config = { + # It is highly recommended to load secrets from environment variables + # for security purposes. Example: os.getenv("COMET_API_KEY") + "api_key": "YOUR_COMET_API_KEY", + "project_name": "Kronos-Finetune-Demo", + "workspace": "your_comet_workspace" # TODO: Change to your Comet ML workspace name + } + self.comet_tag = 'finetune_demo' + self.comet_name = 'finetune_demo' + + # Base directory for saving model checkpoints and results. + # Using a general 'outputs' directory is a common practice. + self.save_path = os.getenv("KRONOS_SAVE_PATH", "./outputs/models") + self.tokenizer_save_folder_name = os.getenv("KRONOS_TOKENIZER_SAVE_FOLDER", 'finetune_tokenizer_demo') + self.predictor_save_folder_name = os.getenv("KRONOS_PREDICTOR_SAVE_FOLDER", 'finetune_predictor_demo') + self.backtest_save_folder_name = os.getenv("KRONOS_BACKTEST_SAVE_FOLDER", 'finetune_backtest_demo') + + # Path for backtesting results. + self.backtest_result_path = os.getenv("KRONOS_BACKTEST_RESULT_PATH", "./outputs/backtest_results") + + # ================================================================= + # Model & Checkpoint Paths + # ================================================================= + # TODO: Update these paths to your pretrained model locations. + # These can be local paths or Hugging Face Hub model identifiers. + self.pretrained_tokenizer_path = os.getenv("KRONOS_PRETRAINED_TOKENIZER_PATH", "path/to/your/Kronos-Tokenizer-base") + self.pretrained_predictor_path = os.getenv("KRONOS_PRETRAINED_PREDICTOR_PATH", "path/to/your/Kronos-small") + + # Paths to the fine-tuned models, derived from the save_path. + # These will be generated automatically during training. + self.finetuned_tokenizer_path = os.getenv( + "KRONOS_FINETUNED_TOKENIZER_PATH", + f"{self.save_path}/{self.tokenizer_save_folder_name}/checkpoints/best_model", + ) + self.finetuned_predictor_path = os.getenv( + "KRONOS_FINETUNED_PREDICTOR_PATH", + f"{self.save_path}/{self.predictor_save_folder_name}/checkpoints/best_model", + ) + + # ================================================================= + # Backtesting Parameters + # ================================================================= + self.backtest_n_symbol_hold = 50 # Number of symbols to hold in the portfolio. + self.backtest_n_symbol_drop = 5 # Number of symbols to drop from the pool. + self.backtest_hold_thresh = 5 # Minimum holding period for a stock. + self.inference_T = 0.6 + self.inference_top_p = 0.9 + self.inference_top_k = 0 + self.inference_sample_count = 5 + self.backtest_batch_size = 1000 + self.backtest_benchmark = self._set_benchmark(self.instrument) + + def _set_benchmark(self, instrument): + explicit = os.getenv("KRONOS_BACKTEST_BENCHMARK") + if explicit: + return explicit + dt_benchmark = { + 'csi800': "SH000906", + 'csi1000': "SH000852", + 'csi300': "SH000300", + } + if instrument in dt_benchmark: + return dt_benchmark[instrument] + return instrument diff --git a/finetune/dataset.py b/finetune/dataset.py index ae4f7242b..c1d8bf47c 100644 --- a/finetune/dataset.py +++ b/finetune/dataset.py @@ -44,11 +44,12 @@ def __init__(self, data_type: str = 'train'): self.window = self.config.lookback_window + self.config.predict_window + 1 self.symbols = list(self.data.keys()) - self.feature_list = self.config.feature_list - self.time_feature_list = self.config.time_feature_list - - # Pre-compute all possible (symbol, start_index) pairs. - self.indices = [] + self.feature_list = self.config.feature_list + self.time_feature_list = self.config.time_feature_list + self.sample_mode = self.config.dataset_sample_mode + + # Pre-compute all possible (symbol, start_index) pairs. + self.indices = [] print(f"[{data_type.upper()}] Pre-computing sample indices...") for symbol in self.symbols: df = self.data[symbol].reset_index() @@ -69,10 +70,17 @@ def __init__(self, data_type: str = 'train'): for i in range(num_samples): self.indices.append((symbol, i)) - # The effective dataset size is the minimum of the configured iterations - # and the total number of available samples. - self.n_samples = min(self.n_samples, len(self.indices)) - print(f"[{data_type.upper()}] Found {len(self.indices)} possible samples. Using {self.n_samples} per epoch.") + if self.sample_mode not in {"sample_random", "full_sequential"}: + raise ValueError("KRONOS_DATASET_SAMPLE_MODE must be 'sample_random' or 'full_sequential'") + + # sample_random keeps the original Kronos demo behavior. full_sequential + # makes idx authoritative, which is required before claiming an exact + # full-window epoch. + self.n_samples = min(self.n_samples, len(self.indices)) + print( + f"[{data_type.upper()}] Found {len(self.indices)} possible samples. " + f"Using {self.n_samples} per epoch. sample_mode={self.sample_mode}" + ) def set_epoch_seed(self, epoch: int): """ @@ -91,9 +99,12 @@ def __len__(self) -> int: def __getitem__(self, idx: int) -> tuple[torch.Tensor, torch.Tensor]: - # Select a random sample from the entire pool of indices. - random_idx = self.py_rng.randint(0, len(self.indices) - 1) - symbol, start_idx = self.indices[random_idx] + if self.sample_mode == "full_sequential": + symbol, start_idx = self.indices[int(idx)] + else: + # Select a random sample from the entire pool of indices. + random_idx = self.py_rng.randint(0, len(self.indices) - 1) + symbol, start_idx = self.indices[random_idx] # Extract the sliding window from the dataframe. df = self.data[symbol] diff --git a/finetune/evaluate_stom_1s_checkpoint.py b/finetune/evaluate_stom_1s_checkpoint.py new file mode 100644 index 000000000..e1387f2f8 --- /dev/null +++ b/finetune/evaluate_stom_1s_checkpoint.py @@ -0,0 +1,416 @@ +"""Evaluate STOM 1-second Kronos checkpoints against holdout QlibDataset pickles.""" + +from __future__ import annotations + +import argparse +import json +import pickle +import random +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +WEBUI_PREDICTION_DIR = PROJECT_ROOT / "webui" / "stom_predictions" +DEFAULT_TOKENIZER = "NeoQuasar/Kronos-Tokenizer-base" + + +@dataclass(frozen=True) +class EvalWindow: + window_id: int + key: str + symbol: str + session: str + history: pd.DataFrame + actual: pd.DataFrame + + +def _utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _safe_pct(numerator: float, denominator: float) -> float: + if denominator == 0 or np.isnan(denominator): + return 0.0 + return numerator / denominator * 100.0 + + +def _split_key(key: str) -> Tuple[str, str]: + if "_" not in key: + return key, "" + symbol, session = key.rsplit("_", 1) + return symbol, session + + +def _to_prediction_frame(frame: pd.DataFrame) -> pd.DataFrame: + out = frame.copy().reset_index().rename(columns={"datetime": "timestamps", "index": "timestamps"}) + if "timestamps" not in out.columns: + first = out.columns[0] + out = out.rename(columns={first: "timestamps"}) + out["timestamps"] = pd.to_datetime(out["timestamps"]) + out = out.rename(columns={"vol": "volume", "amt": "amount"}) + if "volume" not in out.columns: + out["volume"] = 0.0 + if "amount" not in out.columns: + out["amount"] = out["close"] * out["volume"] + return out[["timestamps", "open", "high", "low", "close", "volume", "amount"]] + + +def load_pickle_dataset(dataset_path: Path, split: str = "test") -> Dict[str, pd.DataFrame]: + path = dataset_path / f"{split}_data.pkl" + if not path.exists(): + raise FileNotFoundError(f"Dataset split pickle not found: {path}") + with path.open("rb") as f: + data = pickle.load(f) + if not isinstance(data, dict): + raise ValueError(f"Expected dict pickle at {path}") + return data + + +def _group_by_session(data: Mapping[str, pd.DataFrame]) -> Dict[str, List[Tuple[str, str, pd.DataFrame]]]: + sessions: Dict[str, List[Tuple[str, str, pd.DataFrame]]] = {} + for key, frame in data.items(): + symbol, session = _split_key(key) + sessions.setdefault(session, []).append((key, symbol, frame)) + for rows in sessions.values(): + rows.sort(key=lambda item: item[1]) + return dict(sorted(sessions.items())) + + +def select_aligned_windows( + data: Mapping[str, pd.DataFrame], + lookback_window: int, + predict_window: int, + max_symbols: int = 20, + max_asofs: int = 1, + max_sessions: int = 1, + stride: int = 300, +) -> List[EvalWindow]: + """Select deterministic cross-sectional windows sharing as-of timestamps.""" + + selected: List[EvalWindow] = [] + sessions = _group_by_session(data) + for session_idx, (_session, candidates) in enumerate(sessions.items()): + if session_idx >= max_sessions: + break + usable = [ + (key, symbol, _to_prediction_frame(frame)) + for key, symbol, frame in candidates + if len(frame) >= lookback_window + predict_window + ] + if not usable: + continue + + max_start = max(len(frame) - lookback_window - predict_window for _, _, frame in usable) + starts = list(range(0, max_start + 1, max(1, stride)))[:max_asofs] + for start in starts: + by_asof: Dict[str, List[Tuple[str, str, pd.DataFrame]]] = {} + for key, symbol, frame in usable: + if len(frame) < start + lookback_window + predict_window: + continue + asof = frame["timestamps"].iloc[start + lookback_window - 1].isoformat() + by_asof.setdefault(asof, []).append((key, symbol, frame)) + if not by_asof: + continue + _, aligned = max(by_asof.items(), key=lambda item: len(item[1])) + for key, symbol, frame in aligned[:max_symbols]: + history = frame.iloc[start : start + lookback_window].reset_index(drop=True) + actual = frame.iloc[start + lookback_window : start + lookback_window + predict_window].reset_index( + drop=True + ) + selected.append( + EvalWindow( + window_id=len(selected), + key=key, + symbol=symbol, + session=str(actual["timestamps"].iloc[0].strftime("%Y%m%d")), + history=history, + actual=actual, + ) + ) + if not selected: + raise ValueError("No aligned evaluation windows were selected.") + return selected + + +def persistence_predictions(windows: Sequence[EvalWindow]) -> List[pd.DataFrame]: + frames: List[pd.DataFrame] = [] + for window in windows: + last_close = float(window.history["close"].iloc[-1]) + pred = pd.DataFrame( + { + "open": last_close, + "high": last_close, + "low": last_close, + "close": last_close, + "volume": float(window.history["volume"].iloc[-1]), + "amount": float(window.history["amount"].iloc[-1]), + }, + index=window.actual["timestamps"], + ) + frames.append(pred) + return frames + + +def random_direction_predictions(windows: Sequence[EvalWindow], seed: int = 100) -> List[pd.DataFrame]: + rng = random.Random(seed) + frames: List[pd.DataFrame] = [] + for window in windows: + last_close = float(window.history["close"].iloc[-1]) + close = pd.to_numeric(window.history["close"], errors="coerce") + returns = close.pct_change().replace([np.inf, -np.inf], np.nan).dropna().abs() + move_pct = float(returns.median() * 100.0) if len(returns) else 0.0 + if move_pct == 0: + move_pct = 0.05 + direction = 1 if rng.random() >= 0.5 else -1 + final_close = last_close * (1.0 + direction * move_pct / 100.0) + path = np.linspace(last_close, final_close, len(window.actual) + 1)[1:] + pred = pd.DataFrame( + { + "open": path, + "high": path, + "low": path, + "close": path, + "volume": float(window.history["volume"].iloc[-1]), + "amount": float(window.history["amount"].iloc[-1]), + }, + index=window.actual["timestamps"], + ) + frames.append(pred) + return frames + + +def kronos_predictions( + windows: Sequence[EvalWindow], + model_path: str, + tokenizer_path: str, + device: str, + predict_window: int, + max_context: int = 512, + batch_size: int = 4, + temperature: float = 0.6, + top_p: float = 0.9, + top_k: int = 0, + sample_count: int = 1, +) -> List[pd.DataFrame]: + import torch + from model import Kronos, KronosPredictor, KronosTokenizer + + tokenizer = KronosTokenizer.from_pretrained(tokenizer_path) + model = Kronos.from_pretrained(model_path) + model.eval() + tokenizer.eval() + predictor = KronosPredictor(model, tokenizer, device=device, max_context=max_context) + out: List[pd.DataFrame] = [] + with torch.no_grad(): + for start in range(0, len(windows), max(1, batch_size)): + batch = windows[start : start + max(1, batch_size)] + pred_frames = predictor.predict_batch( + df_list=[w.history[["open", "high", "low", "close", "volume", "amount"]] for w in batch], + x_timestamp_list=[w.history["timestamps"] for w in batch], + y_timestamp_list=[w.actual["timestamps"] for w in batch], + pred_len=predict_window, + T=temperature, + top_k=top_k, + top_p=top_p, + sample_count=sample_count, + verbose=False, + ) + out.extend(pred_frames) + return out + + +def rows_from_predictions(windows: Sequence[EvalWindow], predictions: Sequence[pd.DataFrame], mode: str) -> pd.DataFrame: + rows: List[Dict[str, Any]] = [] + for window, pred in zip(windows, predictions): + actual = window.actual.reset_index(drop=True) + pred = pred.reset_index().rename(columns={"index": "timestamps"}).reset_index(drop=True) + t0_close = float(window.history["close"].iloc[-1]) + pred_close_final = float(pred["close"].iloc[-1]) + actual_close_final = float(actual["close"].iloc[-1]) + pred_return = _safe_pct(pred_close_final - t0_close, t0_close) + actual_return = _safe_pct(actual_close_final - t0_close, t0_close) + direction_hit = int(np.sign(pred_return) == np.sign(actual_return)) + pred_close_series = pd.to_numeric(pred["close"], errors="coerce") + if pred_return >= 0: + pred_path_consistency = float((pred_close_series >= t0_close).mean()) + else: + pred_path_consistency = float((pred_close_series <= t0_close).mean()) + pred_range_pct = _safe_pct(float(pred_close_series.max() - pred_close_series.min()), t0_close) + history_close = pd.to_numeric(window.history["close"], errors="coerce") + history_returns = history_close.pct_change().replace([np.inf, -np.inf], np.nan).dropna() + history_volatility_pct = float(history_returns.std(ddof=0) * 100.0) if len(history_returns) else 0.0 + history_return_pct = _safe_pct(float(history_close.iloc[-1] - history_close.iloc[0]), float(history_close.iloc[0])) + history_mean_amount = float(pd.to_numeric(window.history["amount"], errors="coerce").fillna(0.0).mean()) + history_last_amount = float(pd.to_numeric(window.history["amount"], errors="coerce").fillna(0.0).iloc[-1]) + history_mean_volume = float(pd.to_numeric(window.history["volume"], errors="coerce").fillna(0.0).mean()) + history_last_volume = float(pd.to_numeric(window.history["volume"], errors="coerce").fillna(0.0).iloc[-1]) + for idx in range(min(len(actual), len(pred))): + pred_close = float(pred["close"].iloc[idx]) + actual_close = float(actual["close"].iloc[idx]) + rows.append( + { + "window_id": window.window_id, + "symbol": window.symbol, + "session": window.session, + "asof_timestamp": window.history["timestamps"].iloc[-1].isoformat(), + "target_timestamp": actual["timestamps"].iloc[idx].isoformat(), + "horizon_step": idx + 1, + "horizon_seconds": idx + 1, + "actual_close_t0": t0_close, + "pred_close": pred_close, + "actual_close": actual_close, + "error": pred_close - actual_close, + "abs_error": abs(pred_close - actual_close), + "pred_return_window": pred_return, + "actual_return_window": actual_return, + "direction_hit_window": direction_hit, + "pred_path_consistency": pred_path_consistency, + "pred_range_pct": pred_range_pct, + "history_volatility_pct": history_volatility_pct, + "history_return_pct": history_return_pct, + "history_mean_amount": history_mean_amount, + "history_last_amount": history_last_amount, + "history_mean_volume": history_mean_volume, + "history_last_volume": history_last_volume, + "mode": mode, + } + ) + return pd.DataFrame(rows) + + +def summarize_prediction_frame(df: pd.DataFrame, top_k: int = 5) -> Dict[str, Any]: + error = pd.to_numeric(df["error"], errors="coerce") + abs_error = pd.to_numeric(df["abs_error"], errors="coerce") + actual = pd.to_numeric(df["actual_close"], errors="coerce").replace(0, np.nan) + latest = df.sort_values(["window_id", "horizon_step"]).groupby("window_id").tail(1) + mape = (abs_error / actual).replace([np.inf, -np.inf], np.nan).mean() * 100.0 + top_rows = [] + for _, group in latest.groupby("asof_timestamp", sort=True): + top_rows.append(group.sort_values("pred_return_window", ascending=False).head(top_k)) + top = pd.concat(top_rows, ignore_index=True) if top_rows else latest.iloc[0:0] + return { + "rows": int(len(df)), + "windows": int(latest["window_id"].nunique()), + "symbols": int(latest["symbol"].nunique()), + "mae": float(abs_error.mean()), + "rmse": float(np.sqrt((error**2).mean())), + "mape": float(0.0 if np.isnan(mape) else mape), + "direction_accuracy": float(pd.to_numeric(latest["direction_hit_window"], errors="coerce").mean()), + "avg_pred_return": float(pd.to_numeric(latest["pred_return_window"], errors="coerce").mean()), + "avg_actual_return": float(pd.to_numeric(latest["actual_return_window"], errors="coerce").mean()), + "topk": { + "k": int(top_k), + "periods": int(latest["asof_timestamp"].nunique()), + "trades": int(len(top)), + "avg_actual_return": float(pd.to_numeric(top.get("actual_return_window", pd.Series(dtype=float))).mean()) + if len(top) + else 0.0, + "hit_rate": float(pd.to_numeric(top.get("direction_hit_window", pd.Series(dtype=float))).mean()) + if len(top) + else 0.0, + }, + "beats_direction_0_40": bool( + pd.to_numeric(latest["direction_hit_window"], errors="coerce").mean() > 0.40 + ), + } + + +def write_prediction_artifacts( + frames: Mapping[str, pd.DataFrame], + output_dir: Path, + prefix: str, + top_k: int, +) -> Dict[str, Any]: + output_dir.mkdir(parents=True, exist_ok=True) + result: Dict[str, Any] = {"created_at": _utc_now(), "files": {}, "metrics": {}} + for mode, df in frames.items(): + path = output_dir / f"{prefix}_{mode}.csv" + df.to_csv(path, index=False, encoding="utf-8-sig") + metrics = summarize_prediction_frame(df, top_k=top_k) + metrics_path = path.with_suffix(".metrics.json") + metrics_path.write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8") + result["files"][mode] = str(path) + result["metrics"][mode] = metrics + comparison_path = output_dir / f"{prefix}_comparison.json" + comparison_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + result["comparison_path"] = str(comparison_path) + return result + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Evaluate STOM 1s fine-tuned Kronos checkpoint on holdout split.") + parser.add_argument("--dataset-path", required=True, help="processed_datasets directory containing test_data.pkl") + parser.add_argument("--model-path", required=True, help="Fine-tuned Kronos checkpoint or HF model id") + parser.add_argument("--tokenizer-path", default=DEFAULT_TOKENIZER) + parser.add_argument("--output-dir", default=str(WEBUI_PREDICTION_DIR)) + parser.add_argument("--prefix", required=True) + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--predict-window", type=int, required=True) + parser.add_argument("--max-symbols", type=int, default=20) + parser.add_argument("--max-asofs", type=int, default=1) + parser.add_argument("--max-sessions", type=int, default=1) + parser.add_argument("--stride", type=int, default=300) + parser.add_argument("--batch-size", type=int, default=4) + parser.add_argument("--top-k", type=int, default=5) + parser.add_argument("--device", default="cuda:0") + parser.add_argument("--modes", default="kronos,persistence,random") + parser.add_argument("--seed", type=int, default=100) + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + data = load_pickle_dataset(Path(args.dataset_path), split="test") + windows = select_aligned_windows( + data, + lookback_window=args.lookback_window, + predict_window=args.predict_window, + max_symbols=args.max_symbols, + max_asofs=args.max_asofs, + max_sessions=args.max_sessions, + stride=args.stride, + ) + modes = [item.strip() for item in args.modes.split(",") if item.strip()] + frames: Dict[str, pd.DataFrame] = {} + if "kronos" in modes: + kronos = kronos_predictions( + windows, + model_path=args.model_path, + tokenizer_path=args.tokenizer_path, + device=args.device, + predict_window=args.predict_window, + batch_size=args.batch_size, + ) + frames["kronos"] = rows_from_predictions(windows, kronos, mode="kronos") + if "persistence" in modes: + frames["persistence"] = rows_from_predictions(windows, persistence_predictions(windows), mode="persistence") + if "random" in modes: + frames["random"] = rows_from_predictions(windows, random_direction_predictions(windows, args.seed), mode="random") + if not frames: + raise ValueError("No evaluation modes selected.") + + result = write_prediction_artifacts(frames, Path(args.output_dir), args.prefix, top_k=args.top_k) + result["dataset_path"] = str(Path(args.dataset_path)) + result["model_path"] = args.model_path + result["tokenizer_path"] = args.tokenizer_path + result["lookback_window"] = args.lookback_window + result["predict_window"] = args.predict_window + result["selected_windows"] = len(windows) + result["asof_timestamps"] = sorted({window.history["timestamps"].iloc[-1].isoformat() for window in windows}) + comparison_path = Path(result["comparison_path"]) + comparison_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune/model_source.py b/finetune/model_source.py new file mode 100644 index 000000000..b482dfa19 --- /dev/null +++ b/finetune/model_source.py @@ -0,0 +1,34 @@ +"""Utilities for resolving local or Hugging Face Kronos model sources.""" + +from pathlib import Path + + +def is_missing_local_model_path(model_path: str) -> bool: + """Return True only when a path-like model source points to a missing local directory.""" + + if not model_path: + return True + path = Path(model_path) + looks_local = ( + path.is_absolute() + or "\\" in model_path + or model_path.startswith(".") + or model_path.startswith("path/to/") + or model_path.count("/") > 1 + or "/" not in model_path + ) + return looks_local and not path.exists() + + +def resolve_model_source(primary_path: str, fallback_path: str, label: str, rank: int = 0) -> str: + """Use a fine-tuned local model when present; otherwise fall back to a pretrained source.""" + + if primary_path and not is_missing_local_model_path(primary_path): + return primary_path + if not fallback_path or is_missing_local_model_path(fallback_path): + raise FileNotFoundError( + f"{label} model source is not available. primary={primary_path!r}, fallback={fallback_path!r}" + ) + if rank == 0: + print(f"{label} local source not found; falling back to pretrained source: {fallback_path}") + return fallback_path diff --git a/finetune/predictor_handoff.py b/finetune/predictor_handoff.py new file mode 100644 index 000000000..d62502dae --- /dev/null +++ b/finetune/predictor_handoff.py @@ -0,0 +1,116 @@ +"""Predictor-stage handoff overrides for long-running STOM fine-tuning runs. + +This module is intentionally torch-free so the handoff policy can be tested +without importing PyTorch while a separate long-running CUDA job is active. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Dict, MutableMapping, Optional + + +DEFAULT_HANDOFF_FILENAME = "predictor_handoff_overrides.json" +INT_OVERRIDE_KEYS = {"batch_size", "num_workers"} + + +def default_handoff_path(save_path: str | os.PathLike[str]) -> Path: + """Return the default handoff file path inside a run directory.""" + + return Path(save_path) / DEFAULT_HANDOFF_FILENAME + + +def resolve_handoff_path(config: Any) -> Optional[Path]: + """Resolve the predictor handoff file path for a Config object or dict.""" + + explicit = os.getenv("KRONOS_PREDICTOR_HANDOFF_OVERRIDES") + if explicit: + return Path(explicit) + + save_path = _get_config_value(config, "save_path") + if not save_path: + return None + return default_handoff_path(str(save_path)) + + +def apply_predictor_handoff_overrides(config: Any, path: Optional[Path] = None) -> Dict[str, Any]: + """Apply safe predictor-only overrides from JSON to a Config object or dict. + + Supported JSON shape: + + ```json + { + "enabled": true, + "batch_size": 16, + "num_workers": 2 + } + ``` + + Unknown keys are ignored. Values are validated as integers. The function + returns metadata about whether anything was applied so callers can log it. + """ + + resolved_path = path or resolve_handoff_path(config) + result: Dict[str, Any] = { + "path": str(resolved_path) if resolved_path else None, + "exists": bool(resolved_path and resolved_path.exists()), + "enabled": False, + "applied": {}, + "ignored": {}, + } + if not resolved_path or not resolved_path.exists(): + _set_config_value(config, "predictor_handoff_overrides", result) + return result + + payload = json.loads(resolved_path.read_text(encoding="utf-8-sig")) + if not isinstance(payload, dict): + raise ValueError(f"Predictor handoff override must be a JSON object: {resolved_path}") + + enabled = bool(payload.get("enabled", True)) + result["enabled"] = enabled + if not enabled: + _set_config_value(config, "predictor_handoff_overrides", result) + return result + + for key in INT_OVERRIDE_KEYS: + if key not in payload: + continue + value = _parse_int_override(key, payload[key], resolved_path) + _set_config_value(config, key, value) + result["applied"][key] = value + + for key in payload: + if key not in INT_OVERRIDE_KEYS and key != "enabled": + result["ignored"][key] = payload[key] + + _set_config_value(config, "predictor_handoff_overrides", result) + return result + + +def _parse_int_override(key: str, value: Any, path: Path) -> int: + if isinstance(value, bool): + raise ValueError(f"{key} in {path} must be an integer, not boolean") + try: + parsed = int(value) + except (TypeError, ValueError) as exc: + raise ValueError(f"{key} in {path} must be an integer") from exc + if key == "batch_size" and parsed <= 0: + raise ValueError(f"{key} in {path} must be > 0") + if key == "num_workers" and parsed < 0: + raise ValueError(f"{key} in {path} must be >= 0") + return parsed + + +def _get_config_value(config: Any, key: str) -> Any: + if isinstance(config, MutableMapping): + return config.get(key) + return getattr(config, key, None) + + +def _set_config_value(config: Any, key: str, value: Any) -> None: + if isinstance(config, MutableMapping): + config[key] = value + else: + setattr(config, key, value) diff --git a/finetune/preflight_stom_2025_full.py b/finetune/preflight_stom_2025_full.py new file mode 100644 index 000000000..451887af2 --- /dev/null +++ b/finetune/preflight_stom_2025_full.py @@ -0,0 +1,371 @@ +"""Preflight checks before a 2025-only STOM 1s Kronos-small full run. + +The script is read-only for the source SQLite database. It does not start +training and does not create large datasets; it verifies whether the machine and +repository are ready, then prints the exact export/training commands that should +be launched by the next long-running step. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +import sqlite3 +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional, Sequence + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +FINETUNE_CSV_DIR = PROJECT_ROOT / "finetune_csv" +if str(FINETUNE_CSV_DIR) not in sys.path: + sys.path.insert(0, str(FINETUNE_CSV_DIR)) + +from stom_tick_dataset import connect_readonly, list_stock_tables # noqa: E402 + + +DEFAULT_DB = PROJECT_ROOT / "_database" / "stock_tick_back.db" +DEFAULT_SCAN_REPORT = PROJECT_ROOT / ".omx" / "analysis" / "stom_2025_db_sample_time_report.json" +DEFAULT_EXPORT_DIR = PROJECT_ROOT / "finetune" / "qlib_exports" / "stom_1s_grid_pred60_2025" +DEFAULT_OUTPUT_ROOT = PROJECT_ROOT / "finetune" / "outputs" +DEFAULT_RUN_NAME = "stom_1s_grid_pred60_2025_full_small" +DEFAULT_TRAIN_SAMPLES = 18_771_531 +DEFAULT_VAL_SAMPLES = 3_922_758 +MEASURED_4080S_SECONDS_PER_240K = 7_340.567561 + + +def _utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _rel(path: Path) -> str: + try: + return str(path.resolve().relative_to(PROJECT_ROOT)) + except ValueError: + return str(path.resolve()) + + +def _load_json(path: Path) -> Optional[Dict[str, Any]]: + if not path.exists(): + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def _powershell_quote(value: Path | str) -> str: + text = str(value) + return f'"{text}"' if " " in text else text + + +def _disk_report(path: Path) -> Dict[str, Any]: + target = path if path.exists() else path.parent + usage = shutil.disk_usage(target) + return { + "path": str(path), + "free_bytes": usage.free, + "free_gb": round(usage.free / (1024**3), 2), + "total_gb": round(usage.total / (1024**3), 2), + } + + +def _cuda_report(python_exe: str) -> Dict[str, Any]: + code = r""" +import json +import sys +payload = {"python": sys.executable} +try: + import torch + payload["torch_version"] = torch.__version__ + payload["cuda_available"] = bool(torch.cuda.is_available()) + if torch.cuda.is_available(): + props = torch.cuda.get_device_properties(0) + payload["gpu_name"] = torch.cuda.get_device_name(0) + payload["capability"] = list(torch.cuda.get_device_capability(0)) + payload["memory_total_bytes"] = int(props.total_memory) + payload["memory_total_gb"] = round(props.total_memory / (1024**3), 2) +except Exception as exc: + payload["error"] = repr(exc) +print(json.dumps(payload, ensure_ascii=False)) +""" + completed = subprocess.run( + [python_exe, "-c", code], + cwd=PROJECT_ROOT, + text=True, + encoding="utf-8", + errors="replace", + capture_output=True, + check=False, + ) + if completed.returncode != 0: + return { + "python": python_exe, + "error": completed.stderr.strip() or completed.stdout.strip(), + "cuda_available": False, + } + return json.loads(completed.stdout) + + +def _db_report(db_path: Path) -> Dict[str, Any]: + payload: Dict[str, Any] = { + "path": str(db_path), + "exists": db_path.exists(), + "size_bytes": db_path.stat().st_size if db_path.exists() else 0, + "size_gb": round(db_path.stat().st_size / (1024**3), 2) if db_path.exists() else 0, + } + if not db_path.exists(): + return payload + conn = connect_readonly(db_path) + try: + payload["query_only"] = conn.execute("PRAGMA query_only").fetchone()[0] + payload["table_count"] = len(list_stock_tables(conn, max_tables=None)) + try: + conn.execute("CREATE TABLE __kronos_preflight_write_probe(x INTEGER)") + payload["write_probe_blocked"] = False + except sqlite3.DatabaseError as exc: + payload["write_probe_blocked"] = True + payload["write_probe_error"] = str(exc) + finally: + conn.close() + return payload + + +def _artifact_exists(path: Path) -> Dict[str, Any]: + return { + "path": str(path), + "exists": path.exists(), + "size_bytes": path.stat().st_size if path.is_file() else None, + } + + +def _sample_counts_from_scan(scan_report: Optional[Dict[str, Any]], train_default: int, val_default: int) -> tuple[int, int, str]: + if scan_report is None: + return train_default, val_default, "built_in_defaults" + split = scan_report.get("split_by_session_70_15_15", {}) + train = int(split.get("train", {}).get("possible_samples_pred60", train_default)) + val = int(split.get("val", {}).get("possible_samples_pred60", val_default)) + return train, val, "scan_report" + + +def _sample_counts_from_export_report( + export_report: Optional[Dict[str, Any]], + lookback_window: int, + horizon: int, +) -> Optional[tuple[int, int]]: + if export_report is None: + return None + split_counts = export_report.get("split_counts", {}) + train = split_counts.get("train", {}) + val = split_counts.get("val", {}) + window_offset = lookback_window + horizon + if not train or not val: + return None + train_samples = int(train.get("rows", 0)) - int(train.get("groups", 0)) * window_offset + val_samples = int(val.get("rows", 0)) - int(val.get("groups", 0)) * window_offset + if train_samples <= 0 or val_samples <= 0: + return None + return train_samples, val_samples + + +def build_preflight(args: argparse.Namespace) -> Dict[str, Any]: + db_path = Path(args.db).resolve() + export_dir = Path(args.export_dir).resolve() + dataset_dir = export_dir / "processed_datasets" + output_root = Path(args.output_root).resolve() + run_dir = output_root / args.run_name + scan_report_path = Path(args.scan_report).resolve() + scan_report = _load_json(scan_report_path) + export_report_path = export_dir / "stom_qlib_export_report.json" + export_report = _load_json(export_report_path) + + train_default = int(args.train_samples or DEFAULT_TRAIN_SAMPLES) + val_default = int(args.val_samples or DEFAULT_VAL_SAMPLES) + train_samples, val_samples, sample_source = _sample_counts_from_scan(scan_report, train_default, val_default) + export_samples = _sample_counts_from_export_report(export_report, args.lookback_window, args.horizon) + if export_samples: + train_samples, val_samples = export_samples + sample_source = "export_report" + total_samples = train_samples + val_samples + estimated_seconds = MEASURED_4080S_SECONDS_PER_240K * (total_samples / 240_000) + + export_command = [ + args.python_exe, + "finetune/qlib_stom_pipeline.py", + "export", + "--db", + _rel(db_path), + "--output-dir", + _rel(export_dir), + "--lookback-window", + str(args.lookback_window), + "--predict-window", + str(args.horizon), + "--horizon-seconds", + str(args.horizon), + "--price-mode", + "close_only", + "--time-start", + args.time_start, + "--time-end", + args.time_end, + "--session-start", + f"{args.year}0101", + "--session-end", + f"{args.year}1231", + "--freq", + "1s", + "--regularize-1s", + "--split-by", + "session", + ] + train_command = [ + args.python_exe, + "finetune/run_stom_1s_finetune.py", + "--horizon", + str(args.horizon), + "--mode", + "full", + "--train-stage", + "both", + "--dataset-dir", + _rel(dataset_dir), + "--run-name", + args.run_name, + "--dataset-sample-mode", + "full_sequential", + "--batch-size", + str(args.batch_size), + "--num-workers", + str(args.num_workers), + "--n-train-iter", + str(train_samples), + "--n-val-iter", + str(val_samples), + "--log-interval", + str(args.log_interval), + ] + + expected_paths = { + "dataset_dir": str(dataset_dir), + "train_pkl": _artifact_exists(dataset_dir / "train_data.pkl"), + "val_pkl": _artifact_exists(dataset_dir / "val_data.pkl"), + "test_pkl": _artifact_exists(dataset_dir / "test_data.pkl"), + "run_dir": str(run_dir), + "tokenizer_checkpoint": str(run_dir / "finetune_tokenizer" / "checkpoints" / "best_model"), + "predictor_checkpoint": str(run_dir / "finetune_predictor" / "checkpoints" / "best_model"), + } + existing_200k = PROJECT_ROOT / "finetune" / "outputs" / "stom_1s_grid_pred60_official_200k" + blockers = [] + warnings = [] + + db = _db_report(db_path) + if not db.get("exists"): + blockers.append("STOM tick DB is missing.") + if db.get("query_only") != 1: + blockers.append("DB read-only/query_only check failed.") + if db.get("write_probe_blocked") is False: + blockers.append("DB write probe was not blocked.") + + cuda = _cuda_report(args.python_exe) + if not cuda.get("cuda_available"): + blockers.append("CUDA is not available.") + elif cuda.get("memory_total_gb", 0) < 12: + warnings.append("VRAM is below 12GB; Kronos-small full run batch_size=4 may be unstable.") + + if scan_report is None: + warnings.append("2025 exact scan report is missing; built-in sample estimates were used.") + + if not (existing_200k / "finetune_tokenizer" / "checkpoints" / "best_model").exists(): + warnings.append("Official 200k tokenizer checkpoint was not found.") + if not (existing_200k / "finetune_predictor" / "checkpoints" / "best_model").exists(): + warnings.append("Official 200k predictor checkpoint was not found.") + + if not (dataset_dir / "train_data.pkl").exists(): + warnings.append("2025 processed dataset does not exist yet; run the export command first.") + + output_disk = _disk_report(output_root) + export_disk = _disk_report(export_dir) + if export_disk["free_gb"] < 50: + warnings.append("Export path has less than 50GB free space.") + if output_disk["free_gb"] < 20: + warnings.append("Output path has less than 20GB free space.") + + return { + "created_at": _utc_now(), + "status": "blocked" if blockers else "ready_with_actions", + "year": args.year, + "horizon": args.horizon, + "lookback_window": args.lookback_window, + "target_samples": { + "train": train_samples, + "val": val_samples, + "train_plus_val": total_samples, + "source": sample_source, + }, + "estimated_4080s_runtime": { + "seconds": round(estimated_seconds, 2), + "hours": round(estimated_seconds / 3600, 2), + "days": round(estimated_seconds / 86400, 2), + "basis": "Measured official 200k tokenizer+predictor: 7,340.567561 seconds per 240k samples.", + }, + "db": db, + "cuda": cuda, + "disk": {"export_root": export_disk, "output_root": output_disk}, + "scan_report_loaded": scan_report is not None, + "scan_report_path": str(scan_report_path), + "export_report_loaded": export_report is not None, + "export_report_path": str(export_report_path), + "expected_artifacts": expected_paths, + "commands": { + "export_2025_dataset": " ".join(_powershell_quote(part) for part in export_command), + "train_2025_full_small": " ".join(_powershell_quote(part) for part in train_command), + "dashboard_after_prediction": "python webui/run.py --host 127.0.0.1 --port 5000", + }, + "warnings": warnings, + "blockers": blockers, + "next_action": ( + "run_training_2025_full_small" + if not blockers and (dataset_dir / "train_data.pkl").exists() and (dataset_dir / "val_data.pkl").exists() + else "run_export_2025_dataset" + if not blockers + else "resolve_blockers" + ), + } + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--db", default=str(DEFAULT_DB)) + parser.add_argument("--year", default="2025") + parser.add_argument("--horizon", type=int, default=60) + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--time-start", default="090000") + parser.add_argument("--time-end", default="093000") + parser.add_argument("--export-dir", default=str(DEFAULT_EXPORT_DIR)) + parser.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT)) + parser.add_argument("--run-name", default=DEFAULT_RUN_NAME) + parser.add_argument("--scan-report", default=str(DEFAULT_SCAN_REPORT)) + parser.add_argument("--python-exe", default=sys.executable) + parser.add_argument("--batch-size", type=int, default=4) + parser.add_argument("--num-workers", type=int, default=0) + parser.add_argument("--log-interval", type=int, default=1000) + parser.add_argument("--train-samples", type=int, default=None) + parser.add_argument("--val-samples", type=int, default=None) + parser.add_argument("--json-output", default=None) + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + report = build_preflight(args) + if args.json_output: + output = Path(args.json_output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 1 if report["blockers"] else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune/qlib_stom_pipeline.py b/finetune/qlib_stom_pipeline.py new file mode 100644 index 000000000..dd137370c --- /dev/null +++ b/finetune/qlib_stom_pipeline.py @@ -0,0 +1,1656 @@ +"""STOM tick DB -> Qlib/Kronos research pipeline helpers. + +This module intentionally keeps the first implementation pilot-first: + +* it reads the source SQLite database in read-only mode; +* it writes Qlib dump-ready CSV files for optional ``pyqlib`` conversion; +* it writes the pickle split format consumed by ``finetune/dataset.py``; +* it provides a lightweight Qlib-style Top-K score backtest for Kronos + prediction CSVs. + +The produced data artifacts can be large and are not meant to be committed. +""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import math +import os +import pickle +import subprocess +import sys +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +FINETUNE_CSV_DIR = PROJECT_ROOT / "finetune_csv" +if str(FINETUNE_CSV_DIR) not in sys.path: + sys.path.insert(0, str(FINETUNE_CSV_DIR)) + +from stom_tick_dataset import ( # noqa: E402 + DEFAULT_GROUP_COLUMNS, + connect_readonly, + get_table_columns, + list_stock_tables, + read_stom_table_as_kline, +) + + +KRONOS_PICKLE_COLUMNS = ["open", "high", "low", "close", "vol", "amt"] +QLIB_CSV_FIELDS = ["open", "high", "low", "close", "volume", "amount", "money", "factor"] +SUPPORTED_FREQS = {"1s", "1min", "session"} +# ``session`` collapses ALL rows of one (symbol, session) into ONE bar (the +# "daily proxy" of Story B1). It is a *caveated proxy* for a daily bar — the +# DB is event-triggered + morning-only (~30 min/session), so a session bar is a +# morning-30min bar, NOT a true close-to-close daily bar; cross-session signal +# built on it is confounded by selection bias and is NOT an alpha claim (see +# docs/stom_rl_story_b_daily_data_feasibility_2026-05-27.md). +SESSION_FREQ = "session" +SUPPORTED_SPLIT_STRATEGIES = {"session", "group"} +PYQLIB_PROVIDER_UNSUPPORTED_FREQS = {"1s"} +ExportItem = Tuple[str, str, pd.Timestamp, pd.Timestamp, pd.DataFrame] +STOM_RL_CANONICAL_FEATURES = [ + "open", + "high", + "low", + "close", + "volume", + "amount", + "trade_strength", + "buy_qty_1s", + "sell_qty_1s", + "net_buy_qty_1s", + "bid_ask_imbalance", + "spread_ticks", + "amount_delta", + "turnover_rate", + "change_rate", + "market_cap", + "high_low_mid_change_rate", + "trade_strength_avg_n", + "moving_average_n", + "volatility_n", + "amount_slope_n", + "change_rate_slope_n", +] + +# Trailing window (bars) for the causal ``trade_strength_avg_n`` rolling mean. +# The window covers ONLY bars <= the current bar (no look-ahead); see +# :func:`build_stom_rl_feature_frame`. +STOM_RL_TRADE_STRENGTH_AVG_WINDOW = 30 + +# Trailing window (bars, N) for the four causal trend/momentum features +# (``moving_average_n`` 이동평균, ``volatility_n`` 변동성, ``amount_slope_n`` +# 거래대금각도, ``change_rate_slope_n`` 등락율각도). LOCKED at N=10 one-minute +# bars by the Stage-1 pre-registration (docs/stom_rl_1min_stage1_prereg_2026-05-27.md +# §4). Every window covers ONLY bars <= the current bar (no look-ahead). +STOM_RL_TREND_WINDOW = 10 + +# Population std (ddof=0) for ``volatility_n`` — LOCKED by Stage-1 pre-registration +# §4 ("변동성 ddof = 0"): deterministic, avoids small-sample inflation at the +# window edge. +STOM_RL_VOLATILITY_DDOF = 0 +STOM_RL_FEATURE_MAPPING: Dict[str, Dict[str, Any]] = { + "trade_strength": { + "source_columns": ["체결강도", "trade_strength"], + "formula": "numeric fill 0, clipped to [0, 500]", + }, + "buy_qty_1s": { + "source_columns": ["초당매수수량", "buy_qty_1s", "buy_qty"], + "formula": "numeric fill 0", + }, + "sell_qty_1s": { + "source_columns": ["초당매도수량", "sell_qty_1s", "sell_qty"], + "formula": "numeric fill 0", + }, + "net_buy_qty_1s": { + "source_columns": ["buy_qty_1s", "sell_qty_1s"], + "formula": "buy_qty_1s - sell_qty_1s", + }, + "bid_ask_imbalance": { + "source_columns": ["매수총잔량", "매도총잔량", "bid_qty_1", "ask_qty_1"], + "formula": "bid / max(bid + ask, eps)", + }, + "spread_ticks": { + "source_columns": ["매도호가1", "매수호가1", "ask_price_1", "bid_price_1"], + "formula": "(ask1 - bid1) / tick_size, fallback tick_size=1", + }, + "amount_delta": { + "source_columns": ["amount", "거래대금", "초당거래대금"], + "formula": "group/session first-difference, first row 0", + }, + "turnover_rate": { + "source_columns": ["회전율", "turnover_rate"], + "formula": "numeric fill 0", + }, + "change_rate": { + "source_columns": ["등락율", "change_rate"], + "formula": "numeric fill 0 (same-bar % change, point-in-time)", + }, + "market_cap": { + "source_columns": ["시가총액", "market_cap"], + "formula": "log1p(max(numeric, 0)) to tame the large magnitude", + }, + "high_low_mid_change_rate": { + "source_columns": ["고저평균대비등락율", "high_low_mid_change_rate"], + "formula": "numeric fill 0 (same-bar derived, point-in-time)", + }, + "trade_strength_avg_n": { + "source_columns": ["체결강도", "trade_strength"], + "formula": ( + "trailing causal mean of trade_strength over <= N bars " + "(rolling(N, min_periods=1).mean(), no look-ahead)" + ), + }, + "moving_average_n": { + "source_columns": ["close"], + "formula": ( + "이동평균(N): trailing causal mean of close over <= N bars " + "(rolling(N, min_periods=1).mean(), no look-ahead)" + ), + }, + "volatility_n": { + "source_columns": ["change_rate", "등락율"], + "formula": ( + "변동성(N): trailing causal population std (ddof=0) of change_rate " + "over <= N bars (rolling(N, min_periods=1).std(ddof=0), no look-ahead)" + ), + }, + "amount_slope_n": { + "source_columns": ["amount", "초당거래대금"], + "formula": ( + "거래대금각도: trailing-OLS slope cov(x,y)/var(x) of per-bar SUMMED " + "amount over <= N bars (rolling(N, min_periods=2), x=bar-index, " + "trailing-only, no shift(-k)/center)" + ), + }, + "change_rate_slope_n": { + "source_columns": ["change_rate", "등락율"], + "formula": ( + "등락율각도: trailing-OLS slope cov(x,y)/var(x) of change_rate over " + "<= N bars (rolling(N, min_periods=2), x=bar-index, trailing-only, " + "no shift(-k)/center)" + ), + }, +} + + +@dataclass +class StomQlibExportConfig: + db_path: str + output_dir: str + max_tables: int = 0 + tables: Optional[List[str]] = None + lookback_window: int = 300 + predict_window: int = 60 + price_mode: str = "close_only" + time_start: str = "090000" + time_end: str = "093000" + session_start: Optional[str] = None + session_end: Optional[str] = None + max_rows_per_group: int = 0 + max_groups: int = 0 + freq: str = "1s" + regularize_1s: bool = False + split_by: str = "session" + horizon_seconds: Optional[int] = None + train_ratio: float = 0.70 + val_ratio: float = 0.15 + test_ratio: float = 0.15 + + +def _clean_symbol(value: Any) -> str: + text = str(value).strip() + return text.zfill(6) if text.isdigit() and len(text) <= 6 else text + + +def _instrument_key(symbol: Any, session: Any) -> str: + """Use symbol+session as an instrument key to prevent cross-session windows.""" + + return f"KR{_clean_symbol(symbol)}_{str(session)}" + + +def _first_present(frame: pd.DataFrame, candidates: Sequence[str]) -> Optional[str]: + for candidate in candidates: + if candidate in frame.columns: + return candidate + return None + + +def _numeric_or_zero(frame: pd.DataFrame, candidates: Sequence[str]) -> pd.Series: + column = _first_present(frame, candidates) + if column is None: + return pd.Series(np.zeros(len(frame)), index=frame.index, dtype="float64") + return pd.to_numeric(frame[column], errors="coerce").fillna(0.0) + + +def _trailing_ols_slope(values: np.ndarray) -> float: + """Trailing-OLS slope ``cov(x, y) / var(x)`` for one rolling window. + + ``x = [0, 1, ..., k-1]`` is the within-window bar index and ``y`` is the + window's series values. Returns ``NaN`` when fewer than 2 points are present + or ``var(x) == 0`` (a single distinct bar); the caller fills NaN with 0.0. + + The window is supplied by ``rolling(N, min_periods=2)`` which is *trailing + only* (bars ``<= T``), so this introduces NO look-ahead: row ``T`` never sees + bar ``T+1``. No ``shift(-k)``, no ``center=True``, no full-series fit — this + matches the Stage-1 LOCKED causal slope formula + (docs/stom_rl_1min_stage1_prereg_2026-05-27.md §4). + """ + + y = np.asarray(values, dtype="float64") + k = y.size + if k < 2: + return float("nan") + x = np.arange(k, dtype="float64") + x_mean = x.mean() + var_x = float(((x - x_mean) ** 2).sum()) + if var_x <= 0.0: + return float("nan") + y_mean = y.mean() + cov_xy = float(((x - x_mean) * (y - y_mean)).sum()) + return cov_xy / var_x + + +def build_stom_rl_feature_frame( + frame: pd.DataFrame, + tick_size: float = 1.0, + trend_group_keys: Optional[Sequence[str]] = None, +) -> pd.DataFrame: + """Return a deterministic canonical feature frame for STOM RL. + + The legacy Qlib export keeps ``QLIB_CSV_FIELDS`` unchanged. This helper is + an opt-in portfolio/RL feature expansion layer used by tests, future export + commands, and handoff documentation. + + ``trend_group_keys`` selects the grouping for the sequential/causal features + (``amount_delta`` first-difference + the trailing means/slopes). When + ``None`` (the default) the legacy ``[symbol, session]`` grouping is used, so + the 1s/1min paths are byte-unchanged: trailing windows never bleed across a + symbol's *or* a session's boundary. For Story B1 SESSION bars (one row per + ``(symbol, session)``) the caller passes ``["symbol"]`` so the trailing trend + features compute *across the per-symbol session series* (each session is one + bar) — still trailing-only (no look-ahead), just over the session axis rather + than within a single session. + """ + + if frame.empty: + return pd.DataFrame(columns=STOM_RL_CANONICAL_FEATURES) + out = frame.copy() + for column in ["open", "high", "low", "close", "volume", "amount"]: + out[column] = pd.to_numeric(out.get(column, 0.0), errors="coerce").fillna(0.0) + + out["trade_strength"] = _numeric_or_zero(out, ["체결강도", "trade_strength"]).clip(0.0, 500.0) + out["buy_qty_1s"] = _numeric_or_zero(out, ["초당매수수량", "buy_qty_1s", "buy_qty"]) + out["sell_qty_1s"] = _numeric_or_zero(out, ["초당매도수량", "sell_qty_1s", "sell_qty"]) + out["net_buy_qty_1s"] = out["buy_qty_1s"] - out["sell_qty_1s"] + + bid = _numeric_or_zero(out, ["매수총잔량", "bid_qty_1", "bid_qty"]) + ask = _numeric_or_zero(out, ["매도총잔량", "ask_qty_1", "ask_qty"]) + out["bid_ask_imbalance"] = bid / np.maximum(bid + ask, 1e-9) + + bid_price = _numeric_or_zero(out, ["매수호가1", "bid_price_1", "bid_price"]) + ask_price = _numeric_or_zero(out, ["매도호가1", "ask_price_1", "ask_price"]) + missing_quote = (bid_price <= 0) | (ask_price <= 0) + bid_price = bid_price.mask(missing_quote, out["close"]) + ask_price = ask_price.mask(missing_quote, out["close"]) + out["spread_ticks"] = (ask_price - bid_price).clip(lower=0.0) / max(float(tick_size), 1e-9) + + if trend_group_keys is None: + group_keys = [column for column in ["symbol", "session"] if column in out.columns] + else: + group_keys = [column for column in trend_group_keys if column in out.columns] + if group_keys: + out["amount_delta"] = out.groupby(group_keys, sort=False)["amount"].diff().fillna(0.0) + else: + out["amount_delta"] = out["amount"].diff().fillna(0.0) + out["turnover_rate"] = _numeric_or_zero(out, ["회전율", "turnover_rate"]) + + # Stage C feature expansion (C-0 probe confirmed dense direct DB columns). + out["change_rate"] = _numeric_or_zero(out, ["등락율", "change_rate"]) + # ``시가총액`` (market cap) spans 2+ orders of magnitude; log1p keeps it on a + # comparable scale to the other features (matches the "log" normalization + # guidance) while staying finite for the non-negative magnitudes in the DB. + market_cap_raw = _numeric_or_zero(out, ["시가총액", "market_cap"]).clip(lower=0.0) + out["market_cap"] = np.log1p(market_cap_raw) + out["high_low_mid_change_rate"] = _numeric_or_zero( + out, ["고저평균대비등락율", "high_low_mid_change_rate"] + ) + + # Causal trailing mean of trade strength over <= N bars (NO look-ahead): a + # row at index T uses only rows in [T-N+1, T]. ``rolling(...).mean()`` is + # backward-only by construction; grouping per symbol/session prevents the + # window from bleeding across instruments or sessions. + window = int(STOM_RL_TRADE_STRENGTH_AVG_WINDOW) + if group_keys: + out["trade_strength_avg_n"] = ( + out.groupby(group_keys, sort=False)["trade_strength"] + .transform(lambda s: s.rolling(window, min_periods=1).mean()) + ) + else: + out["trade_strength_avg_n"] = ( + out["trade_strength"].rolling(window, min_periods=1).mean() + ) + + # ------------------------------------------------------------------ + # Causal trend/momentum features (이동평균/변동성/거래대금각도/등락율각도). + # All four are trailing-only (window in [T-N+1, T], NO shift(-k)/center) and + # grouped per symbol/session so the window never bleeds across instruments or + # sessions. N and the 변동성 ddof are LOCKED by Stage-1 pre-registration. + # Level features use min_periods=1; slope features use min_periods=2 (a slope + # needs >=2 points) and their first-bar NaN is filled to 0.0 by the closing + # replace/fillna — no look-ahead is introduced. + # ------------------------------------------------------------------ + trend_window = int(STOM_RL_TREND_WINDOW) + ddof = int(STOM_RL_VOLATILITY_DDOF) + + def _ma(series: pd.Series) -> pd.Series: + return series.rolling(trend_window, min_periods=1).mean() + + def _vol(series: pd.Series) -> pd.Series: + return series.rolling(trend_window, min_periods=1).std(ddof=ddof) + + def _slope(series: pd.Series) -> pd.Series: + return series.rolling(trend_window, min_periods=2).apply( + _trailing_ols_slope, raw=True + ) + + if group_keys: + grouped = out.groupby(group_keys, sort=False) + out["moving_average_n"] = grouped["close"].transform(_ma) + out["volatility_n"] = grouped["change_rate"].transform(_vol) + out["amount_slope_n"] = grouped["amount"].transform(_slope) + out["change_rate_slope_n"] = grouped["change_rate"].transform(_slope) + else: + out["moving_average_n"] = _ma(out["close"]) + out["volatility_n"] = _vol(out["change_rate"]) + out["amount_slope_n"] = _slope(out["amount"]) + out["change_rate_slope_n"] = _slope(out["change_rate"]) + + return out[STOM_RL_CANONICAL_FEATURES].replace([np.inf, -np.inf], 0.0).fillna(0.0) + + +# --------------------------------------------------------------------------- +# STOM RL feature export (Page 7) — opt-in, non-invasive path. +# +# The legacy ``export`` subcommand and ``QLIB_CSV_FIELDS`` stay unchanged so +# existing Kronos/Qlib consumers are not affected. This path reads ONE symbol +# table for a small time window, decodes the source column names, maps them to +# the inputs of :func:`build_stom_rl_feature_frame`, and emits the canonical +# RL features plus a missing/scale report. It never performs a full DB scan. +# --------------------------------------------------------------------------- + +# DB-source columns required to compute the canonical RL features. Korean +# names are stored as UTF-8 in the STOM tick DB and are returned correctly by +# sqlite3's default ``text_factory`` (no cp949 round-trip needed for this DB). +STOM_RL_SOURCE_COLUMNS: Dict[str, List[str]] = { + "timestamp": ["index", "timestamps", "timestamp"], + "close": ["현재가", "종가", "close"], + "open": ["시가", "open"], + "high": ["고가", "high"], + "low": ["저가", "low"], + "buy_qty_1s": ["초당매수수량"], + "sell_qty_1s": ["초당매도수량"], + "체결강도": ["체결강도"], + "amount": ["초당거래대금", "거래대금", "당일거래대금"], + "회전율": ["회전율"], + "매수총잔량": ["매수총잔량"], + "매도총잔량": ["매도총잔량"], + "매수호가1": ["매수호가1"], + "매도호가1": ["매도호가1"], + "등락율": ["등락율"], + "시가총액": ["시가총액"], + "고저평균대비등락율": ["고저평균대비등락율"], +} + + +def _sqlite_quote_ident(name: str) -> str: + return '"' + str(name).replace('"', '""') + '"' + + +def _decode_table_columns(conn: Any, table_name: str) -> List[str]: + """Return source column names, retrying with explicit decoding if needed. + + The STOM tick DB stores Korean column names as UTF-8, which sqlite3 decodes + correctly by default. As a defensive fallback (other dumps may differ) we + re-read the raw bytes and try utf-8 then cp949 when the default names look + corrupted (contain the Unicode replacement character). + """ + + names = get_table_columns(conn, table_name) + if not any("�" in str(name) for name in names): + return list(names) + + raw_factory = conn.text_factory + try: + conn.text_factory = bytes + raw_rows = conn.execute( + f"PRAGMA table_info({_sqlite_quote_ident(table_name)})" + ).fetchall() + finally: + conn.text_factory = raw_factory + + decoded: List[str] = [] + for row in raw_rows: + raw_name = row[1] + if isinstance(raw_name, bytes): + for encoding in ("utf-8", "cp949"): + try: + decoded.append(raw_name.decode(encoding)) + break + except (UnicodeDecodeError, LookupError): + continue + else: + decoded.append(raw_name.decode("utf-8", errors="replace")) + else: + decoded.append(str(raw_name)) + return decoded + + +def _resolve_source_column(columns: Sequence[str], candidates: Sequence[str]) -> Optional[str]: + column_set = set(columns) + for candidate in candidates: + if candidate in column_set: + return candidate + return None + + +def read_stom_table_rl_source( + conn: Any, + table_name: str, + session: Optional[str] = None, + time_start: str = "090000", + time_end: str = "093000", + max_rows: int = 0, +) -> Tuple[pd.DataFrame, Dict[str, Any]]: + """Read ONE STOM symbol table window into RL feature-builder input columns. + + Returns a frame carrying ``symbol``/``session``/``timestamp`` plus the + DB-named source columns expected by :func:`build_stom_rl_feature_frame`. + The query is bounded by the time window (and optional ``max_rows``) so it + never scans the whole table — full-DB scans are out of scope for Page 7. + """ + + source_columns = _decode_table_columns(conn, table_name) + encoding_ok = not any("�" in str(name) for name in source_columns) + resolved: Dict[str, Optional[str]] = { + target: _resolve_source_column(source_columns, candidates) + for target, candidates in STOM_RL_SOURCE_COLUMNS.items() + } + timestamp_col = resolved.get("timestamp") + close_col = resolved.get("close") + if not timestamp_col or not close_col: + missing = [k for k in ("timestamp", "close") if not resolved.get(k)] + raise ValueError(f"Table {table_name} is missing required columns: {missing}") + + selected = sorted({col for col in resolved.values() if col}) + select_clause = ", ".join(_sqlite_quote_ident(col) for col in selected) + order_col = _sqlite_quote_ident(timestamp_col) + where_clauses = [] + params: List[Any] = [] + if session: + if len(session) != 8 or not session.isdigit(): + raise ValueError(f"session must be YYYYMMDD, got: {session}") + prefix_expr = f"substr(CAST({order_col} AS TEXT), 1, 8)" + where_clauses.append(f"{prefix_expr} = ?") + params.append(session) + where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + limit_sql = "" + if max_rows and max_rows > 0: + # Bound the read defensively even before the in-pandas time filter. + limit_sql = f" LIMIT {int(max_rows) * 4 + 4096}" + query = ( + f"SELECT {select_clause} FROM {_sqlite_quote_ident(table_name)}" + f"{where_sql} ORDER BY {order_col}{limit_sql}" + ) + raw = pd.read_sql_query(query, conn, params=params) + + timestamps = pd.to_datetime( + pd.to_numeric(raw[timestamp_col], errors="coerce").astype("Int64").astype(str), + format="%Y%m%d%H%M%S", + errors="coerce", + ) + frame = pd.DataFrame({"timestamp": timestamps}) + frame["symbol"] = _clean_symbol(table_name) + + close = pd.to_numeric(raw[close_col], errors="coerce") + frame["close"] = close + for ohlc in ("open", "high", "low"): + col = resolved.get(ohlc) + frame[ohlc] = pd.to_numeric(raw[col], errors="coerce") if col else close + + buy_col = resolved.get("buy_qty_1s") + sell_col = resolved.get("sell_qty_1s") + buy_qty = pd.to_numeric(raw[buy_col], errors="coerce").fillna(0.0) if buy_col else pd.Series(0.0, index=raw.index) + sell_qty = pd.to_numeric(raw[sell_col], errors="coerce").fillna(0.0) if sell_col else pd.Series(0.0, index=raw.index) + frame["초당매수수량"] = buy_qty + frame["초당매도수량"] = sell_qty + frame["volume"] = buy_qty + sell_qty + + amount_col = resolved.get("amount") + frame["amount"] = pd.to_numeric(raw[amount_col], errors="coerce").fillna(0.0) if amount_col else frame["volume"] * close.fillna(0.0) + + for passthrough in ( + "체결강도", + "회전율", + "매수총잔량", + "매도총잔량", + "매수호가1", + "매도호가1", + "등락율", + "시가총액", + "고저평균대비등락율", + ): + col = resolved.get(passthrough) + if col: + frame[passthrough] = pd.to_numeric(raw[col], errors="coerce") + + frame = frame.dropna(subset=["timestamp", "open", "high", "low", "close"]) + frame = frame[(frame[["open", "high", "low", "close"]] > 0).all(axis=1)] + + hhmmss = frame["timestamp"].dt.strftime("%H%M%S") + if time_start: + frame = frame[hhmmss >= time_start] + if time_end: + frame = frame[hhmmss <= time_end] + frame["session"] = frame["timestamp"].dt.strftime("%Y%m%d") + frame = frame.sort_values("timestamp").drop_duplicates(subset=["timestamp"], keep="last") + if max_rows and max_rows > 0: + frame = frame.head(max_rows) + frame = frame.reset_index(drop=True) + + report = { + "table": table_name, + "symbol": _clean_symbol(table_name), + "source_column_count": len(source_columns), + "column_encoding": "utf-8" if encoding_ok else "decoded-fallback", + "encoding_confirmed": bool(encoding_ok), + "resolved_source_columns": {k: v for k, v in resolved.items()}, + "unresolved_targets": [k for k, v in resolved.items() if v is None], + "row_count": int(len(frame)), + } + return frame, report + + +# --------------------------------------------------------------------------- +# Net-new RL-path 1-minute resampler (Stage 2, R1). +# +# This is NOT ``_resample_group`` (which is ``"timestamps"``-keyed, OHLCV-only, +# and would KeyError / silently drop the 12 non-OHLCV RL passthrough columns — +# manufacturing all-zero features and a FALSE null). This resampler keys on +# ``"timestamp"`` (the RL source frame's key, matching +# :func:`read_stom_table_rl_source`) and aggregates EVERY source column the +# feature builder reads, per the Stage-1 LOCKED per-column aggregation table +# (docs/stom_rl_1min_stage1_prereg_2026-05-27.md §5). It runs on the RL source +# frame BEFORE :func:`build_stom_rl_feature_frame`, so all derived features +# (amount_delta, net_buy_qty_1s, the trailing means/slopes) recompute correctly +# on the 1-minute bars. +# --------------------------------------------------------------------------- + +# Per-column 1-minute aggregation, LOCKED by Stage-1 §5. +# OHLC: open=first, high=max, low=min, close=last +# flow -> SUM: 초당매수수량, 초당매도수량, volume (derived = buy+sell), amount +# (amount = 초당거래대금 PER-SECOND, Stage-1 §2 -> SUM, no cumulative branch) +# order-book + rate/snapshot -> LAST +STOM_RL_RESAMPLE_AGG: Dict[str, str] = { + "open": "first", + "high": "max", + "low": "min", + "close": "last", + # flow -> SUM + "초당매수수량": "sum", + "초당매도수량": "sum", + # ``volume`` is DERIVED in read_stom_table_rl_source (volume = buy+sell); + # summing the per-second derived volume over a 1-min bucket is correct. + "volume": "sum", + # ``amount`` resolves to 초당거래대금 (per-second flow, Stage-1 §2) -> SUM. + "amount": "sum", + # order-book -> LAST (snapshot at the last second in the bucket) + "매수총잔량": "last", + "매도총잔량": "last", + "매수호가1": "last", + "매도호가1": "last", + # rate / snapshot -> LAST + "등락율": "last", + "회전율": "last", + "시가총액": "last", + "고저평균대비등락율": "last", + "체결강도": "last", +} + + +def resample_stom_rl_source_frame(frame: pd.DataFrame, freq: str = "1s") -> pd.DataFrame: + """Resample an RL *source* frame to the requested bar frequency. + + ``frame`` is the output of :func:`read_stom_table_rl_source` — keyed on + ``"timestamp"`` with ``symbol``/``session`` plus the DB-named source columns. + For ``freq == "1s"`` the frame is returned unchanged (the 1s path is + byte-identical, so all existing tests stay green). For ``freq == "1min"`` + each ``(symbol, session)`` group is floored to 1-minute buckets + (``floor("min")``, bucket-START labeling) and every present source column is + aggregated per :data:`STOM_RL_RESAMPLE_AGG` (the Stage-1 LOCKED table); the + resulting ``"timestamp"`` is the bucket start, so bar ``T`` carries NO value + from bar ``T+1`` (coheres with the grid-agnostic T+1 fill). + + For ``freq == "session"`` (Story B1 "daily proxy") ALL rows of one + ``(symbol, session)`` collapse into ONE bar using the SAME LOCKED per-column + aggregation (OHLC=first/max/min/last, flow/amount=SUM, book/rate=LAST). The + bucket is the ``session`` itself, so there is exactly one bar per + ``(symbol, session)``; the resulting ``"timestamp"`` is set to the session + date (midnight of ``YYYYMMDD``) so cross-session bars of *different* symbols + align on a common session-date axis (the cross-session panel grid). This is + a CAVEATED PROXY: the session window is the recorded morning ~30 min, NOT a + true close-to-close day, and symbols appear only on dates they triggered + recording (selection bias). Causal trend features still compute trailing + across the per-symbol SESSION series downstream (no look-ahead). + + Columns not present in the input are skipped; columns present but absent from + the agg table fall back to ``last`` (a point-in-time snapshot, never a future + value). + """ + + if freq not in SUPPORTED_FREQS: + raise ValueError(f"freq must be one of {sorted(SUPPORTED_FREQS)}") + if freq == "1s" or frame.empty or "timestamp" not in frame.columns: + return frame + + work = frame.copy() + work["timestamp"] = pd.to_datetime(work["timestamp"], errors="coerce") + work = work.dropna(subset=["timestamp"]) + if work.empty: + return work.reset_index(drop=True) + + if freq == SESSION_FREQ: + # ONE bar per (symbol, session): the bucket IS the calendar day of the + # session. Use the session date (parsed from the YYYYMMDD ``session`` + # string when present, else the timestamp's day) so different symbols' + # session bars land on a SHARED session-date axis for the cross-session + # panel. ``floor("D")`` of the per-row timestamp would also collapse to + # the day, but deriving from ``session`` keeps the proxy honest when a + # row's clock time and its session label disagree. + if "session" in work.columns: + session_day = pd.to_datetime( + work["session"].astype(str), format="%Y%m%d", errors="coerce" + ) + work["__bucket__"] = session_day.fillna(work["timestamp"].dt.floor("D")) + else: + work["__bucket__"] = work["timestamp"].dt.floor("D") + else: + work["__bucket__"] = work["timestamp"].dt.floor("min") + group_keys = [c for c in ("symbol", "session") if c in work.columns] + value_cols = [ + c for c in work.columns if c not in (*group_keys, "timestamp", "__bucket__") + ] + agg_map = { + col: STOM_RL_RESAMPLE_AGG.get(col, "last") for col in value_cols + } + + grouped = work.sort_values("timestamp", kind="mergesort").groupby( + [*group_keys, "__bucket__"], sort=True + ) + resampled = grouped.agg(agg_map).reset_index() + # The bucket start IS the 1-min bar timestamp (bucket-START labeling). + resampled = resampled.rename(columns={"__bucket__": "timestamp"}) + ordered = ["timestamp", *group_keys, *value_cols] + resampled = resampled[[c for c in ordered if c in resampled.columns]] + return resampled.sort_values("timestamp", kind="mergesort").reset_index(drop=True) + + +def _missing_scale_report(features: pd.DataFrame) -> Dict[str, Any]: + column_stats: Dict[str, Any] = {} + total = int(len(features)) + has_nan = False + has_inf = False + for column in features.columns: + series = pd.to_numeric(features[column], errors="coerce") + null_count = int(series.isna().sum()) + inf_count = int(np.isinf(series.to_numpy(dtype="float64", na_value=np.nan)).sum()) + has_nan = has_nan or null_count > 0 + has_inf = has_inf or inf_count > 0 + finite = series.replace([np.inf, -np.inf], np.nan).dropna() + column_stats[column] = { + "null_rate": (null_count / total) if total else 0.0, + "null_count": null_count, + "inf_count": inf_count, + "min": float(finite.min()) if not finite.empty else None, + "max": float(finite.max()) if not finite.empty else None, + "mean": float(finite.mean()) if not finite.empty else None, + } + return { + "row_count": total, + "feature_count": int(features.shape[1]), + "has_nan": has_nan, + "has_inf": has_inf, + "nan_inf_clean": (not has_nan) and (not has_inf), + "columns": column_stats, + } + + +def export_stom_rl_features( + db_path: os.PathLike | str, + output_dir: os.PathLike | str, + table: str, + session: Optional[str] = None, + time_start: str = "090000", + time_end: str = "093000", + max_rows: int = 0, + tick_size: float = 1.0, + freq: str = "1s", +) -> Dict[str, Any]: + """Export the canonical STOM RL features for ONE symbol/time window. + + Writes ``_rl_features.csv`` (the canonical features plus + ``timestamp``/``symbol``/``session`` keys) and a missing/scale report JSON. + The legacy Qlib export and ``QLIB_CSV_FIELDS`` are left untouched. + + ``freq`` is accepted for parity with the live feed path (default ``"1s"`` + leaves the frame unchanged). NOTE: this CSV emitter is NOT the feed path — + the live walk-forward feed resamples inside :func:`build_panel_from_db` + (``stom_rl/panel_join.py``). ``freq`` here only affects the standalone CSV. + """ + + out_dir = Path(output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + conn = connect_readonly(db_path) + try: + source_frame, source_report = read_stom_table_rl_source( + conn, + table, + session=session, + time_start=time_start, + time_end=time_end, + max_rows=max_rows, + ) + finally: + conn.close() + + if source_frame.empty: + raise ValueError( + f"No rows for table={table} session={session} window={time_start}-{time_end}. " + "Widen the time window or pick a session with data." + ) + + # Resample the RL SOURCE frame BEFORE the feature builder so derived features + # (amount_delta, net_buy_qty_1s, trailing means/slopes) recompute on bars. + source_frame = resample_stom_rl_source_frame(source_frame, freq=freq) + features = build_stom_rl_feature_frame(source_frame, tick_size=tick_size) + keyed = pd.concat( + [source_frame[["timestamp", "symbol", "session"]].reset_index(drop=True), features.reset_index(drop=True)], + axis=1, + ) + csv_name = f"{source_report['symbol']}_rl_features.csv" + csv_path = out_dir / csv_name + keyed.to_csv(csv_path, index=False, encoding="utf-8-sig") + + scale_report = _missing_scale_report(features) + report = { + "mode": "stom_rl_feature_export", + "db_path": str(db_path), + "output_dir": str(out_dir), + "csv_path": str(csv_path), + "canonical_features": list(STOM_RL_CANONICAL_FEATURES), + "config": { + "table": table, + "session": session, + "time_start": time_start, + "time_end": time_end, + "max_rows": max_rows, + "tick_size": tick_size, + "freq": freq, + }, + "source": source_report, + "scale": scale_report, + } + _write_json(out_dir / "stom_rl_feature_report.json", report) + return report + + +def _validate_ratios(train_ratio: float, val_ratio: float, test_ratio: float) -> None: + total = train_ratio + val_ratio + test_ratio + if total <= 0: + raise ValueError("At least one split ratio must be positive.") + if any(r < 0 for r in [train_ratio, val_ratio, test_ratio]): + raise ValueError("Split ratios must be non-negative.") + if not math.isclose(total, 1.0, rel_tol=1e-6, abs_tol=1e-6): + raise ValueError(f"Split ratios must sum to 1.0; got {total:.6f}") + + +def _resample_group(group: pd.DataFrame, freq: str) -> pd.DataFrame: + if freq not in SUPPORTED_FREQS: + raise ValueError(f"freq must be one of {sorted(SUPPORTED_FREQS)}") + group = group.sort_values("timestamps").copy() + if freq == "1s": + return group + + # 1min aggregation keeps standard OHLCV semantics and sums flow fields. + group["bucket"] = group["timestamps"].dt.floor("min") + out = ( + group.groupby(["symbol", "session", "bucket"], sort=True) + .agg( + open=("open", "first"), + high=("high", "max"), + low=("low", "min"), + close=("close", "last"), + volume=("volume", "sum"), + amount=("amount", "sum"), + ) + .reset_index() + .rename(columns={"bucket": "timestamps"}) + ) + return out + + +def _split_count(total: int, train_ratio: float, val_ratio: float, test_ratio: float) -> Tuple[int, int]: + train_end = int(total * train_ratio) + val_end = int(total * (train_ratio + val_ratio)) + + if train_ratio > 0 and train_end == 0 and total > 0: + train_end = 1 + if val_ratio > 0 and val_end <= train_end and total > train_end: + val_end = train_end + 1 + if test_ratio > 0 and val_end >= total and total > train_end: + val_end = max(train_end, total - 1) + return train_end, val_end + + +def _split_items( + items: Sequence[ExportItem], + train_ratio: float, + val_ratio: float, + test_ratio: float, + split_by: str = "session", +) -> Dict[str, List[ExportItem]]: + _validate_ratios(train_ratio, val_ratio, test_ratio) + if split_by not in SUPPORTED_SPLIT_STRATEGIES: + raise ValueError(f"split_by must be one of {sorted(SUPPORTED_SPLIT_STRATEGIES)}") + + ordered = sorted(items, key=lambda item: (item[2], item[0])) + if split_by == "group": + train_end, val_end = _split_count(len(ordered), train_ratio, val_ratio, test_ratio) + return { + "train": ordered[:train_end], + "val": ordered[train_end:val_end], + "test": ordered[val_end:], + } + + sessions = sorted({session for _, session, _, _, _ in ordered}) + train_end, val_end = _split_count(len(sessions), train_ratio, val_ratio, test_ratio) + split_sessions = { + "train": set(sessions[:train_end]), + "val": set(sessions[train_end:val_end]), + "test": set(sessions[val_end:]), + } + + return { + split_name: [item for item in ordered if item[1] in session_set] + for split_name, session_set in split_sessions.items() + } + + +def _split_session_summary(split_items: Dict[str, List[ExportItem]]) -> Dict[str, List[str]]: + return { + split_name: sorted({session for _, session, _, _, _ in split_rows}) + for split_name, split_rows in split_items.items() + } + + +def _session_time(session: Any, hhmmss: Optional[str]) -> Optional[pd.Timestamp]: + if not hhmmss: + return None + text = str(hhmmss) + if len(text) != 6 or not text.isdigit(): + raise ValueError(f"Expected HHMMSS time, got: {hhmmss}") + return pd.to_datetime(f"{session}{text}", format="%Y%m%d%H%M%S", errors="raise") + + +def _regularize_group_to_1s( + group: pd.DataFrame, + time_start: Optional[str] = None, + time_end: Optional[str] = None, +) -> Tuple[pd.DataFrame, Dict[str, Any]]: + """Reindex a symbol/session group to a true one-second grid without leading look-ahead fill.""" + + if group.empty: + return group.copy(), {"input_rows": 0, "output_rows": 0, "inserted_rows": 0} + + ordered = group.sort_values("timestamps").drop_duplicates("timestamps", keep="last").copy() + session = str(ordered["session"].iloc[0]) + start_ts = _session_time(session, time_start) or ordered["timestamps"].min() + end_ts = _session_time(session, time_end) or ordered["timestamps"].max() + start_ts = max(pd.Timestamp(start_ts), pd.Timestamp(ordered["timestamps"].min())) + end_ts = min(pd.Timestamp(end_ts), pd.Timestamp(ordered["timestamps"].max()) if not time_end else pd.Timestamp(end_ts)) + if end_ts < start_ts: + return ordered, { + "input_rows": int(len(group)), + "output_rows": int(len(ordered)), + "inserted_rows": 0, + "warning": "regularize_1s skipped because computed end is before start", + } + + full_index = pd.date_range(start=start_ts, end=end_ts, freq="1s") + indexed = ordered.set_index("timestamps").reindex(full_index) + indexed.index.name = "timestamps" + indexed["symbol"] = indexed["symbol"].ffill() + indexed["session"] = indexed["session"].ffill() + price_columns = ["open", "high", "low", "close"] + indexed[price_columns] = indexed[price_columns].ffill() + indexed[["volume", "amount"]] = indexed[["volume", "amount"]].fillna(0.0) + indexed = indexed.dropna(subset=["symbol", "session", *price_columns]) + out = indexed.reset_index() + return out[DEFAULT_GROUP_COLUMNS + ["timestamps"] + ["open", "high", "low", "close", "volume", "amount"]], { + "input_rows": int(len(ordered)), + "output_rows": int(len(out)), + "inserted_rows": int(max(len(out) - len(ordered), 0)), + "start_timestamp": None if out.empty else str(out["timestamps"].iloc[0]), + "end_timestamp": None if out.empty else str(out["timestamps"].iloc[-1]), + } + + +def _to_kronos_pickle_frame(group: pd.DataFrame) -> pd.DataFrame: + out = group[["timestamps", "open", "high", "low", "close", "volume", "amount"]].copy() + out = out.rename(columns={"timestamps": "datetime", "volume": "vol", "amount": "amt"}) + out["datetime"] = pd.to_datetime(out["datetime"]) + for column in KRONOS_PICKLE_COLUMNS: + out[column] = pd.to_numeric(out[column], errors="coerce") + out = out.dropna(subset=["datetime", *KRONOS_PICKLE_COLUMNS]).sort_values("datetime") + return out.set_index("datetime")[KRONOS_PICKLE_COLUMNS] + + +def _to_qlib_dump_csv_frame(instrument: str, group: pd.DataFrame) -> pd.DataFrame: + out = group[["timestamps", "open", "high", "low", "close", "volume", "amount"]].copy() + out = out.rename(columns={"timestamps": "date"}) + out["date"] = pd.to_datetime(out["date"]).dt.strftime("%Y-%m-%d %H:%M:%S") + out["symbol"] = instrument + out["money"] = pd.to_numeric(out["amount"], errors="coerce").fillna(0.0) + out["factor"] = 1.0 + return out[["symbol", "date", *QLIB_CSV_FIELDS]] + + +def _write_json(path: Path, payload: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _format_command(command: Sequence[str]) -> str: + return " ".join(f'"{part}"' if " " in str(part) else str(part) for part in command) + + +def check_qlib_environment(dump_bin_script: Optional[Path] = None) -> Dict[str, Any]: + """Return pyqlib/dump_bin readiness without requiring pyqlib to be installed.""" + + qlib_spec = importlib.util.find_spec("qlib") + qlib_version = None + qlib_error = None + if qlib_spec is not None: + try: + import qlib # type: ignore + + qlib_version = getattr(qlib, "__version__", "unknown") + except Exception as exc: # pragma: no cover - depends on local pyqlib install + qlib_error = repr(exc) + + candidates: List[Path] = [] + if dump_bin_script is not None: + candidates.append(Path(dump_bin_script)) + env_dump_bin = os.environ.get("QLIB_DUMP_BIN_SCRIPT") + if env_dump_bin: + candidates.append(Path(env_dump_bin)) + if qlib_spec is not None and qlib_spec.origin: + qlib_origin = Path(qlib_spec.origin).resolve() + candidates.extend( + [ + qlib_origin.parent / "scripts" / "dump_bin.py", + qlib_origin.parent.parent / "scripts" / "dump_bin.py", + ] + ) + candidates.extend( + [ + PROJECT_ROOT / "scripts" / "dump_bin.py", + PROJECT_ROOT / "qlib" / "scripts" / "dump_bin.py", + PROJECT_ROOT / ".omx" / "external" / "qlib" / "scripts" / "dump_bin.py", + ] + ) + existing_script = next((path.resolve() for path in candidates if path.exists()), None) + + return { + "qlib_installed": qlib_spec is not None and qlib_error is None, + "qlib_origin": None if qlib_spec is None else qlib_spec.origin, + "qlib_version": qlib_version, + "qlib_error": qlib_error, + "dump_bin_script_found": existing_script is not None, + "dump_bin_script": None if existing_script is None else str(existing_script), + "recommended_install_command": "python -m pip install pyqlib", + "recommended_dump_bin_source": "Clone microsoft/qlib or point --dump-bin-script to qlib/scripts/dump_bin.py", + } + + +def build_dump_bin_command( + csv_path: Path, + qlib_dir: Path, + dump_bin_script: Optional[Path] = None, + include_fields: Optional[Sequence[str]] = None, + date_field_name: str = "date", + symbol_field_name: str = "symbol", + freq: Optional[str] = None, +) -> List[str]: + script = Path(dump_bin_script) if dump_bin_script else Path("scripts") / "dump_bin.py" + command = [ + sys.executable, + str(script), + "dump_all", + "--data_path", + str(csv_path), + "--qlib_dir", + str(qlib_dir), + "--date_field_name", + date_field_name, + "--symbol_field_name", + symbol_field_name, + "--include_fields", + ",".join(include_fields or QLIB_CSV_FIELDS), + ] + if freq: + command.extend(["--freq", freq]) + return command + + +def run_dump_bin_from_report( + export_report_path: Path, + qlib_dir: Optional[Path] = None, + dump_bin_script: Optional[Path] = None, + execute: bool = False, + freq: Optional[str] = None, +) -> Dict[str, Any]: + report = json.loads(Path(export_report_path).read_text(encoding="utf-8")) + csv_path = Path(report["qlib_csv_dir"]) + target_dir = Path(qlib_dir) if qlib_dir else Path(report["output_dir"]) / "qlib_bin" + effective_freq = freq or report.get("config", {}).get("freq") + command = build_dump_bin_command( + csv_path=csv_path, + qlib_dir=target_dir, + dump_bin_script=dump_bin_script, + include_fields=QLIB_CSV_FIELDS, + freq=effective_freq, + ) + result: Dict[str, Any] = { + "mode": "qlib_dump_bin", + "export_report_path": str(export_report_path), + "csv_path": str(csv_path), + "qlib_dir": str(target_dir), + "command": command, + "command_text": _format_command(command), + "executed": execute, + } + if not execute: + result["status"] = "dry_run" + return result + + script = Path(command[1]) + if not script.is_absolute(): + script = PROJECT_ROOT / script + command[1] = str(script) + result["command"] = command + result["command_text"] = _format_command(command) + if not script.exists(): + raise FileNotFoundError( + f"dump_bin.py not found: {script}. " + "Install/clone microsoft/qlib and pass --dump-bin-script, or run qlib-env-check first." + ) + completed = subprocess.run( + command, + cwd=PROJECT_ROOT, + text=True, + capture_output=True, + check=False, + encoding="utf-8", + errors="replace", + ) + stdout = completed.stdout or "" + stderr = completed.stderr or "" + result.update( + { + "returncode": completed.returncode, + "stdout": stdout[-4000:], + "stderr": stderr[-4000:], + "status": "ok" if completed.returncode == 0 else "failed", + } + ) + if completed.returncode != 0: + raise RuntimeError(f"dump_bin failed with exit code {completed.returncode}: {stderr[-1000:]}") + return result + + +def smoke_qlib_provider(provider_uri: Path, region: str = "cn", freq: str = "day") -> Dict[str, Any]: + """Initialize pyqlib provider and load a small calendar sample.""" + + if freq.lower() in PYQLIB_PROVIDER_UNSUPPORTED_FREQS: + raise ValueError( + "pyqlib provider does not support second-level freq such as '1s'. " + "Use --freq 1min for Qlib provider smoke, or use the generated " + "processed_datasets pickles for Kronos 1-second fine-tuning." + ) + + try: + import qlib # type: ignore + from qlib.config import REG_CN, REG_US # type: ignore + from qlib.data import D # type: ignore + except Exception as exc: + raise RuntimeError("pyqlib is not installed or cannot be imported. Run: python -m pip install pyqlib") from exc + + region_map = {"cn": REG_CN, "us": REG_US} + qlib.init(provider_uri=str(provider_uri), region=region_map.get(region.lower(), REG_CN)) + calendar = D.calendar(freq=freq) + sample = [str(item) for item in calendar[: min(len(calendar), 5)]] + return { + "mode": "qlib_provider_smoke", + "provider_uri": str(provider_uri), + "region": region, + "freq": freq, + "calendar_count": int(len(calendar)), + "calendar_sample": sample, + } + + +def export_stom_to_qlib(config: StomQlibExportConfig) -> Dict[str, Any]: + """Export STOM DB rows to Qlib dump-ready CSV and Kronos QlibDataset pickles.""" + + predict_window = config.horizon_seconds if config.horizon_seconds is not None else config.predict_window + if config.horizon_seconds is not None and config.freq != "1s": + raise ValueError("--horizon-seconds is only valid for --freq 1s exports.") + if config.regularize_1s and config.freq != "1s": + raise ValueError("--regularize-1s is only valid with --freq 1s.") + min_rows = config.lookback_window + predict_window + 1 + output_dir = Path(config.output_dir) + qlib_csv_dir = output_dir / "qlib_csv" + processed_dir = output_dir / "processed_datasets" + meta_dir = output_dir / "meta" + for directory in [qlib_csv_dir, processed_dir, meta_dir]: + directory.mkdir(parents=True, exist_ok=True) + + conn = connect_readonly(config.db_path) + items: List[ExportItem] = [] + table_reports: List[Dict[str, Any]] = [] + warnings: List[str] = [] + grid_summary = {"regularized_groups": 0, "inserted_rows": 0} + try: + selected_tables = list(config.tables) if config.tables else list_stock_tables(conn, max_tables=None) + if config.max_tables and config.max_tables > 0: + selected_tables = selected_tables[: config.max_tables] + + for table in selected_tables: + if config.max_groups and len(items) >= config.max_groups: + break + try: + frame, mapping = read_stom_table_as_kline( + conn, + table, + price_mode=config.price_mode, + time_start=config.time_start, + time_end=config.time_end, + session_start=config.session_start, + session_end=config.session_end, + ) + mapping_warnings = mapping.get("warnings", []) + warnings.extend(str(w) for w in mapping_warnings) + written_groups = 0 + written_rows = 0 + skipped_groups = 0 + table_grid_inserted_rows = 0 + for (symbol, session), group in frame.groupby(DEFAULT_GROUP_COLUMNS, sort=True): + if config.max_groups and len(items) >= config.max_groups: + break + group = _resample_group(group, config.freq) + if config.regularize_1s: + group, group_grid = _regularize_group_to_1s( + group, + time_start=config.time_start, + time_end=config.time_end, + ) + grid_summary["regularized_groups"] += 1 + grid_summary["inserted_rows"] += int(group_grid.get("inserted_rows", 0)) + table_grid_inserted_rows += int(group_grid.get("inserted_rows", 0)) + if config.max_rows_per_group and config.max_rows_per_group > 0: + group = group.head(config.max_rows_per_group) + if len(group) < min_rows: + skipped_groups += 1 + continue + + instrument = _instrument_key(symbol, session) + group = group.sort_values("timestamps").reset_index(drop=True) + qlib_frame = _to_qlib_dump_csv_frame(instrument, group) + qlib_frame.to_csv(qlib_csv_dir / f"{instrument}.csv", index=False, encoding="utf-8") + + pickle_frame = _to_kronos_pickle_frame(group) + if len(pickle_frame) < min_rows: + skipped_groups += 1 + continue + + items.append( + ( + instrument, + str(session), + pickle_frame.index.min(), + pickle_frame.index.max(), + pickle_frame, + ) + ) + written_groups += 1 + written_rows += len(pickle_frame) + + table_reports.append( + { + "table": table, + "written_groups": written_groups, + "written_rows": written_rows, + "skipped_groups": skipped_groups, + "regularized_inserted_rows": table_grid_inserted_rows, + "mapping": {k: v for k, v in mapping.items() if k != "warnings"}, + } + ) + except Exception as exc: # pragma: no cover - real DB diagnostics + table_reports.append({"table": table, "error": str(exc)}) + finally: + conn.close() + + if not items: + raise ValueError( + f"No STOM groups were exportable for min_rows={min_rows}. " + "Lower lookback/predict windows or export more rows." + ) + + split_items = _split_items(items, config.train_ratio, config.val_ratio, config.test_ratio, split_by=config.split_by) + split_sessions = _split_session_summary(split_items) + split_counts: Dict[str, Dict[str, int]] = {} + for split_name, split_rows in split_items.items(): + split_payload = {instrument: frame for instrument, _, _, _, frame in split_rows} + with (processed_dir / f"{split_name}_data.pkl").open("wb") as f: + pickle.dump(split_payload, f) + split_counts[split_name] = { + "groups": len(split_rows), + "rows": int(sum(len(frame) for _, _, _, _, frame in split_rows)), + "sessions": len(split_sessions.get(split_name, [])), + } + + calendar = sorted({ts for _, _, _, _, frame in items for ts in frame.index}) + calendar_path = meta_dir / f"calendar_{config.freq}.txt" + calendar_path.write_text("\n".join(ts.strftime("%Y-%m-%d %H:%M:%S") for ts in calendar) + "\n", encoding="utf-8") + + instruments_path = meta_dir / "instruments_all.txt" + instruments_path.write_text( + "\n".join( + f"{instrument}\t{start.strftime('%Y-%m-%d %H:%M:%S')}\t{end.strftime('%Y-%m-%d %H:%M:%S')}" + for instrument, _, start, end, _ in sorted(items, key=lambda item: item[0]) + ) + + "\n", + encoding="utf-8", + ) + + dump_command = ( + "python scripts/dump_bin.py dump_all " + f"--data_path {qlib_csv_dir.as_posix()} " + f"--qlib_dir {(output_dir / 'qlib_bin').as_posix()} " + "--date_field_name date --symbol_field_name symbol " + f"--include_fields {','.join(QLIB_CSV_FIELDS)} " + f"--freq {config.freq}" + ) + (meta_dir / "qlib_dump_bin_command.txt").write_text(dump_command + "\n", encoding="utf-8") + + report = { + "mode": "stom_to_qlib_export", + "config": {**asdict(config), "effective_predict_window": predict_window}, + "min_rows_per_group": min_rows, + "split_strategy": config.split_by, + "output_dir": str(output_dir), + "qlib_csv_dir": str(qlib_csv_dir), + "processed_dataset_dir": str(processed_dir), + "calendar_path": str(calendar_path), + "instruments_path": str(instruments_path), + "qlib_dump_bin_command": dump_command, + "selected_table_count": len(config.tables) if config.tables else (config.max_tables or "all"), + "exported_group_count": len(items), + "exported_row_count": int(sum(len(frame) for _, _, _, _, frame in items)), + "split_counts": split_counts, + "split_sessions": split_sessions, + "grid_summary": grid_summary, + "warnings": sorted(set(warnings)), + "tables": table_reports, + } + _write_json(output_dir / "stom_qlib_export_report.json", report) + return report + + +def _pct_max_drawdown(equity: Sequence[float]) -> float: + peak = -float("inf") + max_dd = 0.0 + for value in equity: + peak = max(peak, value) + if peak > 0: + max_dd = min(max_dd, value / peak - 1.0) + return float(max_dd * 100.0) + + +def _load_prediction_latest(prediction_csv: Path) -> pd.DataFrame: + df = pd.read_csv(prediction_csv, dtype={"symbol": str, "session": str}, encoding="utf-8-sig") + required = {"window_id", "symbol", "session", "asof_timestamp", "pred_return_window", "actual_return_window"} + missing = sorted(required - set(df.columns)) + if missing: + raise ValueError(f"Prediction CSV missing required columns: {missing}") + df["asof_timestamp"] = pd.to_datetime(df["asof_timestamp"], errors="coerce") + sort_columns = ["window_id"] + if "horizon_step" in df.columns: + sort_columns.append("horizon_step") + latest = df.sort_values(sort_columns).groupby("window_id").tail(1).copy() + for column in ["pred_return_window", "actual_return_window", "direction_hit_window"]: + if column in latest.columns: + latest[column] = pd.to_numeric(latest[column], errors="coerce") + latest = latest.dropna(subset=["asof_timestamp", "pred_return_window", "actual_return_window"]) + return latest + + +def run_score_backtest( + prediction_csv: Path, + output_dir: Path, + top_k: int = 10, + cost_bps: float = 0.0, + slippage_bps: float = 0.0, + score_column: str = "pred_return_window", +) -> Dict[str, Any]: + """Run a deterministic Qlib-style Top-K backtest from prediction CSV scores.""" + + if top_k <= 0: + raise ValueError("top_k must be positive") + latest = _load_prediction_latest(prediction_csv) + if score_column not in latest.columns: + raise ValueError(f"score_column not found: {score_column}") + + output_dir.mkdir(parents=True, exist_ok=True) + cost_pct = (cost_bps + slippage_bps) * 0.01 + rows: List[Dict[str, Any]] = [] + curve_rows: List[Dict[str, Any]] = [] + previous_symbols: set[str] = set() + equity = 1.0 + equity_values = [equity] + + for timestamp, group in latest.groupby("asof_timestamp", sort=True): + selected = group.sort_values(score_column, ascending=False).head(top_k).copy() + if selected.empty: + continue + symbols = set(selected["symbol"].astype(str)) + gross = float(selected["actual_return_window"].mean()) + net = gross - cost_pct + hit_rate = float((selected["actual_return_window"] > 0).mean()) + direction_hit = ( + float(selected["direction_hit_window"].mean()) + if "direction_hit_window" in selected.columns + else float("nan") + ) + turnover = 1.0 + if previous_symbols: + turnover = 1.0 - len(symbols & previous_symbols) / max(len(symbols), 1) + previous_symbols = symbols + equity *= 1.0 + net / 100.0 + equity_values.append(equity) + curve_rows.append( + { + "asof_timestamp": pd.Timestamp(timestamp).isoformat(), + "gross_return_pct": gross, + "net_return_pct": net, + "hit_rate": hit_rate, + "direction_hit": direction_hit, + "turnover": turnover, + "equity": equity, + } + ) + for rank, (_, row) in enumerate(selected.iterrows(), start=1): + hit_value = row.get("direction_hit_window") if "direction_hit_window" in row else None + direction_hit = None if pd.isna(hit_value) else int(hit_value) + rows.append( + { + "asof_timestamp": pd.Timestamp(timestamp).isoformat(), + "rank": rank, + "symbol": row.get("symbol"), + "session": row.get("session"), + "window_id": int(row.get("window_id")), + "score": float(row[score_column]), + "actual_return_pct": float(row["actual_return_window"]), + "net_return_pct": float(row["actual_return_window"] - cost_pct), + "direction_hit": direction_hit, + } + ) + + if not curve_rows: + raise ValueError("No backtest periods were generated.") + + curve = pd.DataFrame(curve_rows) + trades = pd.DataFrame(rows) + returns = curve["net_return_pct"].astype(float) + sharpe = 0.0 + if len(returns) > 1 and returns.std(ddof=1) > 0: + sharpe = float((returns.mean() / returns.std(ddof=1)) * math.sqrt(len(returns))) + + metrics = { + "mode": "qlib_style_topk", + "source_prediction_csv": str(prediction_csv), + "top_k": top_k, + "score_column": score_column, + "cost_bps": cost_bps, + "slippage_bps": slippage_bps, + "period_count": int(len(curve)), + "trade_count": int(len(trades)), + "avg_trades_per_period": float(len(trades) / len(curve)), + "avg_gross_return_pct": float(curve["gross_return_pct"].mean()), + "avg_net_return_pct": float(curve["net_return_pct"].mean()), + "hit_rate": float((trades["actual_return_pct"] > 0).mean()), + "direction_hit_rate": float(pd.to_numeric(trades["direction_hit"], errors="coerce").mean()), + "avg_turnover": float(curve["turnover"].mean()), + "cumulative_return_pct": float((equity - 1.0) * 100.0), + "max_drawdown_pct": _pct_max_drawdown(equity_values), + "sharpe_per_period": sharpe, + } + warnings: List[str] = [] + if metrics["avg_trades_per_period"] < top_k: + warnings.append( + "Average selected trades per period is below top_k. " + "For true Qlib cross-sectional Top-K, generate predictions for multiple symbols at the same asof_timestamp." + ) + payload = { + "metrics": metrics, + "warnings": warnings, + "curve": curve_rows, + "top_trades": rows[: min(len(rows), 500)], + } + stem = prediction_csv.stem + json_path = output_dir / f"{stem}.qlib_topk{top_k}.json" + curve_path = output_dir / f"{stem}.qlib_topk{top_k}.curve.csv" + trades_path = output_dir / f"{stem}.qlib_topk{top_k}.trades.csv" + _write_json(json_path, payload) + curve.to_csv(curve_path, index=False, encoding="utf-8") + trades.to_csv(trades_path, index=False, encoding="utf-8") + return { + **payload, + "artifact_paths": { + "json": str(json_path), + "curve_csv": str(curve_path), + "trades_csv": str(trades_path), + }, + } + + +def _parse_tables(raw: Optional[str]) -> Optional[List[str]]: + if not raw: + return None + return [part.strip() for part in raw.split(",") if part.strip()] + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="STOM -> Qlib/Kronos pilot pipeline") + sub = parser.add_subparsers(dest="command", required=True) + + export = sub.add_parser("export", help="Export STOM DB to Qlib dump-ready CSV and QlibDataset pickles") + export.add_argument("--db", required=True) + export.add_argument("--output-dir", required=True) + export.add_argument("--max-tables", type=int, default=0) + export.add_argument("--tables", default=None, help="Comma-separated table list") + export.add_argument("--lookback-window", type=int, default=300) + export.add_argument("--predict-window", type=int, default=60) + export.add_argument("--price-mode", choices=["db_ohlc", "close_only"], default="close_only") + export.add_argument("--time-start", default="090000") + export.add_argument("--time-end", default="093000") + export.add_argument("--session-start", default=None, help="Optional inclusive YYYYMMDD session lower bound.") + export.add_argument("--session-end", default=None, help="Optional inclusive YYYYMMDD session upper bound.") + export.add_argument("--max-rows-per-group", type=int, default=0) + export.add_argument("--max-groups", type=int, default=0) + export.add_argument("--freq", choices=sorted(SUPPORTED_FREQS), default="1s") + export.add_argument( + "--regularize-1s", + action="store_true", + help="Reindex 1-second exports to a strict 1-second grid. Prices forward-fill; missing volume/amount become 0.", + ) + export.add_argument( + "--split-by", + choices=sorted(SUPPORTED_SPLIT_STRATEGIES), + default="session", + help="Split train/val/test by chronological session dates by default to reduce time-series leakage.", + ) + export.add_argument( + "--horizon-seconds", + type=int, + default=None, + help="For 1-second exports, use this exact second horizon as the effective predict window.", + ) + export.add_argument("--train-ratio", type=float, default=0.70) + export.add_argument("--val-ratio", type=float, default=0.15) + export.add_argument("--test-ratio", type=float, default=0.15) + + export_rl = sub.add_parser( + "export-stom-rl", + help="Export the 14 canonical STOM RL features for one symbol/time window (non-invasive, no full DB scan)", + ) + export_rl.add_argument("--db", required=True) + export_rl.add_argument("--output-dir", required=True) + export_rl.add_argument("--table", required=True, help="Single symbol table name, e.g. 000020") + export_rl.add_argument("--session", default=None, help="Optional YYYYMMDD session filter.") + export_rl.add_argument("--time-start", default="090000") + export_rl.add_argument("--time-end", default="093000") + export_rl.add_argument("--max-rows", type=int, default=0, help="Cap rows after filtering. 0 means keep all.") + export_rl.add_argument("--tick-size", type=float, default=1.0) + + backtest = sub.add_parser("score-backtest", help="Run Qlib-style Top-K backtest from Kronos prediction CSV") + backtest.add_argument("--prediction-csv", required=True) + backtest.add_argument("--output-dir", default="webui/qlib_backtests") + backtest.add_argument("--top-k", type=int, default=10) + backtest.add_argument("--cost-bps", type=float, default=0.0) + backtest.add_argument("--slippage-bps", type=float, default=0.0) + backtest.add_argument("--score-column", default="pred_return_window") + + env_check = sub.add_parser("qlib-env-check", help="Check optional pyqlib/dump_bin readiness") + env_check.add_argument("--dump-bin-script", default=None) + + dump_bin = sub.add_parser("dump-bin", help="Build or execute Qlib dump_bin command from an export report") + dump_bin.add_argument("--export-report", required=True) + dump_bin.add_argument("--qlib-dir", default=None) + dump_bin.add_argument("--dump-bin-script", default=None) + dump_bin.add_argument("--freq", default=None) + dump_bin.add_argument("--execute", action="store_true") + + provider = sub.add_parser("provider-smoke", help="Initialize pyqlib provider and read a calendar sample") + provider.add_argument("--provider-uri", required=True) + provider.add_argument("--region", choices=["cn", "us"], default="cn") + provider.add_argument("--freq", default="day") + + return parser + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = build_arg_parser().parse_args(argv) + if args.command == "export": + report = export_stom_to_qlib( + StomQlibExportConfig( + db_path=args.db, + output_dir=args.output_dir, + max_tables=args.max_tables, + tables=_parse_tables(args.tables), + lookback_window=args.lookback_window, + predict_window=args.predict_window, + price_mode=args.price_mode, + time_start=args.time_start, + time_end=args.time_end, + session_start=args.session_start, + session_end=args.session_end, + max_rows_per_group=args.max_rows_per_group, + max_groups=args.max_groups, + freq=args.freq, + regularize_1s=args.regularize_1s, + split_by=args.split_by, + horizon_seconds=args.horizon_seconds, + train_ratio=args.train_ratio, + val_ratio=args.val_ratio, + test_ratio=args.test_ratio, + ) + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + if args.command == "export-stom-rl": + report = export_stom_rl_features( + db_path=args.db, + output_dir=args.output_dir, + table=args.table, + session=args.session, + time_start=args.time_start, + time_end=args.time_end, + max_rows=args.max_rows, + tick_size=args.tick_size, + ) + print(json.dumps(report, ensure_ascii=False, indent=2)) + return 0 + if args.command == "score-backtest": + result = run_score_backtest( + prediction_csv=Path(args.prediction_csv), + output_dir=Path(args.output_dir), + top_k=args.top_k, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + score_column=args.score_column, + ) + print( + json.dumps( + result["metrics"] + | {"warnings": result.get("warnings", []), "artifact_paths": result["artifact_paths"]}, + ensure_ascii=False, + indent=2, + ) + ) + return 0 + if args.command == "qlib-env-check": + result = check_qlib_environment(Path(args.dump_bin_script) if args.dump_bin_script else None) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "dump-bin": + result = run_dump_bin_from_report( + export_report_path=Path(args.export_report), + qlib_dir=Path(args.qlib_dir) if args.qlib_dir else None, + dump_bin_script=Path(args.dump_bin_script) if args.dump_bin_script else None, + execute=args.execute, + freq=args.freq, + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + if args.command == "provider-smoke": + result = smoke_qlib_provider(Path(args.provider_uri), region=args.region, freq=args.freq) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + raise ValueError(f"Unknown command: {args.command}") + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune/run_stom_1s_finetune.py b/finetune/run_stom_1s_finetune.py new file mode 100644 index 000000000..7cff6dde1 --- /dev/null +++ b/finetune/run_stom_1s_finetune.py @@ -0,0 +1,562 @@ +"""Launch reproducible STOM 1-second Kronos QlibDataset fine-tuning runs.""" + +from __future__ import annotations + +import argparse +from collections import deque +import json +import os +import subprocess +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_EXPORT_ROOT = PROJECT_ROOT / "finetune" / "qlib_exports" +DEFAULT_OUTPUT_ROOT = PROJECT_ROOT / "finetune" / "outputs" +DEFAULT_TOKENIZER = "NeoQuasar/Kronos-Tokenizer-base" +DEFAULT_PREDICTOR = "NeoQuasar/Kronos-small" +try: + from .training_progress import TrainingProgressTracker, build_dry_run_progress +except ImportError: # pragma: no cover - direct script execution path + from training_progress import TrainingProgressTracker, build_dry_run_progress + +STOM_1S_FULL_SAMPLE_POOLS = { + 30: {"train": 75_277_195, "val": 16_275_307}, + 60: {"train": 73_718_875, "val": 15_938_107}, +} +SAMPLE_STAGE_PRESETS = { + "budget_20k": {"train": 20_000, "val": 4_000}, + "expand_200k": {"train": 200_000, "val": 40_000}, + "expand_1m": {"train": 1_000_000, "val": 100_000}, + "expand_5m": {"train": 5_000_000, "val": 250_000}, + "full_window": None, +} +TRAIN_STAGES = {"tokenizer", "predictor", "both"} +RESUME_STAGES = {"tokenizer", "predictor"} +TOKENIZER_CHECKPOINT_POLICIES = {"best_then_latest", "best", "latest_train_model"} + + +def _utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _mode_default(value: Optional[int], mode: str, smoke: int, stage: int, full: int) -> int: + if value is not None: + return value + if mode == "smoke": + return smoke + if mode == "stage": + return stage + return full + + +def _stage_override( + base_value: Optional[int], + tokenizer_value: Optional[int], + predictor_value: Optional[int], + train_stage: str, +) -> Optional[int]: + """Resolve a per-stage CLI override while preserving the existing shared default.""" + + if train_stage == "tokenizer" and tokenizer_value is not None: + return tokenizer_value + if train_stage == "predictor" and predictor_value is not None: + return predictor_value + return base_value + + +def _tail(text: str, limit: int = 8000) -> str: + return text[-limit:] if len(text) > limit else text + + +def sample_stage_budget(stage: Optional[str], horizon: int) -> Optional[Dict[str, int]]: + if not stage: + return None + if stage == "full_window": + return dict(STOM_1S_FULL_SAMPLE_POOLS[horizon]) + preset = SAMPLE_STAGE_PRESETS.get(stage) + if preset is None: + raise ValueError(f"Unknown sample stage: {stage}") + return dict(preset) + + +def _checkpoint_has_model_weights(path: Path) -> bool: + """Return True when a Hugging Face checkpoint folder has model weights.""" + + if not path.is_dir(): + return False + return any((path / name).exists() for name in ("model.safetensors", "pytorch_model.bin")) + + +def resolve_tokenizer_handoff_checkpoint( + save_path: Path, + tokenizer_save_folder: str, + policy: str, +) -> tuple[Path, str]: + """Pick the tokenizer checkpoint used by the predictor handoff. + + Full STOM runs save ``latest_train_model`` before tokenizer validation. + Validation can be much slower than training when the validation batch size + is forced to 1 for OOM safety, so the default policy keeps the official + ``best_model`` path when it exists and safely falls back to the trained + pre-validation checkpoint when it is the only available checkpoint. + """ + + checkpoint_root = save_path / tokenizer_save_folder / "checkpoints" + best_model = checkpoint_root / "best_model" + latest_train_model = checkpoint_root / "latest_train_model" + if policy == "best": + return best_model, "best_model" + if policy == "latest_train_model": + return latest_train_model, "latest_train_model" + if policy != "best_then_latest": + raise ValueError(f"Unknown tokenizer checkpoint policy: {policy}") + if _checkpoint_has_model_weights(best_model): + return best_model, "best_model" + if _checkpoint_has_model_weights(latest_train_model): + return latest_train_model, "latest_train_model" + return best_model, "best_model_expected" + + +def build_run( + horizon: int, + args: argparse.Namespace, + mode: str, + train_stage: Optional[str] = None, +) -> Dict[str, Any]: + dataset_dir = Path(args.dataset_dir) if args.dataset_dir else ( + DEFAULT_EXPORT_ROOT / f"stom_1s_grid_pred{horizon}_full" / "processed_datasets" + ) + dataset_dir = dataset_dir.resolve() + if not dataset_dir.exists(): + raise FileNotFoundError(f"processed_datasets not found: {dataset_dir}") + for required in ["train_data.pkl", "val_data.pkl"]: + if not (dataset_dir / required).exists(): + raise FileNotFoundError(f"required dataset file missing: {dataset_dir / required}") + + sample_budget = sample_stage_budget(args.sample_stage, horizon) + run_suffix = args.sample_stage or mode + run_name = args.run_name or f"stom_1s_grid_pred{horizon}_{run_suffix}" + save_path = (Path(args.output_root).resolve() if args.output_root else DEFAULT_OUTPUT_ROOT) / run_name + log_dir = save_path / "logs" + normalized_stage = train_stage or getattr(args, "train_stage", "predictor") + if normalized_stage == "both": + normalized_stage = "predictor" + requested_train_stage = getattr(args, "train_stage", normalized_stage) + stage_count = 2 if requested_train_stage == "both" else 1 + if stage_count == 2: + stage_index = 1 if normalized_stage == "tokenizer" else 2 + else: + stage_index = 1 + manifest_name = "run_manifest.json" if normalized_stage == "predictor" else f"{normalized_stage}_run_manifest.json" + manifest_path = save_path / manifest_name + + epochs = _mode_default(args.epochs, mode, smoke=1, stage=1, full=1) + batch_size_arg = _stage_override( + args.batch_size, + args.tokenizer_batch_size, + args.predictor_batch_size, + normalized_stage, + ) + num_workers = _stage_override( + args.num_workers, + args.tokenizer_num_workers, + args.predictor_num_workers, + normalized_stage, + ) + batch_size = _mode_default(batch_size_arg, mode, smoke=1, stage=4, full=4) + tokenizer_val_batch_size = _mode_default( + args.tokenizer_val_batch_size, + mode, + smoke=1, + stage=2, + full=1, + ) + default_train = sample_budget["train"] if sample_budget else _mode_default(None, mode, smoke=2, stage=512, full=20_000) + default_val = sample_budget["val"] if sample_budget else _mode_default(None, mode, smoke=2, stage=128, full=4_000) + n_train_iter = args.n_train_iter if args.n_train_iter is not None else default_train + n_val_iter = args.n_val_iter if args.n_val_iter is not None else default_val + log_interval = _mode_default(args.log_interval, mode, smoke=1, stage=25, full=100) + + env = { + "KRONOS_DATASET_PATH": str(dataset_dir), + "KRONOS_LOOKBACK_WINDOW": str(args.lookback_window), + "KRONOS_PREDICT_WINDOW": str(horizon), + "KRONOS_EPOCHS": str(epochs), + "KRONOS_BATCH_SIZE": str(batch_size), + "KRONOS_N_TRAIN_ITER": str(n_train_iter), + "KRONOS_N_VAL_ITER": str(n_val_iter), + "KRONOS_LOG_INTERVAL": str(log_interval), + "KRONOS_NUM_WORKERS": str(num_workers), + "KRONOS_USE_COMET": "0", + "KRONOS_SAVE_PATH": str(save_path), + "KRONOS_TOKENIZER_SAVE_FOLDER": args.tokenizer_save_folder, + "KRONOS_PREDICTOR_SAVE_FOLDER": args.predictor_save_folder, + "KRONOS_PRETRAINED_TOKENIZER_PATH": args.pretrained_tokenizer_path, + "KRONOS_PRETRAINED_PREDICTOR_PATH": args.pretrained_predictor_path, + "KRONOS_DDP_BACKEND": args.ddp_backend, + "KRONOS_DATASET_SAMPLE_MODE": args.dataset_sample_mode, + "USE_LIBUV": "0", + "PYTHONUNBUFFERED": "1", + } + if normalized_stage == "tokenizer": + env["KRONOS_TOKENIZER_VAL_BATCH_SIZE"] = str(tokenizer_val_batch_size) + env["KRONOS_TOKENIZER_SAVE_PRE_VAL_CHECKPOINT"] = "1" + env["KRONOS_TOKENIZER_EMPTY_CACHE_BEFORE_VAL"] = "1" + # ── GPU 최대 활용 최적화 (opt-in flags propagation) ────── + if getattr(args, "persistent_workers", False): + env["KRONOS_PERSISTENT_WORKERS"] = "1" + prefetch_factor_val = getattr(args, "prefetch_factor", None) + if prefetch_factor_val is not None: + env["KRONOS_PREFETCH_FACTOR"] = str(prefetch_factor_val) + if getattr(args, "tokenizer_amp", False): + env["KRONOS_TOKENIZER_AMP"] = "1" + env["KRONOS_TOKENIZER_AMP_DTYPE"] = str(getattr(args, "tokenizer_amp_dtype", "bf16")) + if getattr(args, "tokenizer_compile", False): + env["KRONOS_TOKENIZER_COMPILE"] = "1" + env["KRONOS_TOKENIZER_COMPILE_MODE"] = str(getattr(args, "tokenizer_compile_mode", "reduce-overhead")) + if getattr(args, "tokenizer_compile_fullgraph", False): + env["KRONOS_TOKENIZER_COMPILE_FULLGRAPH"] = "1" + if args.nproc_per_node == 1: + env.update( + { + "RANK": "0", + "WORLD_SIZE": "1", + "LOCAL_RANK": "0", + "MASTER_ADDR": args.master_addr, + "MASTER_PORT": str(args.master_port), + "KRONOS_DISABLE_DDP": "1", + } + ) + tokenizer_handoff_checkpoint: Optional[Path] = None + tokenizer_handoff_source: Optional[str] = None + if args.finetuned_tokenizer_path: + tokenizer_handoff_checkpoint = Path(args.finetuned_tokenizer_path).resolve() + tokenizer_handoff_source = "explicit" + env["KRONOS_FINETUNED_TOKENIZER_PATH"] = str(tokenizer_handoff_checkpoint) + elif normalized_stage == "predictor" and getattr(args, "train_stage", "predictor") == "both": + tokenizer_handoff_checkpoint, tokenizer_handoff_source = resolve_tokenizer_handoff_checkpoint( + save_path, + args.tokenizer_save_folder, + getattr(args, "tokenizer_checkpoint_policy", "best_then_latest"), + ) + env["KRONOS_FINETUNED_TOKENIZER_PATH"] = str(tokenizer_handoff_checkpoint) + if args.finetuned_predictor_path: + env["KRONOS_FINETUNED_PREDICTOR_PATH"] = str(Path(args.finetuned_predictor_path).resolve()) + + if args.nproc_per_node == 1: + script_name = "train_tokenizer.py" if normalized_stage == "tokenizer" else "train_predictor.py" + command = [sys.executable, str(PROJECT_ROOT / "finetune" / script_name)] + else: + script_name = "train_tokenizer.py" if normalized_stage == "tokenizer" else "train_predictor.py" + command = [ + sys.executable, + "-m", + "torch.distributed.run", + "--standalone", + "--nproc_per_node", + str(args.nproc_per_node), + str(PROJECT_ROOT / "finetune" / script_name), + ] + + return { + "horizon": horizon, + "mode": mode, + "train_stage": normalized_stage, + "requested_train_stage": requested_train_stage, + "stage_index": stage_index, + "stage_count": stage_count, + "sample_stage": args.sample_stage, + "target_train_samples": n_train_iter, + "target_val_samples": n_val_iter, + "known_full_sample_pool": STOM_1S_FULL_SAMPLE_POOLS.get(horizon), + "run_name": run_name, + "dataset_dir": str(dataset_dir), + "save_path": str(save_path), + "log_dir": str(log_dir), + "manifest_path": str(manifest_path), + "command": command, + "env": env, + "tokenizer_checkpoint_policy": getattr(args, "tokenizer_checkpoint_policy", "best_then_latest"), + "tokenizer_handoff_checkpoint": str(tokenizer_handoff_checkpoint) if tokenizer_handoff_checkpoint else None, + "tokenizer_handoff_source": tokenizer_handoff_source, + } + + +def execute_run(spec: Mapping[str, Any], dry_run: bool = False) -> Dict[str, Any]: + save_path = Path(str(spec["save_path"])) + log_dir = Path(str(spec["log_dir"])) + manifest_path = Path(str(spec["manifest_path"])) + log_dir.mkdir(parents=True, exist_ok=True) + manifest_path.parent.mkdir(parents=True, exist_ok=True) + stage_name = str(spec.get("train_stage", "train")) + stdout_path = log_dir / f"{stage_name}.stdout.log" + stderr_path = log_dir / f"{stage_name}.stderr.log" + progress_path = log_dir / f"{stage_name}.progress.json" + + payload: Dict[str, Any] = { + "created_at": _utc_now(), + "status": "dry_run" if dry_run else "running", + **{k: v for k, v in spec.items() if k not in {"env"}}, + "env_overrides": dict(spec["env"]), + "progress_path": str(progress_path), + "stdout_log": str(stdout_path), + "stderr_log": str(stderr_path), + } + manifest_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + if dry_run: + stdout_path.write_text( + f"dry-run only; {spec.get('train_stage', 'training')} command was not executed.\n", + encoding="utf-8", + ) + stderr_path.write_text("dry-run only; no stderr was produced.\n", encoding="utf-8") + build_dry_run_progress(spec, progress_path, stdout_path, stderr_path, manifest_path) + return payload + + env = os.environ.copy() + env.update({str(k): str(v) for k, v in spec["env"].items()}) + env.setdefault("PYTHONIOENCODING", "utf-8") + env.setdefault("PYTHONUTF8", "1") + started = datetime.now(timezone.utc) + tracker = TrainingProgressTracker( + spec=spec, + progress_path=progress_path, + stdout_path=stdout_path, + stderr_path=stderr_path, + manifest_path=manifest_path, + ) + stdout_tail: deque[str] = deque(maxlen=400) + stderr_note = "stderr is merged into stdout so the live dashboard can stream one ordered log.\n" + stderr_path.write_text(stderr_note, encoding="utf-8") + + try: + process = subprocess.Popen( + list(spec["command"]), + cwd=PROJECT_ROOT, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + encoding="utf-8", + errors="replace", + bufsize=1, + ) + except Exception as exc: + tracker.fail_before_start(str(exc)) + payload.update( + { + "completed_at": _utc_now(), + "duration_seconds": (datetime.now(timezone.utc) - started).total_seconds(), + "returncode": -1, + "status": "failed", + "stdout_tail": "", + "stderr_tail": str(exc), + } + ) + manifest_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + raise + + tracker.start(process.pid) + with stdout_path.open("w", encoding="utf-8", buffering=1) as stdout_file: + if process.stdout is not None: + for line in process.stdout: + stdout_file.write(line) + stdout_file.flush() + stdout_tail.append(line) + tracker.observe_line(line) + print(line, end="", flush=True) + returncode = process.wait() + tracker.finish(returncode) + + payload.update( + { + "completed_at": _utc_now(), + "duration_seconds": (datetime.now(timezone.utc) - started).total_seconds(), + "returncode": returncode, + "status": "ok" if returncode == 0 else "failed", + "stdout_log": str(stdout_path), + "stderr_log": str(stderr_path), + "progress_path": str(progress_path), + "stdout_tail": _tail("".join(stdout_tail)), + "stderr_tail": stderr_note, + } + ) + summary_folder_key = ( + "KRONOS_TOKENIZER_SAVE_FOLDER" if spec.get("train_stage") == "tokenizer" else "KRONOS_PREDICTOR_SAVE_FOLDER" + ) + summary_path = save_path / str(spec["env"][summary_folder_key]) / "summary.json" + if summary_path.exists(): + payload["summary_path"] = str(summary_path) + payload["summary"] = json.loads(summary_path.read_text(encoding="utf-8")) + manifest_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + if returncode != 0: + raise RuntimeError( + f"{spec.get('train_stage', 'predictor')} fine-tuning failed for pred{spec['horizon']}; see {stdout_path}" + ) + return payload + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run STOM 1-second Kronos fine-tuning from QlibDataset pickles.") + parser.add_argument("--horizon", choices=["30", "60", "all"], default="all") + parser.add_argument("--mode", choices=["smoke", "stage", "full"], default="stage") + parser.add_argument( + "--train-stage", + choices=sorted(TRAIN_STAGES), + default="predictor", + help="Run tokenizer, predictor, or tokenizer then predictor. Official Kronos fine-tuning uses both.", + ) + parser.add_argument( + "--start-stage", + choices=sorted(RESUME_STAGES), + default=None, + help="Resume a --train-stage both run from tokenizer or predictor while preserving 2-stage progress.", + ) + parser.add_argument( + "--sample-stage", + choices=sorted(SAMPLE_STAGE_PRESETS), + default=None, + help=( + "Optional staged full-data training budget. " + "Use budget_20k -> expand_200k -> expand_1m -> expand_5m -> full_window." + ), + ) + parser.add_argument("--dataset-dir", default=None, help="Override processed_datasets directory; only valid for one horizon.") + parser.add_argument("--output-root", default=str(DEFAULT_OUTPUT_ROOT)) + parser.add_argument("--run-name", default=None, help="Override run name; only valid for one horizon.") + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--epochs", type=int, default=None) + parser.add_argument("--batch-size", type=int, default=None) + parser.add_argument( + "--tokenizer-batch-size", + type=int, + default=None, + help="Tokenizer-only batch-size override for --train-stage both handoff runs.", + ) + parser.add_argument( + "--tokenizer-val-batch-size", + type=int, + default=None, + help=( + "Tokenizer validation-only batch-size override. " + "Defaults to 1 in full mode to avoid post-training CUDA OOM." + ), + ) + parser.add_argument( + "--tokenizer-checkpoint-policy", + choices=sorted(TOKENIZER_CHECKPOINT_POLICIES), + default="best_then_latest", + help=( + "Predictor handoff policy for --train-stage both. " + "best_then_latest uses best_model when present and latest_train_model when validation has not finished." + ), + ) + parser.add_argument( + "--predictor-batch-size", + type=int, + default=None, + help="Predictor-only batch-size override for --train-stage both handoff runs.", + ) + # ── GPU 최대 활용 최적화 옵션 (opt-in, default 는 기존 동작 유지) ── + parser.add_argument( + "--persistent-workers", + action="store_true", + help="DataLoader persistent_workers=True (num_workers > 0 일 때만 효과).", + ) + parser.add_argument( + "--prefetch-factor", + type=int, + default=None, + help="DataLoader prefetch_factor (default 2). num_workers > 0 일 때만 효과.", + ) + parser.add_argument( + "--tokenizer-amp", + action="store_true", + help="Tokenizer 학습 시 mixed precision (autocast) 사용. dtype 은 --tokenizer-amp-dtype.", + ) + parser.add_argument( + "--tokenizer-amp-dtype", + choices=["bf16", "fp16", "fp32"], + default="bf16", + help="AMP dtype. bf16 권장 (4080 SUPER 가속 + GradScaler 불필요).", + ) + parser.add_argument( + "--tokenizer-compile", + action="store_true", + help="Tokenizer 모델을 torch.compile 로 래핑. 첫 epoch 컴파일 오버헤드 발생.", + ) + parser.add_argument( + "--tokenizer-compile-mode", + choices=["default", "reduce-overhead", "max-autotune"], + default="reduce-overhead", + help="torch.compile mode. reduce-overhead 가 일반 학습에 적합.", + ) + parser.add_argument( + "--tokenizer-compile-fullgraph", + action="store_true", + help="torch.compile fullgraph=True. Kronos rotary attention 호환 실패 시 비활성화 필요.", + ) + parser.add_argument("--n-train-iter", type=int, default=None) + parser.add_argument("--n-val-iter", type=int, default=None) + parser.add_argument("--log-interval", type=int, default=None) + parser.add_argument("--num-workers", type=int, default=0) + parser.add_argument( + "--tokenizer-num-workers", + type=int, + default=None, + help="Tokenizer-only DataLoader worker override for --train-stage both handoff runs.", + ) + parser.add_argument( + "--predictor-num-workers", + type=int, + default=None, + help="Predictor-only DataLoader worker override for --train-stage both handoff runs.", + ) + parser.add_argument( + "--dataset-sample-mode", + choices=["sample_random", "full_sequential"], + default="sample_random", + help="sample_random keeps the original Kronos demo behavior; full_sequential makes dataset idx authoritative.", + ) + parser.add_argument("--nproc-per-node", type=int, default=1) + parser.add_argument("--ddp-backend", default="gloo") + parser.add_argument("--master-addr", default="127.0.0.1") + parser.add_argument("--master-port", type=int, default=29531) + parser.add_argument("--pretrained-tokenizer-path", default=DEFAULT_TOKENIZER) + parser.add_argument("--pretrained-predictor-path", default=DEFAULT_PREDICTOR) + parser.add_argument("--finetuned-tokenizer-path", default=None) + parser.add_argument("--finetuned-predictor-path", default=None) + parser.add_argument("--tokenizer-save-folder", default="finetune_tokenizer") + parser.add_argument("--predictor-save-folder", default="finetune_predictor") + parser.add_argument("--dry-run", action="store_true") + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + if args.dataset_dir and args.horizon == "all": + raise ValueError("--dataset-dir can only be used with --horizon 30 or --horizon 60") + if args.run_name and args.horizon == "all": + raise ValueError("--run-name can only be used with --horizon 30 or --horizon 60") + if args.start_stage and args.train_stage != "both": + raise ValueError("--start-stage is only valid with --train-stage both") + + horizons: List[int] = [30, 60] if args.horizon == "all" else [int(args.horizon)] + results = [] + for horizon in horizons: + stages = ["tokenizer", "predictor"] if args.train_stage == "both" else [args.train_stage] + if args.start_stage: + stages = stages[stages.index(args.start_stage):] + for stage in stages: + spec = build_run(horizon, args, args.mode, train_stage=stage) + result = execute_run(spec, dry_run=args.dry_run) + results.append(result) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune/search_stom_1s_filters.py b/finetune/search_stom_1s_filters.py new file mode 100644 index 000000000..40f9e615a --- /dev/null +++ b/finetune/search_stom_1s_filters.py @@ -0,0 +1,727 @@ +"""Search prediction-time STOM 1s score filters for Top-K diagnostics.""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from itertools import product +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence + +import numpy as np +import pandas as pd + + +def _utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + result = float(value) + except (TypeError, ValueError): + return default + if np.isnan(result) or np.isinf(result): + return default + return result + + +def _json_safe(value: Any) -> Any: + if isinstance(value, dict): + return {key: _json_safe(item) for key, item in value.items()} + if isinstance(value, list): + return [_json_safe(item) for item in value] + if isinstance(value, (np.integer, np.floating)): + return _json_safe(value.item()) + if isinstance(value, float) and (np.isnan(value) or np.isinf(value)): + return None + return value + + +@dataclass(frozen=True) +class FilterSpec: + min_pred_return: float + min_consistency: float + max_pred_range: Optional[float] + min_amount_quantile: float + max_volatility: Optional[float] + + @property + def name(self) -> str: + range_part = "none" if self.max_pred_range is None else f"{self.max_pred_range:g}" + vol_part = "none" if self.max_volatility is None else f"{self.max_volatility:g}" + return ( + f"ret>={self.min_pred_return:g}|cons>={self.min_consistency:g}|" + f"range<={range_part}|amt_q>={self.min_amount_quantile:g}|vol<={vol_part}" + ) + + +BASELINE_SPEC = FilterSpec(-999.0, 0.0, None, 0.0, None) + + +def generate_filter_specs() -> List[FilterSpec]: + return [ + FilterSpec(*values) + for values in product( + [-0.10, -0.05, 0.0, 0.02, 0.05, 0.10], + [0.0, 0.50, 0.60, 0.70, 0.80], + [None, 0.05, 0.10, 0.20, 0.50, 1.00], + [0.0, 0.25, 0.50, 0.75], + [None, 0.05, 0.10, 0.20, 0.50, 1.00], + ) + ] + + +def _spec_from_row(row: Mapping[str, Any]) -> FilterSpec: + def optional_float(value: Any) -> Optional[float]: + if value is None: + return None + try: + result = float(value) + except (TypeError, ValueError): + return None + if np.isnan(result) or np.isinf(result): + return None + return result + + return FilterSpec( + min_pred_return=_safe_float(row.get("min_pred_return")), + min_consistency=_safe_float(row.get("min_consistency")), + max_pred_range=optional_float(row.get("max_pred_range")), + min_amount_quantile=_safe_float(row.get("min_amount_quantile")), + max_volatility=optional_float(row.get("max_volatility")), + ) + + +def _latest_windows(df: pd.DataFrame) -> pd.DataFrame: + required = {"window_id", "asof_timestamp", "pred_return_window", "actual_return_window"} + missing = sorted(required - set(df.columns)) + if missing: + raise ValueError(f"Prediction CSV missing required columns: {missing}") + + latest = df.sort_values(["window_id", "horizon_step" if "horizon_step" in df.columns else "target_timestamp"]) + latest = latest.groupby("window_id", sort=False).tail(1).copy() + latest["asof_timestamp"] = pd.to_datetime(latest["asof_timestamp"], errors="coerce") + numeric_defaults = { + "pred_return_window": 0.0, + "actual_return_window": 0.0, + "direction_hit_window": 0.0, + "pred_path_consistency": 0.0, + "pred_range_pct": 0.0, + "history_mean_amount": 0.0, + "history_volatility_pct": 0.0, + } + for column, default in numeric_defaults.items(): + if column not in latest.columns: + latest[column] = default + latest[column] = pd.to_numeric(latest[column], errors="coerce").fillna(default) + return latest + + +def _selected_rows(latest: pd.DataFrame, spec: FilterSpec, top_k: int) -> pd.DataFrame: + amount_threshold = float(latest["history_mean_amount"].quantile(spec.min_amount_quantile)) + mask = ( + (latest["pred_return_window"] >= spec.min_pred_return) + & (latest["pred_path_consistency"] >= spec.min_consistency) + & (latest["history_mean_amount"] >= amount_threshold) + ) + if spec.max_pred_range is not None: + mask &= latest["pred_range_pct"] <= spec.max_pred_range + if spec.max_volatility is not None: + mask &= latest["history_volatility_pct"] <= spec.max_volatility + candidates = latest[mask] + if candidates.empty: + return candidates + return ( + candidates.sort_values(["asof_timestamp", "pred_return_window"], ascending=[True, False]) + .groupby("asof_timestamp", sort=True) + .head(top_k) + .reset_index(drop=True) + ) + + +def _metrics(selected: pd.DataFrame, latest: pd.DataFrame, spec: FilterSpec, top_k: int, cost_pct: float) -> Dict[str, Any]: + payload = asdict(spec) + payload["filter_name"] = spec.name + payload["top_k"] = top_k + payload["period_count_total"] = int(latest["asof_timestamp"].nunique()) + payload["window_count_total"] = int(len(latest)) + if selected.empty: + payload.update( + { + "period_count": 0, + "trade_count": 0, + "avg_trades_per_period": 0.0, + "avg_gross_return_pct": 0.0, + "avg_net_return_pct": 0.0, + "direction_hit_rate": 0.0, + "win_rate": 0.0, + "coverage": 0.0, + "cumulative_return_pct": 0.0, + } + ) + return payload + + period_returns = [] + for _, group in selected.groupby("asof_timestamp", sort=True): + gross = float(group["actual_return_window"].mean()) + period_returns.append(gross - cost_pct) + cumulative = float(np.prod([1.0 + value / 100.0 for value in period_returns]) - 1.0) * 100.0 + payload.update( + { + "period_count": int(selected["asof_timestamp"].nunique()), + "trade_count": int(len(selected)), + "avg_trades_per_period": float(len(selected) / max(selected["asof_timestamp"].nunique(), 1)), + "avg_gross_return_pct": float(selected["actual_return_window"].mean()), + "avg_net_return_pct": float(np.mean(period_returns)), + "direction_hit_rate": float(selected["direction_hit_window"].mean()), + "win_rate": float((selected["actual_return_window"] > 0).mean()), + "coverage": float(selected["asof_timestamp"].nunique() / max(latest["asof_timestamp"].nunique(), 1)), + "cumulative_return_pct": cumulative, + } + ) + return payload + + +def _evaluate_specs(latest: pd.DataFrame, specs: Sequence[FilterSpec], top_k: int, cost_pct: float) -> pd.DataFrame: + rows = [] + for spec in specs: + selected = _selected_rows(latest, spec, top_k=top_k) + rows.append(_metrics(selected, latest, spec, top_k=top_k, cost_pct=cost_pct)) + return pd.DataFrame(rows) + + +def _rank_feasible_results( + all_results: pd.DataFrame, + min_trades: int, + min_periods: int, + min_coverage: float, +) -> pd.DataFrame: + feasible = all_results[ + (all_results["trade_count"] >= min_trades) + & (all_results["period_count"] >= min_periods) + & (all_results["coverage"] >= min_coverage) + ].copy() + if feasible.empty: + feasible = all_results.copy() + return feasible.sort_values( + ["avg_net_return_pct", "cumulative_return_pct", "direction_hit_rate", "trade_count"], + ascending=[False, False, False, False], + ) + + +def _sorted_periods(latest: pd.DataFrame) -> List[pd.Timestamp]: + periods = latest["asof_timestamp"].dropna().drop_duplicates().sort_values() + return [pd.Timestamp(value) for value in periods.tolist()] + + +def _period_subset(latest: pd.DataFrame, periods: Sequence[pd.Timestamp]) -> pd.DataFrame: + period_set = set(pd.Timestamp(value) for value in periods) + return latest[latest["asof_timestamp"].isin(period_set)].copy() + + +def _iso_timestamp(value: pd.Timestamp) -> str: + return pd.Timestamp(value).isoformat() + + +def _mean_float(values: pd.Series) -> float: + if values.empty: + return 0.0 + return float(pd.to_numeric(values, errors="coerce").fillna(0.0).mean()) + + +def _weighted_mean(frame: pd.DataFrame, value_column: str, weight_column: str) -> float: + if frame.empty: + return 0.0 + values = pd.to_numeric(frame[value_column], errors="coerce").fillna(0.0) + weights = pd.to_numeric(frame[weight_column], errors="coerce").fillna(0.0) + weight_sum = float(weights.sum()) + if weight_sum <= 0: + return 0.0 + return float((values * weights).sum() / weight_sum) + + +def _parse_bps_grid(raw: str) -> List[float]: + values = [] + for part in str(raw).split(","): + text = part.strip() + if not text: + continue + value = float(text) + if value < 0: + raise ValueError("Cost bps values must be non-negative") + values.append(value) + if not values: + raise ValueError("At least one cost bps value is required") + return sorted(dict.fromkeys(values)) + + +def _cost_pct_from_total_bps(total_bps: float) -> float: + return float(total_bps) * 0.01 + + +def _shift_net_to_total_cost( + net_return_pct: Any, + original_total_cost_bps: float, + target_total_cost_bps: float, +) -> float: + return _safe_float(net_return_pct) + _cost_pct_from_total_bps(original_total_cost_bps) - _cost_pct_from_total_bps( + target_total_cost_bps + ) + + +def _scenario_passes(summary: Mapping[str, Any], thresholds: Mapping[str, float]) -> bool: + return bool( + _safe_float(summary.get("avg_test_net_return_pct")) >= _safe_float(thresholds.get("min_avg_test_net_pct")) + and _safe_float(summary.get("positive_test_fold_rate")) >= _safe_float( + thresholds.get("min_positive_test_fold_rate") + ) + and _safe_float(summary.get("avg_test_improvement_net_pct")) >= _safe_float( + thresholds.get("min_improvement_net_pct") + ) + and _safe_float(summary.get("total_test_trade_count")) >= _safe_float(thresholds.get("min_total_test_trades")) + ) + + +def _filter_cost_sensitivity( + filter_report: Mapping[str, Any], + total_cost_bps_grid: Sequence[float], +) -> List[Dict[str, Any]]: + rows = [] + original_total_cost_bps = _safe_float(filter_report.get("cost_bps")) + _safe_float(filter_report.get("slippage_bps")) + candidates = [] + baseline = dict(filter_report.get("baseline_topk") or {}) + baseline["candidate_type"] = "baseline_topk" + candidates.append(baseline) + best = dict(filter_report.get("best_filter") or {}) + if best: + best["candidate_type"] = "filter" + candidates.append(best) + for row in filter_report.get("top_filters") or []: + candidate = dict(row) + candidate["candidate_type"] = "filter" + candidates.append(candidate) + + for total_cost_bps in total_cost_bps_grid: + ranked = [] + for candidate in candidates: + gross = _safe_float(candidate.get("avg_gross_return_pct")) + ranked.append( + { + "total_cost_bps": float(total_cost_bps), + "candidate_type": candidate.get("candidate_type", "filter"), + "filter_name": candidate.get("filter_name", ""), + "period_count": int(_safe_float(candidate.get("period_count"))), + "trade_count": int(_safe_float(candidate.get("trade_count"))), + "coverage": _safe_float(candidate.get("coverage")), + "avg_gross_return_pct": gross, + "avg_net_return_pct": _shift_net_to_total_cost( + candidate.get("avg_net_return_pct"), original_total_cost_bps, total_cost_bps + ), + "direction_hit_rate": _safe_float(candidate.get("direction_hit_rate")), + } + ) + ranked.sort( + key=lambda row: ( + row["avg_net_return_pct"], + row["avg_gross_return_pct"], + row["direction_hit_rate"], + row["trade_count"], + ), + reverse=True, + ) + if ranked: + rows.append(ranked[0]) + return rows + + +def _rolling_cost_sensitivity( + rolling_report: Mapping[str, Any], + total_cost_bps_grid: Sequence[float], + thresholds: Mapping[str, float], +) -> List[Dict[str, Any]]: + original_total_cost_bps = _safe_float(rolling_report.get("cost_bps")) + _safe_float( + rolling_report.get("slippage_bps") + ) + folds = list(rolling_report.get("folds") or []) + rows = [] + for total_cost_bps in total_cost_bps_grid: + adjusted_folds = [] + for fold in folds: + test_net = _shift_net_to_total_cost( + fold.get("test_avg_net_return_pct"), original_total_cost_bps, total_cost_bps + ) + train_net = _shift_net_to_total_cost( + fold.get("train_avg_net_return_pct"), original_total_cost_bps, total_cost_bps + ) + baseline_test_net = _shift_net_to_total_cost( + fold.get("baseline_test_avg_net_return_pct"), original_total_cost_bps, total_cost_bps + ) + adjusted_folds.append( + { + "fold": fold.get("fold"), + "selected_filter": fold.get("selected_filter"), + "train_avg_net_return_pct": train_net, + "test_avg_net_return_pct": test_net, + "baseline_test_avg_net_return_pct": baseline_test_net, + "test_vs_baseline_net_pct": test_net - baseline_test_net, + "test_trade_count": int(_safe_float(fold.get("test_trade_count"))), + "test_direction_hit_rate": _safe_float(fold.get("test_direction_hit_rate")), + } + ) + + fold_frame = pd.DataFrame(adjusted_folds) + if fold_frame.empty: + summary = { + "total_cost_bps": float(total_cost_bps), + "fold_count": 0, + "total_test_trade_count": 0, + "avg_train_net_return_pct": 0.0, + "avg_test_net_return_pct": 0.0, + "avg_test_baseline_net_return_pct": 0.0, + "avg_test_improvement_net_pct": 0.0, + "test_direction_hit_rate_weighted": 0.0, + "positive_test_fold_rate": 0.0, + "overfit_gap_pct": 0.0, + } + else: + avg_train = _mean_float(fold_frame["train_avg_net_return_pct"]) + avg_test = _mean_float(fold_frame["test_avg_net_return_pct"]) + summary = { + "total_cost_bps": float(total_cost_bps), + "fold_count": int(len(adjusted_folds)), + "total_test_trade_count": int(fold_frame["test_trade_count"].sum()), + "avg_train_net_return_pct": avg_train, + "avg_test_net_return_pct": avg_test, + "avg_test_baseline_net_return_pct": _mean_float(fold_frame["baseline_test_avg_net_return_pct"]), + "avg_test_improvement_net_pct": _mean_float(fold_frame["test_vs_baseline_net_pct"]), + "test_direction_hit_rate_weighted": _weighted_mean( + fold_frame, "test_direction_hit_rate", "test_trade_count" + ), + "positive_test_fold_rate": float((fold_frame["test_avg_net_return_pct"] > 0).mean()), + "overfit_gap_pct": avg_train - avg_test, + } + summary["passes_gate"] = _scenario_passes(summary, thresholds) + rows.append(summary) + return rows + + +def build_cost_gate_report( + filter_report_path: Path, + rolling_report_path: Path, + total_cost_bps_grid: Sequence[float], + target_total_cost_bps: Optional[float] = None, + min_avg_test_net_pct: float = 0.0, + min_positive_test_fold_rate: float = 0.5, + min_improvement_net_pct: float = 0.0, + min_total_test_trades: int = 100, +) -> Dict[str, Any]: + """Build a cost-sensitivity gate from existing filter-search and rolling artifacts. + + This reuses already generated validation artifacts, so it is safe to run + before launching expensive staged GPU fine-tuning. + """ + + filter_report = json.loads(filter_report_path.read_text(encoding="utf-8")) + rolling_report = json.loads(rolling_report_path.read_text(encoding="utf-8")) + cost_grid = list(total_cost_bps_grid) + target_cost = float(target_total_cost_bps if target_total_cost_bps is not None else max(cost_grid)) + thresholds = { + "min_avg_test_net_pct": float(min_avg_test_net_pct), + "min_positive_test_fold_rate": float(min_positive_test_fold_rate), + "min_improvement_net_pct": float(min_improvement_net_pct), + "min_total_test_trades": float(min_total_test_trades), + } + filter_sensitivity = _filter_cost_sensitivity(filter_report, cost_grid) + rolling_sensitivity = _rolling_cost_sensitivity(rolling_report, cost_grid, thresholds) + target = min(rolling_sensitivity, key=lambda row: abs(row["total_cost_bps"] - target_cost)) + expand_allowed = bool(target.get("passes_gate")) + return { + "created_at": _utc_now(), + "artifact_type": "cost_sensitivity_gate", + "filter_report": str(filter_report_path), + "rolling_report": str(rolling_report_path), + "total_cost_bps_grid": [float(value) for value in cost_grid], + "target_total_cost_bps": target["total_cost_bps"], + "gate_thresholds": thresholds, + "filter_cost_sensitivity": filter_sensitivity, + "rolling_cost_sensitivity": rolling_sensitivity, + "target_cost_summary": target, + "expand_training_allowed": expand_allowed, + "decision": "allow_expand_200k" if expand_allowed else "hold_expand_200k", + "decision_reason": ( + "target cost scenario passed rolling net, fold-rate, improvement, and trade-count gates" + if expand_allowed + else "target cost scenario failed at least one rolling profitability/stability gate" + ), + } + + +def search_filters( + prediction_csv: Path, + top_k: int = 5, + cost_bps: float = 15.0, + slippage_bps: float = 10.0, + min_trades: int = 10, + min_periods: int = 3, + min_coverage: float = 0.5, +) -> Dict[str, Any]: + df = pd.read_csv(prediction_csv, dtype={"symbol": str, "session": str}, encoding="utf-8-sig") + latest = _latest_windows(df) + cost_pct = (cost_bps + slippage_bps) * 0.01 + feasible = _rank_feasible_results( + _evaluate_specs(latest, generate_filter_specs(), top_k=top_k, cost_pct=cost_pct), + min_trades=min_trades, + min_periods=min_periods, + min_coverage=min_coverage, + ) + + baseline = _metrics(_selected_rows(latest, BASELINE_SPEC, top_k=top_k), latest, BASELINE_SPEC, top_k, cost_pct) + best = feasible.head(1).to_dict(orient="records")[0] + return { + "created_at": _utc_now(), + "prediction_csv": str(prediction_csv), + "top_k": top_k, + "cost_bps": cost_bps, + "slippage_bps": slippage_bps, + "min_trades": min_trades, + "min_periods": min_periods, + "min_coverage": min_coverage, + "baseline_topk": baseline, + "best_filter": best, + "improvement_vs_baseline_net_pct": _safe_float(best.get("avg_net_return_pct")) - _safe_float( + baseline.get("avg_net_return_pct") + ), + "top_filters": feasible.head(20).to_dict(orient="records"), + } + + +def rolling_validate_filters( + prediction_csv: Path, + top_k: int = 5, + cost_bps: float = 15.0, + slippage_bps: float = 10.0, + min_trades: int = 10, + min_periods: int = 3, + min_coverage: float = 0.5, + train_periods: int = 30, + test_periods: int = 10, + step_periods: int = 10, +) -> Dict[str, Any]: + """Pick the best filter on earlier periods and apply it to later periods.""" + + df = pd.read_csv(prediction_csv, dtype={"symbol": str, "session": str}, encoding="utf-8-sig") + latest = _latest_windows(df) + periods = _sorted_periods(latest) + if len(periods) < train_periods + test_periods: + raise ValueError( + f"Need at least train_periods + test_periods periods; got {len(periods)} " + f"for train={train_periods}, test={test_periods}" + ) + + cost_pct = (cost_bps + slippage_bps) * 0.01 + specs = generate_filter_specs() + folds: List[Dict[str, Any]] = [] + last_start = len(periods) - train_periods - test_periods + for fold_id, start in enumerate(range(0, last_start + 1, max(1, step_periods)), start=1): + train_slice = periods[start : start + train_periods] + test_slice = periods[start + train_periods : start + train_periods + test_periods] + train_latest = _period_subset(latest, train_slice) + test_latest = _period_subset(latest, test_slice) + + train_ranked = _rank_feasible_results( + _evaluate_specs(train_latest, specs, top_k=top_k, cost_pct=cost_pct), + min_trades=min_trades, + min_periods=min_periods, + min_coverage=min_coverage, + ) + train_best = train_ranked.head(1).to_dict(orient="records")[0] + selected_spec = _spec_from_row(train_best) + test_metrics = _metrics( + _selected_rows(test_latest, selected_spec, top_k=top_k), + test_latest, + selected_spec, + top_k, + cost_pct, + ) + baseline_test = _metrics( + _selected_rows(test_latest, BASELINE_SPEC, top_k=top_k), + test_latest, + BASELINE_SPEC, + top_k, + cost_pct, + ) + folds.append( + { + "fold": fold_id, + "train_start": _iso_timestamp(train_slice[0]), + "train_end": _iso_timestamp(train_slice[-1]), + "test_start": _iso_timestamp(test_slice[0]), + "test_end": _iso_timestamp(test_slice[-1]), + "selected_filter": selected_spec.name, + "train_avg_net_return_pct": _safe_float(train_best.get("avg_net_return_pct")), + "train_direction_hit_rate": _safe_float(train_best.get("direction_hit_rate")), + "train_trade_count": int(_safe_float(train_best.get("trade_count"))), + "test_avg_net_return_pct": _safe_float(test_metrics.get("avg_net_return_pct")), + "test_avg_gross_return_pct": _safe_float(test_metrics.get("avg_gross_return_pct")), + "test_direction_hit_rate": _safe_float(test_metrics.get("direction_hit_rate")), + "test_trade_count": int(_safe_float(test_metrics.get("trade_count"))), + "test_coverage": _safe_float(test_metrics.get("coverage")), + "test_cumulative_return_pct": _safe_float(test_metrics.get("cumulative_return_pct")), + "baseline_test_avg_net_return_pct": _safe_float(baseline_test.get("avg_net_return_pct")), + "test_vs_baseline_net_pct": _safe_float(test_metrics.get("avg_net_return_pct")) + - _safe_float(baseline_test.get("avg_net_return_pct")), + } + ) + + fold_frame = pd.DataFrame(folds) + avg_train = _mean_float(fold_frame["train_avg_net_return_pct"]) + avg_test = _mean_float(fold_frame["test_avg_net_return_pct"]) + summary = { + "fold_count": int(len(folds)), + "total_period_count": int(len(periods)), + "train_periods": int(train_periods), + "test_periods": int(test_periods), + "step_periods": int(step_periods), + "total_test_trade_count": int(pd.to_numeric(fold_frame["test_trade_count"], errors="coerce").fillna(0).sum()), + "avg_train_net_return_pct": avg_train, + "avg_test_net_return_pct": avg_test, + "avg_test_baseline_net_return_pct": _mean_float(fold_frame["baseline_test_avg_net_return_pct"]), + "avg_test_improvement_net_pct": _mean_float(fold_frame["test_vs_baseline_net_pct"]), + "test_direction_hit_rate_weighted": _weighted_mean( + fold_frame, "test_direction_hit_rate", "test_trade_count" + ), + "positive_test_fold_rate": float((fold_frame["test_avg_net_return_pct"] > 0).mean()), + "overfit_gap_pct": avg_train - avg_test, + "is_profitable_after_cost": bool(avg_test > 0), + } + return { + "created_at": _utc_now(), + "prediction_csv": str(prediction_csv), + "top_k": top_k, + "cost_bps": cost_bps, + "slippage_bps": slippage_bps, + "min_trades": min_trades, + "min_periods": min_periods, + "min_coverage": min_coverage, + "summary": summary, + "folds": folds, + } + + +def write_filter_report(result: Dict[str, Any], output_dir: Path, prefix: str) -> Dict[str, Any]: + output_dir.mkdir(parents=True, exist_ok=True) + result = _json_safe(result) + json_path = output_dir / f"{prefix}.filter_search.json" + csv_path = output_dir / f"{prefix}.filter_search_top20.csv" + pd.DataFrame(result["top_filters"]).to_csv(csv_path, index=False, encoding="utf-8-sig") + result["artifact_paths"] = {"json": str(json_path), "csv": str(csv_path)} + json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + return result + + +def write_rolling_filter_report(result: Dict[str, Any], output_dir: Path, prefix: str) -> Dict[str, Any]: + output_dir.mkdir(parents=True, exist_ok=True) + result = _json_safe(result) + json_path = output_dir / f"{prefix}.rolling_filter_validation.json" + csv_path = output_dir / f"{prefix}.rolling_filter_validation_folds.csv" + pd.DataFrame(result["folds"]).to_csv(csv_path, index=False, encoding="utf-8-sig") + result["artifact_paths"] = {"json": str(json_path), "csv": str(csv_path)} + json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + return result + + +def write_cost_gate_report(result: Dict[str, Any], output_dir: Path, prefix: str) -> Dict[str, Any]: + output_dir.mkdir(parents=True, exist_ok=True) + result = _json_safe(result) + json_path = output_dir / f"{prefix}.cost_gate.json" + csv_path = output_dir / f"{prefix}.cost_gate_rolling_sensitivity.csv" + pd.DataFrame(result["rolling_cost_sensitivity"]).to_csv(csv_path, index=False, encoding="utf-8-sig") + result["artifact_paths"] = {"json": str(json_path), "csv": str(csv_path)} + json_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") + return result + + +def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Search STOM 1s prediction-time score filters.") + parser.add_argument("--prediction-csv", default=None) + parser.add_argument("--output-dir", default="webui/qlib_backtests") + parser.add_argument("--prefix", default=None) + parser.add_argument("--top-k", type=int, default=5) + parser.add_argument("--cost-bps", type=float, default=15.0) + parser.add_argument("--slippage-bps", type=float, default=10.0) + parser.add_argument("--min-trades", type=int, default=10) + parser.add_argument("--min-periods", type=int, default=3) + parser.add_argument("--min-coverage", type=float, default=0.5) + parser.add_argument("--rolling-validate", action="store_true") + parser.add_argument("--rolling-train-periods", type=int, default=30) + parser.add_argument("--rolling-test-periods", type=int, default=10) + parser.add_argument("--rolling-step-periods", type=int, default=10) + parser.add_argument("--gate-analysis", action="store_true") + parser.add_argument("--filter-report", default=None) + parser.add_argument("--rolling-report", default=None) + parser.add_argument("--total-cost-bps-grid", default="5,10,15,25") + parser.add_argument("--target-total-cost-bps", type=float, default=None) + parser.add_argument("--min-avg-test-net-pct", type=float, default=0.0) + parser.add_argument("--min-positive-test-fold-rate", type=float, default=0.5) + parser.add_argument("--min-improvement-net-pct", type=float, default=0.0) + parser.add_argument("--min-total-test-trades", type=int, default=100) + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = parse_args(argv) + if args.gate_analysis: + if not args.filter_report or not args.rolling_report: + raise ValueError("--gate-analysis requires --filter-report and --rolling-report") + result = build_cost_gate_report( + filter_report_path=Path(args.filter_report), + rolling_report_path=Path(args.rolling_report), + total_cost_bps_grid=_parse_bps_grid(args.total_cost_bps_grid), + target_total_cost_bps=args.target_total_cost_bps, + min_avg_test_net_pct=args.min_avg_test_net_pct, + min_positive_test_fold_rate=args.min_positive_test_fold_rate, + min_improvement_net_pct=args.min_improvement_net_pct, + min_total_test_trades=args.min_total_test_trades, + ) + prefix = args.prefix or Path(args.rolling_report).stem + result = write_cost_gate_report(result, Path(args.output_dir), prefix) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + if not args.prediction_csv: + raise ValueError("--prediction-csv is required unless --gate-analysis is used") + prediction_csv = Path(args.prediction_csv) + prefix = args.prefix or prediction_csv.stem + if args.rolling_validate: + result = rolling_validate_filters( + prediction_csv=prediction_csv, + top_k=args.top_k, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + min_trades=args.min_trades, + min_periods=args.min_periods, + min_coverage=args.min_coverage, + train_periods=args.rolling_train_periods, + test_periods=args.rolling_test_periods, + step_periods=args.rolling_step_periods, + ) + result = write_rolling_filter_report(result, Path(args.output_dir), prefix) + else: + result = search_filters( + prediction_csv=prediction_csv, + top_k=args.top_k, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + min_trades=args.min_trades, + min_periods=args.min_periods, + min_coverage=args.min_coverage, + ) + result = write_filter_report(result, Path(args.output_dir), prefix) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune/stom_rl_c0_feature_probe.py b/finetune/stom_rl_c0_feature_probe.py new file mode 100644 index 000000000..e6d59b366 --- /dev/null +++ b/finetune/stom_rl_c0_feature_probe.py @@ -0,0 +1,283 @@ +"""Stage C-0 DB feature feasibility probe (read-only, bounded sampling). + +Decides whether the high-signal features the RL export currently drops can be +added to ``export-stom-rl``. Reuses the existing read-only helpers +(``connect_readonly`` / ``list_stock_tables`` / ``_decode_table_columns``) so it +never performs a full-DB scan: a bounded sample of symbols x their available +sessions x a bounded row read per session is inspected. + +Target candidate source columns (Korean, UTF-8 in the STOM tick DB): + 등락율 -> change_rate + 시가총액 -> market_cap + 고저평균대비등락율 -> high_low_mid_change_rate (else computed from 고가/저가/현재가) + 체결강도 -> trade_strength_avg_n (trailing mean; already a source) + 회전율 -> turnover_rate (already canonical) + +Coverage rule (plan V10): per existing target column, compute PER-SESSION +non-null coverage % (and non-zero where 0 plausibly means missing). If a +feature has < 80% non-null coverage in ANY sampled session => "fallback-or-drop". +Features >= 80% everywhere => "add". + +Usage: + py -3.11 finetune/stom_rl_c0_feature_probe.py \ + --db _database/stock_tick_back.db --symbols 8 --sessions-per-symbol 3 \ + --rows-per-session 3000 --out .omx/artifacts/deep_rl/stageC_export/c0_probe.json +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +FINETUNE_CSV_DIR = PROJECT_ROOT / "finetune_csv" +if str(FINETUNE_CSV_DIR) not in sys.path: + sys.path.insert(0, str(FINETUNE_CSV_DIR)) + +from stom_tick_dataset import connect_readonly, list_stock_tables # noqa: E402 +from qlib_stom_pipeline import _decode_table_columns, _sqlite_quote_ident # noqa: E402 + +# Candidate source columns to probe (target_feature -> accepted DB column names). +# Names that are ALREADY in STOM_RL_SOURCE_COLUMNS (체결강도, 회전율, 고가/저가/현재가) +# are probed too, so coverage of "already-present" sources is measured on the +# same footing as the NEW columns (등락율 / 시가총액 / 고저평균대비등락율). +PROBE_COLUMNS: Dict[str, List[str]] = { + "change_rate(등락율)": ["등락율"], + "market_cap(시가총액)": ["시가총액"], + "high_low_mid_change_rate(고저평균대비등락율)": ["고저평균대비등락율"], + "trade_strength(체결강도)": ["체결강도"], + "turnover_rate(회전율)": ["회전율"], + # OHLC sources backing the computed high_low_mid fallback: + "high(고가)": ["고가"], + "low(저가)": ["저가"], + "close(현재가)": ["현재가", "종가"], +} + +# Columns where a literal 0 plausibly signals "not recorded" (missing) rather +# than a true value, so coverage is reported both as non-null and non-zero. +ZERO_MEANS_MISSING = { + "market_cap(시가총액)", + "turnover_rate(회전율)", + "trade_strength(체결강도)", +} + +COVERAGE_THRESHOLD = 0.80 + + +def _sessions_for_table(conn: Any, table: str, ts_col: str, limit: int) -> List[str]: + """Return distinct YYYYMMDD session dates for a table (bounded).""" + q = ( + f"SELECT DISTINCT substr(CAST({_sqlite_quote_ident(ts_col)} AS TEXT), 1, 8) AS s " + f"FROM {_sqlite_quote_ident(table)} ORDER BY s LIMIT {int(limit)}" + ) + rows = conn.execute(q).fetchall() + return [str(r[0]) for r in rows if r[0] is not None and str(r[0]).isdigit() and len(str(r[0])) == 8] + + +def _resolve_ts_col(columns: List[str]) -> Optional[str]: + for c in ("index", "timestamps", "timestamp"): + if c in columns: + return c + return None + + +def _coverage_for_session( + conn: Any, + table: str, + ts_col: str, + session: str, + present_cols: Dict[str, str], + rows_per_session: int, +) -> Dict[str, Any]: + """Bounded read of one session window; return per-column coverage stats.""" + select_cols = sorted(set(present_cols.values())) + select_clause = ", ".join(_sqlite_quote_ident(c) for c in select_cols) + prefix = f"substr(CAST({_sqlite_quote_ident(ts_col)} AS TEXT), 1, 8)" + limit_sql = f" LIMIT {int(rows_per_session)}" if rows_per_session > 0 else "" + q = ( + f"SELECT {select_clause} FROM {_sqlite_quote_ident(table)} " + f"WHERE {prefix} = ? ORDER BY {_sqlite_quote_ident(ts_col)}{limit_sql}" + ) + raw = pd.read_sql_query(q, conn, params=[session]) + total = int(len(raw)) + out: Dict[str, Any] = {"session": session, "rows": total, "columns": {}} + for feat, dbcol in present_cols.items(): + series = pd.to_numeric(raw[dbcol], errors="coerce") + non_null = int(series.notna().sum()) + non_zero = int((series.fillna(0) != 0).sum()) + finite = series.replace([np.inf, -np.inf], np.nan).dropna() + out["columns"][feat] = { + "db_column": dbcol, + "non_null_pct": (non_null / total) if total else 0.0, + "non_zero_pct": (non_zero / total) if total else 0.0, + "min": float(finite.min()) if not finite.empty else None, + "max": float(finite.max()) if not finite.empty else None, + "mean": float(finite.mean()) if not finite.empty else None, + } + return out + + +def _effective_coverage(feat: str, col_stat: Dict[str, Any]) -> float: + """Use non-zero coverage where 0 means missing, else non-null.""" + if feat in ZERO_MEANS_MISSING: + return float(col_stat["non_zero_pct"]) + return float(col_stat["non_null_pct"]) + + +def run_probe( + db_path: str, + symbols: int, + sessions_per_symbol: int, + rows_per_session: int, + symbol_scan_cap: int, +) -> Dict[str, Any]: + conn = connect_readonly(db_path) + try: + # Bounded symbol discovery: only scan up to symbol_scan_cap tables to + # find ones that actually carry the candidate columns. + all_tables = list_stock_tables(conn, max_tables=symbol_scan_cap) + # First, decode columns for the scan window to learn which symbols carry + # the NEW target columns (등락율 / 시가총액 / 고저평균대비등락율). + new_targets = ["등락율", "시가총액", "고저평균대비등락율"] + column_presence: Dict[str, List[str]] = {} + candidate_tables: List[str] = [] + for table in all_tables: + cols = _decode_table_columns(conn, table) + column_presence[table] = cols + if any(t in cols for t in new_targets): + candidate_tables.append(table) + + # Prefer tables carrying the new columns; fall back to first tables. + chosen = (candidate_tables or all_tables)[:symbols] + + per_symbol: List[Dict[str, Any]] = [] + # feature -> list of (symbol, session, effective_coverage) + coverage_log: Dict[str, List[Tuple[str, str, float]]] = {f: [] for f in PROBE_COLUMNS} + sanity: Dict[str, Dict[str, float]] = {f: {"min": np.inf, "max": -np.inf} for f in PROBE_COLUMNS} + + for table in chosen: + cols = column_presence.get(table) or _decode_table_columns(conn, table) + ts_col = _resolve_ts_col(cols) + if ts_col is None: + per_symbol.append({"table": table, "error": "no timestamp column", "decoded_columns": cols}) + continue + present_cols: Dict[str, str] = {} + for feat, candidates in PROBE_COLUMNS.items(): + for cand in candidates: + if cand in cols: + present_cols[feat] = cand + break + sessions = _sessions_for_table(conn, table, ts_col, sessions_per_symbol) + sym_entry: Dict[str, Any] = { + "table": table, + "timestamp_column": ts_col, + "present_target_features": sorted(present_cols.keys()), + "absent_target_features": sorted(set(PROBE_COLUMNS) - set(present_cols)), + "sessions_sampled": sessions, + "session_coverage": [], + } + for session in sessions: + if not present_cols: + continue + cov = _coverage_for_session(conn, table, ts_col, session, present_cols, rows_per_session) + sym_entry["session_coverage"].append(cov) + for feat, stat in cov["columns"].items(): + eff = _effective_coverage(feat, stat) + coverage_log[feat].append((table, session, eff)) + if stat["min"] is not None: + sanity[feat]["min"] = min(sanity[feat]["min"], stat["min"]) + if stat["max"] is not None: + sanity[feat]["max"] = max(sanity[feat]["max"], stat["max"]) + per_symbol.append(sym_entry) + + # Aggregate verdict per feature. + verdicts: Dict[str, Any] = {} + for feat in PROBE_COLUMNS: + obs = coverage_log[feat] + exists = len(obs) > 0 + if not exists: + verdicts[feat] = { + "exists": False, + "sessions_observed": 0, + "min_coverage": None, + "worst_session": None, + "verdict": "drop-or-fallback", + "note": "source column not present in any sampled symbol", + } + continue + covs = [c for _, _, c in obs] + worst_idx = int(np.argmin(covs)) + worst = obs[worst_idx] + min_cov = float(min(covs)) + verdict = "add" if min_cov >= COVERAGE_THRESHOLD else "fallback-or-drop" + smin = sanity[feat]["min"] + smax = sanity[feat]["max"] + verdicts[feat] = { + "exists": True, + "sessions_observed": len(obs), + "min_coverage": min_cov, + "mean_coverage": float(np.mean(covs)), + "worst_session": {"symbol": worst[0], "session": worst[1], "coverage": worst[2]}, + "value_min": None if smin == np.inf else float(smin), + "value_max": None if smax == -np.inf else float(smax), + "coverage_basis": "non_zero" if feat in ZERO_MEANS_MISSING else "non_null", + "verdict": verdict, + } + + return { + "db_path": str(Path(db_path).resolve()), + "params": { + "symbols": symbols, + "sessions_per_symbol": sessions_per_symbol, + "rows_per_session": rows_per_session, + "symbol_scan_cap": symbol_scan_cap, + "coverage_threshold": COVERAGE_THRESHOLD, + }, + "chosen_symbols": chosen, + "candidate_tables_with_new_columns": candidate_tables, + "per_symbol": per_symbol, + "verdicts": verdicts, + } + finally: + conn.close() + + +def main() -> int: + parser = argparse.ArgumentParser(description="Stage C-0 DB feature feasibility probe (read-only).") + parser.add_argument("--db", required=True) + parser.add_argument("--symbols", type=int, default=8) + parser.add_argument("--sessions-per-symbol", type=int, default=3) + parser.add_argument("--rows-per-session", type=int, default=3000) + parser.add_argument("--symbol-scan-cap", type=int, default=40, + help="bounded number of tables to scan for column discovery") + parser.add_argument("--out", default="") + args = parser.parse_args() + + result = run_probe( + db_path=args.db, + symbols=args.symbols, + sessions_per_symbol=args.sessions_per_symbol, + rows_per_session=args.rows_per_session, + symbol_scan_cap=args.symbol_scan_cap, + ) + text = json.dumps(result, ensure_ascii=False, indent=2) + if args.out: + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(text, encoding="utf-8") + print(f"[c0-probe] wrote {out_path}") + else: + print(text) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune/tokenizer_safety.py b/finetune/tokenizer_safety.py new file mode 100644 index 000000000..2a0103de2 --- /dev/null +++ b/finetune/tokenizer_safety.py @@ -0,0 +1,75 @@ +"""Tokenizer long-run safety helpers. + +These helpers avoid importing torch so runner/dashboard tests can validate +checkpoint and OOM-recording behavior even on Python environments without a +working CUDA/PyTorch installation. +""" + +from __future__ import annotations + +import json +import os +from time import gmtime, strftime +from typing import Any, Optional + + +def resolve_tokenizer_validation_batch_size(config: dict[str, Any]) -> int: + """Return a positive validation batch size, defaulting to training batch size.""" + + raw_value = config.get("tokenizer_validation_batch_size", config["batch_size"]) + if raw_value in (None, ""): + raw_value = config["batch_size"] + return max(1, int(raw_value)) + + +def unwrap_model(model: Any) -> Any: + return model.module if hasattr(model, "module") else model + + +def save_tokenizer_checkpoint( + model: Any, + save_dir: str, + checkpoint_name: str, + rank: int, + reason: str, +) -> Optional[str]: + """Save a tokenizer checkpoint from rank 0 and return its path.""" + + if rank != 0: + return None + save_path = os.path.join(save_dir, "checkpoints", checkpoint_name) + unwrap_model(model).save_pretrained(save_path) + print(f"Tokenizer checkpoint saved to {save_path} ({reason})") + return save_path + + +def is_cuda_oom_error(exc: BaseException) -> bool: + message = str(exc).lower() + return "cuda" in message and "out of memory" in message + + +def write_tokenizer_validation_failure( + save_dir: str, + epoch_idx: int, + exc: BaseException, + pre_validation_checkpoint: Optional[str], + rank: int, +) -> Optional[str]: + """Persist a small failure artifact so long-run OOMs are diagnosable.""" + + if rank != 0: + return None + failure_path = os.path.join(save_dir, "validation_failure.json") + payload = { + "stage": "tokenizer_validation", + "epoch": epoch_idx + 1, + "error_type": type(exc).__name__, + "error": str(exc), + "is_cuda_oom": is_cuda_oom_error(exc), + "pre_validation_checkpoint": pre_validation_checkpoint, + "created_at": strftime("%Y-%m-%dT%H-%M-%S", gmtime()), + } + with open(failure_path, "w", encoding="utf-8") as handle: + json.dump(payload, handle, ensure_ascii=False, indent=2) + print(f"Tokenizer validation failure artifact saved to {failure_path}") + return failure_path diff --git a/finetune/train_predictor.py b/finetune/train_predictor.py index 47eddc91f..e182abce9 100644 --- a/finetune/train_predictor.py +++ b/finetune/train_predictor.py @@ -1,21 +1,29 @@ -import os -import sys -import json -import time -from time import gmtime, strftime -import torch.distributed as dist -import torch -from torch.utils.data import DataLoader -from torch.utils.data.distributed import DistributedSampler -from torch.nn.parallel import DistributedDataParallel as DDP - -import comet_ml - -# Ensure project root is in path -sys.path.append('../') -from config import Config -from dataset import QlibDataset -from model.kronos import KronosTokenizer, Kronos +import os +import sys +import json +import time +from pathlib import Path +from time import gmtime, strftime +import torch.distributed as dist +import torch +from torch.utils.data import DataLoader +from torch.utils.data.distributed import DistributedSampler +from torch.nn.parallel import DistributedDataParallel as DDP + +try: + import comet_ml +except ImportError: # pragma: no cover - depends on optional local package + comet_ml = None + +# Ensure project root is in path regardless of the current working directory. +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +from config import Config +from dataset import QlibDataset +from model_source import resolve_model_source +from predictor_handoff import apply_predictor_handoff_overrides +from model.kronos import KronosTokenizer, Kronos # Import shared utilities from utils.training_utils import ( setup_ddp, @@ -23,10 +31,10 @@ set_seed, get_model_size, format_time -) +) -def create_dataloaders(config: dict, rank: int, world_size: int): +def create_dataloaders(config: dict, rank: int, world_size: int): """ Creates and returns distributed dataloaders for training and validation. @@ -43,17 +51,27 @@ def create_dataloaders(config: dict, rank: int, world_size: int): valid_dataset = QlibDataset('val') print(f"[Rank {rank}] Train dataset size: {len(train_dataset)}, Validation dataset size: {len(valid_dataset)}") - train_sampler = DistributedSampler(train_dataset, num_replicas=world_size, rank=rank, shuffle=True) - val_sampler = DistributedSampler(valid_dataset, num_replicas=world_size, rank=rank, shuffle=False) - - train_loader = DataLoader( - train_dataset, batch_size=config['batch_size'], sampler=train_sampler, - num_workers=config.get('num_workers', 2), pin_memory=True, drop_last=True - ) - val_loader = DataLoader( - valid_dataset, batch_size=config['batch_size'], sampler=val_sampler, - num_workers=config.get('num_workers', 2), pin_memory=True, drop_last=False - ) + use_distributed_sampler = world_size > 1 and dist.is_available() and dist.is_initialized() + train_sampler = ( + DistributedSampler(train_dataset, num_replicas=world_size, rank=rank, shuffle=True) + if use_distributed_sampler + else None + ) + val_sampler = ( + DistributedSampler(valid_dataset, num_replicas=world_size, rank=rank, shuffle=False) + if use_distributed_sampler + else None + ) + + drop_last = config.get("dataset_sample_mode") != "full_sequential" + train_loader = DataLoader( + train_dataset, batch_size=config['batch_size'], sampler=train_sampler, shuffle=train_sampler is None, + num_workers=config.get('num_workers', 2), pin_memory=True, drop_last=drop_last + ) + val_loader = DataLoader( + valid_dataset, batch_size=config['batch_size'], sampler=val_sampler, shuffle=False, + num_workers=config.get('num_workers', 2), pin_memory=True, drop_last=False + ) return train_loader, val_loader, train_dataset, valid_dataset @@ -84,10 +102,11 @@ def train_model(model, tokenizer, device, config, save_dir, logger, rank, world_ dt_result = {} batch_idx_global = 0 - for epoch_idx in range(config['epochs']): - epoch_start_time = time.time() - model.train() - train_loader.sampler.set_epoch(epoch_idx) + for epoch_idx in range(config['epochs']): + epoch_start_time = time.time() + model.train() + if hasattr(train_loader.sampler, "set_epoch"): + train_loader.sampler.set_epoch(epoch_idx) train_dataset.set_epoch_seed(epoch_idx * 10000 + rank) valid_dataset.set_epoch_seed(0) @@ -105,8 +124,9 @@ def train_model(model, tokenizer, device, config, save_dir, logger, rank, world_ token_out = [token_seq_0[:, 1:], token_seq_1[:, 1:]] # Forward pass and loss calculation - logits = model(token_in[0], token_in[1], batch_x_stamp[:, :-1, :]) - loss, s1_loss, s2_loss = model.module.head.compute_loss(logits[0], logits[1], token_out[0], token_out[1]) + logits = model(token_in[0], token_in[1], batch_x_stamp[:, :-1, :]) + loss_model = model.module if hasattr(model, "module") else model + loss, s1_loss, s2_loss = loss_model.head.compute_loss(logits[0], logits[1], token_out[0], token_out[1]) # Backward pass and optimization optimizer.zero_grad() @@ -145,18 +165,25 @@ def train_model(model, tokenizer, device, config, save_dir, logger, rank, world_ token_out = [token_seq_0[:, 1:], token_seq_1[:, 1:]] logits = model(token_in[0], token_in[1], batch_x_stamp[:, :-1, :]) - val_loss, _, _ = model.module.head.compute_loss(logits[0], logits[1], token_out[0], token_out[1]) + loss_model = model.module if hasattr(model, "module") else model + val_loss, _, _ = loss_model.head.compute_loss(logits[0], logits[1], token_out[0], token_out[1]) tot_val_loss_sum_rank += val_loss.item() val_batches_processed_rank += 1 # Reduce validation metrics - val_loss_sum_tensor = torch.tensor(tot_val_loss_sum_rank, device=device) - val_batches_tensor = torch.tensor(val_batches_processed_rank, device=device) - dist.all_reduce(val_loss_sum_tensor, op=dist.ReduceOp.SUM) - dist.all_reduce(val_batches_tensor, op=dist.ReduceOp.SUM) - - avg_val_loss = val_loss_sum_tensor.item() / val_batches_tensor.item() if val_batches_tensor.item() > 0 else 0 + if dist.is_available() and dist.is_initialized(): + val_loss_sum_tensor = torch.tensor(tot_val_loss_sum_rank, device=device) + val_batches_tensor = torch.tensor(val_batches_processed_rank, device=device) + dist.all_reduce(val_loss_sum_tensor, op=dist.ReduceOp.SUM) + dist.all_reduce(val_batches_tensor, op=dist.ReduceOp.SUM) + val_loss_sum = val_loss_sum_tensor.item() + val_batches = val_batches_tensor.item() + else: + val_loss_sum = tot_val_loss_sum_rank + val_batches = val_batches_processed_rank + + avg_val_loss = val_loss_sum / val_batches if val_batches > 0 else 0 # --- End of Epoch Summary & Checkpointing (Master Process Only) --- if rank == 0: @@ -168,22 +195,24 @@ def train_model(model, tokenizer, device, config, save_dir, logger, rank, world_ logger.log_metric('val_predictor_loss_epoch', avg_val_loss, epoch=epoch_idx) if avg_val_loss < best_val_loss: - best_val_loss = avg_val_loss - save_path = f"{save_dir}/checkpoints/best_model" - model.module.save_pretrained(save_path) - print(f"Best model saved to {save_path} (Val Loss: {best_val_loss:.4f})") - - dist.barrier() + best_val_loss = avg_val_loss + save_path = f"{save_dir}/checkpoints/best_model" + save_model = model.module if hasattr(model, "module") else model + save_model.save_pretrained(save_path) + print(f"Best model saved to {save_path} (Val Loss: {best_val_loss:.4f})") + + if dist.is_available() and dist.is_initialized(): + dist.barrier() dt_result['best_val_loss'] = best_val_loss return dt_result -def main(config: dict): - """Main function to orchestrate the DDP training process.""" - rank, world_size, local_rank = setup_ddp() - device = torch.device(f"cuda:{local_rank}") - set_seed(config['seed'], rank) +def main(config: dict): + """Main function to orchestrate the DDP training process.""" + rank, world_size, local_rank = setup_ddp() + device = torch.device(f"cuda:{local_rank}" if torch.cuda.is_available() else "cpu") + set_seed(config['seed'], rank) save_dir = os.path.join(config['save_path'], config['predictor_save_folder_name']) @@ -196,8 +225,10 @@ def main(config: dict): 'save_directory': save_dir, 'world_size': world_size, } - if config['use_comet']: - comet_logger = comet_ml.Experiment( + if config['use_comet']: + if comet_ml is None: + raise RuntimeError("KRONOS_USE_COMET is enabled but comet_ml is not installed.") + comet_logger = comet_ml.Experiment( api_key=config['comet_config']['api_key'], project_name=config['comet_config']['project_name'], workspace=config['comet_config']['workspace'], @@ -207,18 +238,35 @@ def main(config: dict): comet_logger.log_parameters(config) print("Comet Logger Initialized.") - dist.barrier() + if dist.is_available() and dist.is_initialized(): + dist.barrier() # Model Initialization - tokenizer = KronosTokenizer.from_pretrained(config['finetuned_tokenizer_path']) - tokenizer.eval().to(device) - - model = Kronos.from_pretrained(config['pretrained_predictor_path']) - model.to(device) - model = DDP(model, device_ids=[local_rank], find_unused_parameters=False) - - if rank == 0: - print(f"Predictor Model Size: {get_model_size(model.module)}") + tokenizer_source = resolve_model_source( + config.get('finetuned_tokenizer_path', ''), + config.get('pretrained_tokenizer_path', ''), + "Tokenizer", + rank, + ) + predictor_source = resolve_model_source( + config.get('finetuned_predictor_path', ''), + config.get('pretrained_predictor_path', ''), + "Predictor", + rank, + ) + + tokenizer = KronosTokenizer.from_pretrained(tokenizer_source) + tokenizer.eval().to(device) + + model = Kronos.from_pretrained(predictor_source) + model.to(device) + if dist.is_available() and dist.is_initialized() and world_size > 1: + ddp_kwargs = {"device_ids": [local_rank]} if torch.cuda.is_available() else {} + model = DDP(model, find_unused_parameters=False, **ddp_kwargs) + + if rank == 0: + size_model = model.module if hasattr(model, "module") else model + print(f"Predictor Model Size: {get_model_size(size_model)}") # Start Training dt_result = train_model( @@ -235,10 +283,17 @@ def main(config: dict): cleanup_ddp() -if __name__ == '__main__': - # Usage: torchrun --standalone --nproc_per_node=NUM_GPUS train_predictor.py - if "WORLD_SIZE" not in os.environ: - raise RuntimeError("This script must be launched with `torchrun`.") - - config_instance = Config() - main(config_instance.__dict__) +if __name__ == '__main__': + # Usage: torchrun --standalone --nproc_per_node=NUM_GPUS train_predictor.py + disable_ddp = os.getenv("KRONOS_DISABLE_DDP", "").lower() in {"1", "true", "yes", "on"} + if "WORLD_SIZE" not in os.environ and not disable_ddp: + raise RuntimeError("This script must be launched with `torchrun`.") + + config_instance = Config() + handoff = apply_predictor_handoff_overrides(config_instance) + if handoff.get("applied"): + print( + "Predictor handoff overrides applied: " + f"{handoff['applied']} from {handoff.get('path')}" + ) + main(config_instance.__dict__) diff --git a/finetune/train_tokenizer.py b/finetune/train_tokenizer.py index 60186e1ea..2865ad3f5 100644 --- a/finetune/train_tokenizer.py +++ b/finetune/train_tokenizer.py @@ -1,281 +1,451 @@ -import os -import sys -import json -import time -from time import gmtime, strftime -import argparse -import datetime -import torch.distributed as dist -import torch -import torch.nn.functional as F -from torch.utils.data import DataLoader -from torch.utils.data.distributed import DistributedSampler -from torch.nn.parallel import DistributedDataParallel as DDP - -import comet_ml - -# Ensure project root is in path -sys.path.append("../") -from config import Config -from dataset import QlibDataset -from model.kronos import KronosTokenizer -# Import shared utilities -from utils.training_utils import ( - setup_ddp, - cleanup_ddp, - set_seed, - get_model_size, - format_time, -) - - -def create_dataloaders(config: dict, rank: int, world_size: int): - """ - Creates and returns distributed dataloaders for training and validation. - - Args: - config (dict): A dictionary of configuration parameters. - rank (int): The global rank of the current process. - world_size (int): The total number of processes. - - Returns: - tuple: A tuple containing (train_loader, val_loader, train_dataset, valid_dataset). - """ - print(f"[Rank {rank}] Creating distributed dataloaders...") - train_dataset = QlibDataset('train') - valid_dataset = QlibDataset('val') - print(f"[Rank {rank}] Train dataset size: {len(train_dataset)}, Validation dataset size: {len(valid_dataset)}") - - train_sampler = DistributedSampler(train_dataset, num_replicas=world_size, rank=rank, shuffle=True) - val_sampler = DistributedSampler(valid_dataset, num_replicas=world_size, rank=rank, shuffle=False) - - train_loader = DataLoader( - train_dataset, - batch_size=config['batch_size'], - sampler=train_sampler, - shuffle=False, # Shuffle is handled by the sampler - num_workers=config.get('num_workers', 2), - pin_memory=True, - drop_last=True - ) - val_loader = DataLoader( - valid_dataset, - batch_size=config['batch_size'], - sampler=val_sampler, - shuffle=False, - num_workers=config.get('num_workers', 2), - pin_memory=True, - drop_last=False - ) - print(f"[Rank {rank}] Dataloaders created. Train steps/epoch: {len(train_loader)}, Val steps: {len(val_loader)}") - return train_loader, val_loader, train_dataset, valid_dataset - - -def train_model(model, device, config, save_dir, logger, rank, world_size): - """ - The main training and validation loop for the tokenizer. - - Args: - model (DDP): The DDP-wrapped model to train. - device (torch.device): The device for the current process. - config (dict): Configuration dictionary. - save_dir (str): Directory to save checkpoints. - logger (comet_ml.Experiment): Comet logger instance. - rank (int): Global rank of the process. - world_size (int): Total number of processes. - - Returns: - tuple: A tuple containing the trained model and a dictionary of results. - """ - start_time = time.time() - if rank == 0: - effective_bs = config['batch_size'] * world_size * config['accumulation_steps'] - print(f"[Rank {rank}] BATCHSIZE (per GPU): {config['batch_size']}") - print(f"[Rank {rank}] Effective total batch size: {effective_bs}") - - train_loader, val_loader, train_dataset, valid_dataset = create_dataloaders(config, rank, world_size) - - optimizer = torch.optim.AdamW( - model.parameters(), - lr=config['tokenizer_learning_rate'], - weight_decay=config['adam_weight_decay'] - ) - - scheduler = torch.optim.lr_scheduler.OneCycleLR( - optimizer=optimizer, - max_lr=config['tokenizer_learning_rate'], - steps_per_epoch=len(train_loader), - epochs=config['epochs'], - pct_start=0.03, - div_factor=10 - ) - - best_val_loss = float('inf') - dt_result = {} - batch_idx_global_train = 0 - - for epoch_idx in range(config['epochs']): - epoch_start_time = time.time() - model.train() - train_loader.sampler.set_epoch(epoch_idx) - - # Set dataset seeds for reproducible sampling - train_dataset.set_epoch_seed(epoch_idx * 10000 + rank) - valid_dataset.set_epoch_seed(0) # Keep validation sampling consistent - +import os +import sys +import json +import time +import importlib.util +from time import gmtime, strftime +import torch.distributed as dist +import torch +import torch.nn.functional as F +from torch.utils.data import DataLoader +from torch.utils.data.distributed import DistributedSampler +from torch.nn.parallel import DistributedDataParallel as DDP + +try: + import comet_ml +except ImportError: # pragma: no cover - optional dependency + comet_ml = None + +# Ensure project root is in path regardless of the current working directory. +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) +from config import Config +from dataset import QlibDataset +from model.kronos import KronosTokenizer +from tokenizer_safety import ( + is_cuda_oom_error, + resolve_tokenizer_validation_batch_size, + save_tokenizer_checkpoint, + unwrap_model, + write_tokenizer_validation_failure, +) +# Import shared utilities +from utils.training_utils import ( + setup_ddp, + cleanup_ddp, + set_seed, + get_model_size, + format_time, +) + + +def create_dataloaders(config: dict, rank: int, world_size: int): + """ + Creates and returns distributed dataloaders for training and validation. + + Args: + config (dict): A dictionary of configuration parameters. + rank (int): The global rank of the current process. + world_size (int): The total number of processes. + + Returns: + tuple: A tuple containing (train_loader, val_loader, train_dataset, valid_dataset). + """ + print(f"[Rank {rank}] Creating distributed dataloaders...") + train_dataset = QlibDataset('train') + valid_dataset = QlibDataset('val') + print(f"[Rank {rank}] Train dataset size: {len(train_dataset)}, Validation dataset size: {len(valid_dataset)}") + + use_distributed_sampler = world_size > 1 and dist.is_available() and dist.is_initialized() + train_sampler = ( + DistributedSampler(train_dataset, num_replicas=world_size, rank=rank, shuffle=True) + if use_distributed_sampler + else None + ) + val_sampler = ( + DistributedSampler(valid_dataset, num_replicas=world_size, rank=rank, shuffle=False) + if use_distributed_sampler + else None + ) + drop_last = config.get("dataset_sample_mode") != "full_sequential" + + # GPU 최대 활용: persistent_workers + prefetch_factor 는 num_workers > 0 일 때만 의미. + n_workers = int(config.get('num_workers', 2) or 0) + persistent = bool(config.get('persistent_workers', False)) and n_workers > 0 + loader_extra = {} + if n_workers > 0: + loader_extra['prefetch_factor'] = int(config.get('prefetch_factor', 2)) + loader_extra['persistent_workers'] = persistent + + train_loader = DataLoader( + train_dataset, + batch_size=config['batch_size'], + sampler=train_sampler, + shuffle=train_sampler is None and config.get("dataset_sample_mode") != "full_sequential", + num_workers=n_workers, + pin_memory=True, + drop_last=drop_last, + **loader_extra, + ) + validation_batch_size = resolve_tokenizer_validation_batch_size(config) + val_loader = DataLoader( + valid_dataset, + batch_size=validation_batch_size, + sampler=val_sampler, + shuffle=False, + num_workers=n_workers, + pin_memory=True, + drop_last=False, + **loader_extra, + ) + print( + f"[Rank {rank}] Dataloaders created. Train steps/epoch: {len(train_loader)}, " + f"Val steps: {len(val_loader)}, Validation batch size: {validation_batch_size}" + ) + return train_loader, val_loader, train_dataset, valid_dataset + + +def train_model(model, device, config, save_dir, logger, rank, world_size): + """ + The main training and validation loop for the tokenizer. + + Args: + model (DDP): The DDP-wrapped model to train. + device (torch.device): The device for the current process. + config (dict): Configuration dictionary. + save_dir (str): Directory to save checkpoints. + logger (comet_ml.Experiment): Comet logger instance. + rank (int): Global rank of the process. + world_size (int): Total number of processes. + + Returns: + tuple: A tuple containing the trained model and a dictionary of results. + """ + start_time = time.time() + if rank == 0: + effective_bs = config['batch_size'] * world_size * config['accumulation_steps'] + print(f"[Rank {rank}] BATCHSIZE (per GPU): {config['batch_size']}") + print(f"[Rank {rank}] Effective total batch size: {effective_bs}") + + train_loader, val_loader, train_dataset, valid_dataset = create_dataloaders(config, rank, world_size) + + optimizer = torch.optim.AdamW( + model.parameters(), + lr=config['tokenizer_learning_rate'], + weight_decay=config['adam_weight_decay'] + ) + + scheduler = torch.optim.lr_scheduler.OneCycleLR( + optimizer=optimizer, + max_lr=config['tokenizer_learning_rate'], + steps_per_epoch=len(train_loader), + epochs=config['epochs'], + pct_start=0.03, + div_factor=10 + ) + + best_val_loss = float('inf') + dt_result = {} + batch_idx_global_train = 0 + + # ── AMP / autocast 설정 (opt-in) ──────────────────────────── + amp_enabled = bool(config.get('tokenizer_enable_amp', False)) and torch.cuda.is_available() + amp_dtype_label = str(config.get('tokenizer_amp_dtype', 'bf16')).lower() + if amp_dtype_label in ('bf16', 'bfloat16'): + amp_dtype = torch.bfloat16 + elif amp_dtype_label in ('fp16', 'float16', 'half'): + amp_dtype = torch.float16 + else: + amp_enabled = False + amp_dtype = torch.float32 + amp_use_scaler = amp_enabled and amp_dtype == torch.float16 + scaler = torch.amp.GradScaler('cuda', enabled=amp_use_scaler) + if rank == 0 and amp_enabled: + print(f"[Rank {rank}] AMP enabled - dtype={amp_dtype_label} scaler={amp_use_scaler}") + + def autocast_ctx(): + if not amp_enabled: + from contextlib import nullcontext + return nullcontext() + return torch.amp.autocast('cuda', dtype=amp_dtype) + + for epoch_idx in range(config['epochs']): + epoch_start_time = time.time() + model.train() + if hasattr(train_loader.sampler, "set_epoch"): + train_loader.sampler.set_epoch(epoch_idx) + + # Set dataset seeds for reproducible sampling + train_dataset.set_epoch_seed(epoch_idx * 10000 + rank) + valid_dataset.set_epoch_seed(0) # Keep validation sampling consistent + for i, (ori_batch_x, _) in enumerate(train_loader): ori_batch_x = ori_batch_x.to(device, non_blocking=True) - - # --- Gradient Accumulation Loop --- - current_batch_total_loss = 0.0 - for j in range(config['accumulation_steps']): - start_idx = j * (ori_batch_x.shape[0] // config['accumulation_steps']) - end_idx = (j + 1) * (ori_batch_x.shape[0] // config['accumulation_steps']) - batch_x = ori_batch_x[start_idx:end_idx] - - # Forward pass - zs, bsq_loss, _, _ = model(batch_x) - z_pre, z = zs - - # Loss calculation - recon_loss_pre = F.mse_loss(z_pre, batch_x) - recon_loss_all = F.mse_loss(z, batch_x) - recon_loss = recon_loss_pre + recon_loss_all - loss = (recon_loss + bsq_loss) / 2 # Assuming w_1=w_2=1 - - loss_scaled = loss / config['accumulation_steps'] - current_batch_total_loss += loss.item() - loss_scaled.backward() - - # --- Optimizer Step after Accumulation --- - torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=2.0) - optimizer.step() - scheduler.step() - optimizer.zero_grad() - - # --- Logging (Master Process Only) --- - if rank == 0 and (batch_idx_global_train + 1) % config['log_interval'] == 0: - avg_loss = current_batch_total_loss / config['accumulation_steps'] - print( - f"[Rank {rank}, Epoch {epoch_idx + 1}/{config['epochs']}, Step {i + 1}/{len(train_loader)}] " - f"LR {optimizer.param_groups[0]['lr']:.6f}, Loss: {avg_loss:.4f}" - ) - if rank == 0 and logger: - avg_loss = current_batch_total_loss / config['accumulation_steps'] - logger.log_metric('train_tokenizer_loss_batch', avg_loss, step=batch_idx_global_train) - logger.log_metric(f'train_vqvae_vq_loss_each_batch', bsq_loss.item(), step=batch_idx_global_train) - logger.log_metric(f'train_recon_loss_pre_each_batch', recon_loss_pre.item(), step=batch_idx_global_train) - logger.log_metric(f'train_recon_loss_each_batch', recon_loss_all.item(), step=batch_idx_global_train) - logger.log_metric('tokenizer_learning_rate', optimizer.param_groups[0]["lr"], step=batch_idx_global_train) - - batch_idx_global_train += 1 - - # --- Validation Loop --- - model.eval() - tot_val_loss_sum_rank = 0.0 - val_sample_count_rank = 0 - with torch.no_grad(): - for ori_batch_x, _ in val_loader: - ori_batch_x = ori_batch_x.to(device, non_blocking=True) - zs, _, _, _ = model(ori_batch_x) - _, z = zs - val_loss_item = F.mse_loss(z, ori_batch_x) - - tot_val_loss_sum_rank += val_loss_item.item() * ori_batch_x.size(0) - val_sample_count_rank += ori_batch_x.size(0) - - # Reduce validation losses from all processes - val_loss_sum_tensor = torch.tensor(tot_val_loss_sum_rank, device=device) - val_count_tensor = torch.tensor(val_sample_count_rank, device=device) - dist.all_reduce(val_loss_sum_tensor, op=dist.ReduceOp.SUM) - dist.all_reduce(val_count_tensor, op=dist.ReduceOp.SUM) - - avg_val_loss = val_loss_sum_tensor.item() / val_count_tensor.item() if val_count_tensor.item() > 0 else 0 - - # --- End of Epoch Summary & Checkpointing (Master Process Only) --- - if rank == 0: - print(f"\n--- Epoch {epoch_idx + 1}/{config['epochs']} Summary ---") - print(f"Validation Loss: {avg_val_loss:.4f}") - print(f"Time This Epoch: {format_time(time.time() - epoch_start_time)}") - print(f"Total Time Elapsed: {format_time(time.time() - start_time)}\n") - if logger: - logger.log_metric('val_tokenizer_loss_epoch', avg_val_loss, epoch=epoch_idx) - - if avg_val_loss < best_val_loss: - best_val_loss = avg_val_loss - save_path = f"{save_dir}/checkpoints/best_model" - model.module.save_pretrained(save_path) - print(f"Best model saved to {save_path} (Val Loss: {best_val_loss:.4f})") - if logger: - logger.log_model("best_model", save_path) - - dist.barrier() # Ensure all processes finish the epoch before starting the next one. - - dt_result['best_val_loss'] = best_val_loss - return model, dt_result - - -def main(config: dict): - """ - Main function to orchestrate the DDP training process. - """ - rank, world_size, local_rank = setup_ddp() - device = torch.device(f"cuda:{local_rank}") - set_seed(config['seed'], rank) - - save_dir = os.path.join(config['save_path'], config['tokenizer_save_folder_name']) - - # Logger and summary setup (master process only) - comet_logger, master_summary = None, {} - if rank == 0: - os.makedirs(os.path.join(save_dir, 'checkpoints'), exist_ok=True) - master_summary = { - 'start_time': strftime("%Y-%m-%dT%H-%M-%S", gmtime()), - 'save_directory': save_dir, - 'world_size': world_size, - } - if config['use_comet']: - comet_logger = comet_ml.Experiment( - api_key=config['comet_config']['api_key'], - project_name=config['comet_config']['project_name'], - workspace=config['comet_config']['workspace'], - ) - comet_logger.add_tag(config['comet_tag']) - comet_logger.set_name(config['comet_name']) - comet_logger.log_parameters(config) - print("Comet Logger Initialized.") - - dist.barrier() # Ensure save directory is created before proceeding - - # Model Initialization - model = KronosTokenizer.from_pretrained(config['pretrained_tokenizer_path']) - model.to(device) - model = DDP(model, device_ids=[local_rank], find_unused_parameters=False) - - if rank == 0: - print(f"Model Size: {get_model_size(model.module)}") - - # Start Training - _, dt_result = train_model( - model, device, config, save_dir, comet_logger, rank, world_size - ) - - # Finalize and save summary (master process only) - if rank == 0: - master_summary['final_result'] = dt_result - with open(os.path.join(save_dir, 'summary.json'), 'w') as f: - json.dump(master_summary, f, indent=4) - print('Training finished. Summary file saved.') - if comet_logger: - comet_logger.end() - - cleanup_ddp() - - -if __name__ == '__main__': - # Usage: torchrun --standalone --nproc_per_node=NUM_GPUS train_tokenizer.py - if "WORLD_SIZE" not in os.environ: - raise RuntimeError("This script must be launched with `torchrun`.") - - config_instance = Config() - main(config_instance.__dict__) + + # --- Gradient Accumulation Loop --- + current_batch_total_loss = 0.0 + for j in range(config['accumulation_steps']): + start_idx = j * (ori_batch_x.shape[0] // config['accumulation_steps']) + end_idx = (j + 1) * (ori_batch_x.shape[0] // config['accumulation_steps']) + batch_x = ori_batch_x[start_idx:end_idx] + + # Forward + loss (AMP autocast 활성 시 bf16/fp16 로 자동 cast) + with autocast_ctx(): + zs, bsq_loss, _, _ = model(batch_x) + z_pre, z = zs + recon_loss_pre = F.mse_loss(z_pre, batch_x) + recon_loss_all = F.mse_loss(z, batch_x) + recon_loss = recon_loss_pre + recon_loss_all + loss = (recon_loss + bsq_loss) / 2 # Assuming w_1=w_2=1 + + loss_scaled = loss / config['accumulation_steps'] + current_batch_total_loss += loss.item() + # fp16 일 때만 GradScaler — bf16/fp32 에서는 일반 backward + if amp_use_scaler: + scaler.scale(loss_scaled).backward() + else: + loss_scaled.backward() + + # --- Optimizer Step after Accumulation --- + if amp_use_scaler: + scaler.unscale_(optimizer) + torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=2.0) + scaler.step(optimizer) + scaler.update() + else: + torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=2.0) + optimizer.step() + scheduler.step() + optimizer.zero_grad(set_to_none=True) + + # --- Logging (Master Process Only) --- + if rank == 0 and (batch_idx_global_train + 1) % config['log_interval'] == 0: + avg_loss = current_batch_total_loss / config['accumulation_steps'] + print( + f"[Rank {rank}, Epoch {epoch_idx + 1}/{config['epochs']}, Step {i + 1}/{len(train_loader)}] " + f"LR {optimizer.param_groups[0]['lr']:.6f}, Loss: {avg_loss:.4f}" + ) + if rank == 0 and logger: + avg_loss = current_batch_total_loss / config['accumulation_steps'] + logger.log_metric('train_tokenizer_loss_batch', avg_loss, step=batch_idx_global_train) + logger.log_metric(f'train_vqvae_vq_loss_each_batch', bsq_loss.item(), step=batch_idx_global_train) + logger.log_metric(f'train_recon_loss_pre_each_batch', recon_loss_pre.item(), step=batch_idx_global_train) + logger.log_metric(f'train_recon_loss_each_batch', recon_loss_all.item(), step=batch_idx_global_train) + logger.log_metric('tokenizer_learning_rate', optimizer.param_groups[0]["lr"], step=batch_idx_global_train) + + batch_idx_global_train += 1 + + pre_validation_checkpoint = None + if config.get("tokenizer_save_pre_validation_checkpoint", True): + pre_validation_checkpoint = save_tokenizer_checkpoint( + model, + save_dir, + str(config.get("tokenizer_pre_validation_checkpoint_name") or "latest_train_model"), + rank, + reason=f"pre-validation epoch {epoch_idx + 1}", + ) + + if dist.is_available() and dist.is_initialized(): + dist.barrier() + + try: + del ori_batch_x, batch_x, zs, z_pre, z, loss, loss_scaled + del bsq_loss, recon_loss_pre, recon_loss_all, recon_loss + except UnboundLocalError: + pass + + optimizer.zero_grad(set_to_none=True) + if torch.cuda.is_available() and config.get("tokenizer_empty_cache_before_validation", True): + torch.cuda.empty_cache() + + # --- Validation Loop --- + model.eval() + tot_val_loss_sum_rank = 0.0 + val_sample_count_rank = 0 + val_total_steps = len(val_loader) + val_log_interval = max(1, int(config.get('tokenizer_validation_log_interval', config['log_interval']) or 1)) + val_max_steps = max(0, int(config.get('tokenizer_validation_max_steps', 0) or 0)) + if rank == 0: + val_limit_label = val_max_steps if val_max_steps > 0 else "all" + print( + f"[Rank {rank}] Tokenizer validation started. " + f"Steps: {val_total_steps}, Log interval: {val_log_interval}, Max steps: {val_limit_label}" + ) + try: + with torch.inference_mode(): + for val_step_idx, (ori_batch_x, _) in enumerate(val_loader, start=1): + ori_batch_x = ori_batch_x.to(device, non_blocking=True) + with autocast_ctx(): + zs, _, _, _ = model(ori_batch_x) + _, z = zs + val_loss_item = F.mse_loss(z, ori_batch_x) + + tot_val_loss_sum_rank += val_loss_item.item() * ori_batch_x.size(0) + val_sample_count_rank += ori_batch_x.size(0) + should_log_validation = ( + val_step_idx == 1 + or val_step_idx % val_log_interval == 0 + or val_step_idx == val_total_steps + or (val_max_steps > 0 and val_step_idx >= val_max_steps) + ) + if rank == 0 and should_log_validation: + running_val_loss = ( + tot_val_loss_sum_rank / val_sample_count_rank + if val_sample_count_rank > 0 + else 0.0 + ) + print( + f"[Rank {rank}] Tokenizer validation progress: " + f"Step {val_step_idx}/{val_total_steps}, Samples {val_sample_count_rank}, " + f"Loss: {running_val_loss:.4f}" + ) + if val_max_steps > 0 and val_step_idx >= val_max_steps: + if rank == 0: + print( + f"[Rank {rank}] Tokenizer validation capped at " + f"{val_step_idx}/{val_total_steps} steps by KRONOS_TOKENIZER_VAL_MAX_STEPS." + ) + break + except Exception as exc: + if torch.cuda.is_available() and is_cuda_oom_error(exc): + torch.cuda.empty_cache() + write_tokenizer_validation_failure(save_dir, epoch_idx, exc, pre_validation_checkpoint, rank) + if rank == 0: + print( + "Tokenizer validation failed with CUDA OOM. " + f"Pre-validation checkpoint remains at {pre_validation_checkpoint}." + ) + raise + + if dist.is_available() and dist.is_initialized(): + val_loss_sum_tensor = torch.tensor(tot_val_loss_sum_rank, device=device) + val_count_tensor = torch.tensor(val_sample_count_rank, device=device) + dist.all_reduce(val_loss_sum_tensor, op=dist.ReduceOp.SUM) + dist.all_reduce(val_count_tensor, op=dist.ReduceOp.SUM) + val_loss_sum = val_loss_sum_tensor.item() + val_count = val_count_tensor.item() + else: + val_loss_sum = tot_val_loss_sum_rank + val_count = val_sample_count_rank + + avg_val_loss = val_loss_sum / val_count if val_count > 0 else 0 + + # --- End of Epoch Summary & Checkpointing (Master Process Only) --- + if rank == 0: + print(f"\n--- Epoch {epoch_idx + 1}/{config['epochs']} Summary ---") + print(f"Validation Loss: {avg_val_loss:.4f}") + print(f"Time This Epoch: {format_time(time.time() - epoch_start_time)}") + print(f"Total Time Elapsed: {format_time(time.time() - start_time)}\n") + if logger: + logger.log_metric('val_tokenizer_loss_epoch', avg_val_loss, epoch=epoch_idx) + + if avg_val_loss < best_val_loss: + best_val_loss = avg_val_loss + save_path = f"{save_dir}/checkpoints/best_model" + unwrap_model(model).save_pretrained(save_path) + print(f"Best model saved to {save_path} (Val Loss: {best_val_loss:.4f})") + if logger: + logger.log_model("best_model", save_path) + + if dist.is_available() and dist.is_initialized(): + dist.barrier() + + dt_result['best_val_loss'] = best_val_loss + return model, dt_result + + +def main(config: dict): + """ + Main function to orchestrate the DDP training process. + """ + rank, world_size, local_rank = setup_ddp() + device = torch.device(f"cuda:{local_rank}" if torch.cuda.is_available() else "cpu") + set_seed(config['seed'], rank) + + save_dir = os.path.join(config['save_path'], config['tokenizer_save_folder_name']) + + # Logger and summary setup (master process only) + comet_logger, master_summary = None, {} + if rank == 0: + os.makedirs(os.path.join(save_dir, 'checkpoints'), exist_ok=True) + master_summary = { + 'start_time': strftime("%Y-%m-%dT%H-%M-%S", gmtime()), + 'save_directory': save_dir, + 'world_size': world_size, + } + if config['use_comet']: + if comet_ml is None: + raise RuntimeError("KRONOS_USE_COMET is enabled but comet_ml is not installed.") + comet_logger = comet_ml.Experiment( + api_key=config['comet_config']['api_key'], + project_name=config['comet_config']['project_name'], + workspace=config['comet_config']['workspace'], + ) + comet_logger.add_tag(config['comet_tag']) + comet_logger.set_name(config['comet_name']) + comet_logger.log_parameters(config) + print("Comet Logger Initialized.") + + if dist.is_available() and dist.is_initialized(): + dist.barrier() + + # Model Initialization + model = KronosTokenizer.from_pretrained(config['pretrained_tokenizer_path']) + model.to(device) + if dist.is_available() and dist.is_initialized() and world_size > 1: + ddp_kwargs = {"device_ids": [local_rank]} if torch.cuda.is_available() else {} + model = DDP(model, find_unused_parameters=False, **ddp_kwargs) + + # torch.compile (opt-in). Kronos 의 rotary attention 등 비표준 연산이 trace 실패할 수 있어 + # fullgraph=False 가 안전. 실패 시 OOM 안전망과 무관하게 즉시 raise 되므로 학습 자체에는 영향 없음. + if bool(config.get('tokenizer_enable_compile', False)): + compile_mode = str(config.get('tokenizer_compile_mode', 'reduce-overhead')) + compile_fullgraph = bool(config.get('tokenizer_compile_fullgraph', False)) + # CUDA torch.compile on this Windows workstation requires Triton during + # the first compiled forward pass. Without this guard the run can pass + # torch.compile(...) and then fail before step 1 with + # "ModuleNotFoundError: No module named 'triton'". + if torch.cuda.is_available() and importlib.util.find_spec('triton') is None: + if rank == 0: + print('[Rank 0] torch.compile skipped - Triton is not installed; using eager mode.') + else: + try: + model = torch.compile(model, mode=compile_mode, fullgraph=compile_fullgraph) + if rank == 0: + print(f"[Rank {rank}] torch.compile enabled - mode={compile_mode} fullgraph={compile_fullgraph}") + except Exception as compile_exc: + if rank == 0: + print(f"[Rank {rank}] torch.compile failed ({compile_exc}); falling back to eager mode.") + + if rank == 0: + size_model = model.module if hasattr(model, "module") else model + print(f"Model Size: {get_model_size(size_model)}") + + # Start Training + _, dt_result = train_model( + model, device, config, save_dir, comet_logger, rank, world_size + ) + + # Finalize and save summary (master process only) + if rank == 0: + master_summary['final_result'] = dt_result + with open(os.path.join(save_dir, 'summary.json'), 'w') as f: + json.dump(master_summary, f, indent=4) + print('Training finished. Summary file saved.') + if comet_logger: + comet_logger.end() + + cleanup_ddp() + + +if __name__ == '__main__': + # Usage: torchrun --standalone --nproc_per_node=NUM_GPUS train_tokenizer.py + disable_ddp = os.getenv("KRONOS_DISABLE_DDP", "").lower() in {"1", "true", "yes", "on"} + if "WORLD_SIZE" not in os.environ and not disable_ddp: + raise RuntimeError("This script must be launched with `torchrun`.") + + config_instance = Config() + main(config_instance.__dict__) diff --git a/finetune/training_progress.py b/finetune/training_progress.py new file mode 100644 index 000000000..409220cae --- /dev/null +++ b/finetune/training_progress.py @@ -0,0 +1,328 @@ + +"""Progress helpers for long-running STOM Kronos fine-tuning jobs. + +The runner writes a small JSON sidecar after every training log line so a web +page can monitor multi-day jobs without waiting for the child process to exit. +""" + +from __future__ import annotations + +import json +import re +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Mapping, Optional + + +TRAIN_STEP_RE = re.compile( + r"\[Rank\s+(?P\d+),\s*Epoch\s+(?P\d+)\/(?P\d+),\s*" + r"Step\s+(?P\d+)\/(?P\d+)\]\s*" + r"LR\s+(?P[0-9.eE+-]+),\s*Loss:\s*(?P[0-9.eE+-]+)" +) +VALIDATION_LOSS_RE = re.compile(r"Validation Loss:\s*(?P[0-9.eE+-]+)") +TOKENIZER_VALIDATION_PROGRESS_RE = re.compile( + r"Tokenizer validation progress:\s*Step\s+(?P\d+)\/(?P\d+),\s*" + r"Samples\s+(?P\d+),\s*Loss:\s*(?P[0-9.eE+-]+)" +) +BEST_MODEL_RE = re.compile( + r"Best model saved to\s+(?P.+?)\s*\(Val Loss:\s*(?P[0-9.eE+-]+)\)" +) +TRAIN_DATASET_RE = re.compile( + r"Train dataset size:\s*(?P\d+),\s*Validation dataset size:\s*(?P\d+)" +) +DATALOADER_RE = re.compile( + r"Dataloaders created\.\s*Train steps/epoch:\s*(?P\d+),\s*Val steps:\s*(?P\d+)" +) + + +def utc_now() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def parse_training_log_line(line: str) -> Dict[str, Any]: + """Parse one Kronos training log line into structured progress fields. + + Returns an empty dict when the line is not a known progress/metric line. + """ + match = TRAIN_STEP_RE.search(line) + if match: + data = match.groupdict() + return { + "event": "train_step", + "rank": int(data["rank"]), + "epoch": int(data["epoch"]), + "epochs": int(data["epochs"]), + "step": int(data["step"]), + "total_steps": int(data["total_steps"]), + "learning_rate": float(data["lr"]), + "loss": float(data["loss"]), + } + + match = VALIDATION_LOSS_RE.search(line) + if match: + return {"event": "validation", "validation_loss": float(match.group("validation_loss"))} + + match = TOKENIZER_VALIDATION_PROGRESS_RE.search(line) + if match: + return { + "event": "validation_progress", + "validation_step": int(match.group("validation_step")), + "validation_total_steps": int(match.group("validation_total_steps")), + "validation_samples": int(match.group("validation_samples")), + "validation_loss": float(match.group("validation_loss")), + } + + match = BEST_MODEL_RE.search(line) + if match: + return { + "event": "best_model", + "best_model_path": match.group("best_model_path").strip(), + "best_val_loss": float(match.group("best_val_loss")), + } + + match = TRAIN_DATASET_RE.search(line) + if match: + return { + "event": "dataset_size", + "train_dataset_size": int(match.group("train_dataset_size")), + "val_dataset_size": int(match.group("val_dataset_size")), + } + + match = DATALOADER_RE.search(line) + if match: + return { + "event": "dataloader_ready", + "total_steps": int(match.group("total_steps")), + "val_steps": int(match.group("val_steps")), + } + + if "Training finished. Summary file saved." in line: + return {"event": "summary_saved"} + + return {} + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +@dataclass +class TrainingProgressTracker: + """Maintain and persist one stage's training progress JSON sidecar.""" + + spec: Mapping[str, Any] + progress_path: Path + stdout_path: Path + stderr_path: Path + manifest_path: Path + started_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + created_at: str = field(default_factory=utc_now) + status: str = "created" + pid: Optional[int] = None + last_line: str = "" + current_epoch: int = 0 + epochs: int = 0 + current_step: int = 0 + total_steps: int = 0 + val_steps: int = 0 + last_loss: Optional[float] = None + last_learning_rate: Optional[float] = None + last_validation_loss: Optional[float] = None + best_val_loss: Optional[float] = None + best_model_path: Optional[str] = None + train_dataset_size: Optional[int] = None + val_dataset_size: Optional[int] = None + validation_step: int = 0 + validation_total_steps: int = 0 + validation_samples: int = 0 + phase: str = "created" + returncode: Optional[int] = None + + def start(self, pid: Optional[int] = None) -> Dict[str, Any]: + self.pid = pid + self.status = "running" + self.phase = "running" + return self.write() + + def observe_line(self, line: str) -> Dict[str, Any]: + self.last_line = line.rstrip("\r\n") + parsed = parse_training_log_line(line) + event = parsed.get("event") + if event == "train_step": + self.current_epoch = parsed["epoch"] + self.epochs = parsed["epochs"] + self.current_step = parsed["step"] + self.total_steps = parsed["total_steps"] + self.last_learning_rate = parsed["learning_rate"] + self.last_loss = parsed["loss"] + self.phase = "training" + elif event == "validation": + self.last_validation_loss = parsed["validation_loss"] + self.phase = "validation_complete" + elif event == "validation_progress": + self.validation_step = parsed["validation_step"] + self.validation_total_steps = parsed["validation_total_steps"] + self.validation_samples = parsed["validation_samples"] + self.last_validation_loss = parsed["validation_loss"] + self.phase = "validation" + elif event == "best_model": + self.best_val_loss = parsed["best_val_loss"] + self.best_model_path = parsed["best_model_path"] + self.phase = "checkpoint" + elif event == "dataset_size": + self.train_dataset_size = parsed["train_dataset_size"] + self.val_dataset_size = parsed["val_dataset_size"] + elif event == "dataloader_ready": + self.total_steps = parsed["total_steps"] + self.val_steps = parsed["val_steps"] + return self.write(last_event=parsed or None) + + def finish(self, returncode: int) -> Dict[str, Any]: + self.returncode = returncode + self.status = "ok" if returncode == 0 else "failed" + self.phase = "completed" if returncode == 0 else "failed" + return self.write() + + def fail_before_start(self, error: str) -> Dict[str, Any]: + self.status = "failed" + self.phase = "failed" + self.last_line = error + self.returncode = -1 + return self.write(extra={"error": error}) + + def write(self, last_event: Optional[Mapping[str, Any]] = None, extra: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: + self.progress_path.parent.mkdir(parents=True, exist_ok=True) + payload = self.snapshot(last_event=last_event) + if extra: + payload.update(dict(extra)) + self.progress_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload + + def snapshot(self, last_event: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: + elapsed_seconds = max(0.0, (datetime.now(timezone.utc) - self.started_at).total_seconds()) + batch_size = _safe_int(self.spec.get("env", {}).get("KRONOS_BATCH_SIZE"), default=1) + world_size = _safe_int(self.spec.get("env", {}).get("WORLD_SIZE"), default=1) or 1 + stage_count = max(1, _safe_int(self.spec.get("stage_count"), default=1)) + stage_index = min(stage_count, max(1, _safe_int(self.spec.get("stage_index"), default=1))) + total_steps = max(0, self.total_steps) + epochs = max(1, self.epochs or _safe_int(self.spec.get("env", {}).get("KRONOS_EPOCHS"), default=1)) + current_epoch = max(1, self.current_epoch or 1) + current_step = max(0, self.current_step) + if total_steps > 0: + completed_steps = min((current_epoch - 1) * total_steps + current_step, total_steps * epochs) + stage_fraction = completed_steps / float(total_steps * epochs) + else: + completed_steps = 0 + stage_fraction = 0.0 + validation_fraction = 0.0 + if self.validation_total_steps > 0: + validation_fraction = min(1.0, max(0.0, self.validation_step / float(self.validation_total_steps))) + # Tokenizer validation starts after the train loop has completed. + # Reserve the final 2% of the stage for validation so long-running + # validation no longer appears frozen at the last training log line. + stage_fraction = max(stage_fraction, 0.98 + 0.02 * validation_fraction) + stage_fraction = max(0.0, min(1.0, stage_fraction)) + if self.status == "dry_run": + overall_fraction = 0.0 + else: + overall_fraction = ((stage_index - 1) + stage_fraction) / float(stage_count) + overall_fraction = max(0.0, min(1.0, overall_fraction)) + steps_per_second = completed_steps / elapsed_seconds if elapsed_seconds > 0 and completed_steps > 0 else 0.0 + samples_per_second = steps_per_second * batch_size * world_size + eta_seconds: Optional[float] + if self.status == "running" and stage_fraction > 0 and elapsed_seconds > 0: + eta_seconds = max(0.0, elapsed_seconds * (1.0 - stage_fraction) / stage_fraction) + else: + eta_seconds = None + + payload: Dict[str, Any] = { + "schema_version": 1, + "created_at": self.created_at, + "updated_at": utc_now(), + "status": self.status, + "returncode": self.returncode, + "pid": self.pid, + "run_name": self.spec.get("run_name"), + "horizon": self.spec.get("horizon"), + "mode": self.spec.get("mode"), + "train_stage": self.spec.get("train_stage"), + "requested_train_stage": self.spec.get("requested_train_stage", self.spec.get("train_stage")), + "sample_stage": self.spec.get("sample_stage"), + "dataset_dir": self.spec.get("dataset_dir"), + "target_train_samples": self.spec.get("target_train_samples"), + "target_val_samples": self.spec.get("target_val_samples"), + "stage": { + "index": stage_index, + "count": stage_count, + "name": self.spec.get("train_stage"), + "percent": round(stage_fraction * 100.0, 4), + "overall_percent": round(overall_fraction * 100.0, 4), + }, + "progress": { + "phase": self.phase, + "epoch": self.current_epoch, + "epochs": self.epochs or epochs, + "step": self.current_step, + "total_steps": self.total_steps, + "completed_steps": completed_steps, + "stage_fraction": stage_fraction, + "overall_fraction": overall_fraction, + "validation_step": self.validation_step, + "validation_total_steps": self.validation_total_steps, + "validation_samples": self.validation_samples, + "validation_fraction": validation_fraction, + }, + "metrics": { + "last_loss": self.last_loss, + "last_learning_rate": self.last_learning_rate, + "last_validation_loss": self.last_validation_loss, + "best_val_loss": self.best_val_loss, + "best_model_path": self.best_model_path, + }, + "dataset": { + "train_dataset_size": self.train_dataset_size, + "val_dataset_size": self.val_dataset_size, + "val_steps": self.val_steps, + }, + "timing": { + "started_at": self.started_at.strftime("%Y-%m-%dT%H:%M:%SZ"), + "elapsed_seconds": elapsed_seconds, + "eta_seconds": eta_seconds, + "steps_per_second": steps_per_second, + "samples_per_second": samples_per_second, + }, + "paths": { + "progress": str(self.progress_path), + "stdout_log": str(self.stdout_path), + "stderr_log": str(self.stderr_path), + "manifest": str(self.manifest_path), + }, + "last_line": self.last_line, + } + if last_event: + payload["last_event"] = dict(last_event) + return payload + + +def build_dry_run_progress( + spec: Mapping[str, Any], + progress_path: Path, + stdout_path: Path, + stderr_path: Path, + manifest_path: Path, +) -> Dict[str, Any]: + tracker = TrainingProgressTracker( + spec=spec, + progress_path=progress_path, + stdout_path=stdout_path, + stderr_path=stderr_path, + manifest_path=manifest_path, + status="dry_run", + ) + tracker.status = "dry_run" + return tracker.write() diff --git a/finetune/utils/training_utils.py b/finetune/utils/training_utils.py index 8756322ad..e7be9b1f9 100644 --- a/finetune/utils/training_utils.py +++ b/finetune/utils/training_utils.py @@ -1,9 +1,25 @@ import os import random -import datetime -import numpy as np -import torch -import torch.distributed as dist +import datetime +import numpy as np +import torch +import torch.distributed as dist + + +def _select_ddp_backend() -> str: + explicit = os.environ.get("KRONOS_DDP_BACKEND") or os.environ.get("DIST_BACKEND") + if explicit: + return explicit.lower() + if torch.cuda.is_available() and dist.is_nccl_available(): + return "nccl" + return "gloo" + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value in (None, ""): + return default + return value.lower() not in {"0", "false", "no", "off"} def setup_ddp(): @@ -17,19 +33,31 @@ def setup_ddp(): Returns: tuple: A tuple containing (rank, world_size, local_rank). """ - if not dist.is_available(): - raise RuntimeError("torch.distributed is not available.") - - dist.init_process_group(backend="nccl") - rank = int(os.environ["RANK"]) - world_size = int(os.environ["WORLD_SIZE"]) - local_rank = int(os.environ["LOCAL_RANK"]) - torch.cuda.set_device(local_rank) - print( - f"[DDP Setup] Global Rank: {rank}/{world_size}, " - f"Local Rank (GPU): {local_rank} on device {torch.cuda.current_device()}" - ) - return rank, world_size, local_rank + if not dist.is_available(): + raise RuntimeError("torch.distributed is not available.") + if _env_bool("KRONOS_DISABLE_DDP", False): + local_rank = int(os.environ.get("LOCAL_RANK", "0")) + if torch.cuda.is_available(): + torch.cuda.set_device(local_rank) + print( + "[DDP Setup] Disabled by KRONOS_DISABLE_DDP=1; " + f"using local_rank={local_rank}, device={torch.cuda.current_device() if torch.cuda.is_available() else 'cpu'}" + ) + return 0, 1, local_rank + + backend = _select_ddp_backend() + dist.init_process_group(backend=backend) + rank = int(os.environ["RANK"]) + world_size = int(os.environ["WORLD_SIZE"]) + local_rank = int(os.environ["LOCAL_RANK"]) + if torch.cuda.is_available(): + torch.cuda.set_device(local_rank) + print( + f"[DDP Setup] Global Rank: {rank}/{world_size}, " + f"Local Rank: {local_rank}, backend={backend}, " + f"device={torch.cuda.current_device() if torch.cuda.is_available() else 'cpu'}" + ) + return rank, world_size, local_rank def cleanup_ddp(): diff --git a/finetune_csv/README_STOM_1TICK.md b/finetune_csv/README_STOM_1TICK.md new file mode 100644 index 000000000..70c5c7078 --- /dev/null +++ b/finetune_csv/README_STOM_1TICK.md @@ -0,0 +1,94 @@ +# STOM 1tick DB로 Kronos 학습 준비 + +이 문서는 `D:\Chanil_Park\Project\Programming\Kronos\_database\stock_tick_back.db` +같은 STOM SQLite 1tick/1초 데이터를 Kronos 학습 CSV로 변환하고 학습하는 절차입니다. + +## 핵심 판단 + +- 가능합니다. 단, Kronos는 원본 체결/호가 전체를 직접 학습하는 모델이 아니라 + `timestamps, open, high, low, close, volume, amount` 형태의 K-line/OHLCV 시퀀스를 학습합니다. +- STOM DB는 종목별 SQLite 테이블 구조이므로, 변환 후에도 `symbol`, `session` 컬럼을 유지해야 합니다. +- 새 `GroupedKlineDataset`은 `symbol + session` 단위로 윈도우를 만들기 때문에 + 여러 종목을 학습해도 한 학습 샘플이 다른 종목/다른 날짜로 넘어가지 않습니다. +- 정규화는 기본값 `normalize_using: lookback`입니다. 예측 구간까지 포함해 평균/표준편차를 계산하는 + 기존 단일 CSV 방식보다 실전 예측 누수를 줄입니다. + +## 1. DB 분석 + +```powershell +python finetune_csv/prepare_stom_1tick.py inspect ` + --db _database/stock_tick_back.db ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 20 ` + --json-output finetune_csv/data/stom_1tick_inspect.json +``` + +- `lookback-window 300`: 최근 5분(1초봉 300개) +- `predict-window 60`: 다음 1분(60개) +- 필요한 최소 행 수는 `300 + 60 + 1 = 361`개입니다. +- 빠른 확인은 `--max-tables 20`, 전체 검사/변환은 `--max-tables 0`을 사용합니다. + +## 2. 학습 CSV 생성 + +```powershell +python finetune_csv/prepare_stom_1tick.py export ` + --db _database/stock_tick_back.db ` + --output finetune_csv/data/stom_1tick_kline.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 100 ` + --price-mode close_only ` + --json-output finetune_csv/data/stom_1tick_export.json +``` + +생성 CSV 컬럼: + +```text +symbol, session, timestamps, open, high, low, close, volume, amount +``` + +가격 모드: + +- `--price-mode close_only`: `현재가/종가`를 `open/high/low/close` 모두에 넣습니다. +- `--price-mode db_ohlc`: DB의 `시가/고가/저가`와 `현재가 또는 종가`를 사용합니다. + STOM의 `시가/고가/저가`가 “해당 1초봉 OHLC”가 아니라 “당일 누적 시고저”라면 `close_only`가 더 안전합니다. + +거래량/거래대금: + +- `volume`: `거래량/초당거래량`이 있으면 사용, 없으면 `초당매수수량 + 초당매도수량` +- `amount`: `초당거래대금` 우선, 없으면 `close * volume` + +## 3. 학습 실행 + +기본 템플릿: + +```powershell +python finetune_csv/train_sequential.py --config finetune_csv/configs/config_stom_1tick.yaml +``` + +기본 설정은: + +- pretrained tokenizer 유지 +- `NeoQuasar/Kronos-small` predictor만 파인튜닝 +- `lookback=300`, `predict=60` +- `symbol/session` 그룹 단위 split + +RTX 4080 Super 16GB에서는 먼저 `Kronos-small`, `batch_size 16~32`, `basemodel_epochs 1~3`으로 +파일/속도/손실 감소를 확인한 뒤 전체 종목으로 늘리는 것을 권장합니다. + +## 4. 추천 실험 순서 + +1. `--max-tables 20`으로 CSV 생성 +2. `basemodel_epochs: 1`, `max_samples: 50000`으로 smoke test +3. 손실이 정상적으로 감소하면 `--max-tables 100~300` +4. 최종적으로 `--max-tables 0` 전체 종목/일자 학습 +5. 별도 검증 스크립트에서 예측 등락률, 분위수 점수, 기존 조건식을 결합해 종목 점수화 + +## 주의 + +- `_database` 원본 DB는 읽기 전용으로만 사용합니다. +- DB가 매일 거래대금 100위 중심이라 종목 구성이 바뀌어도 문제 없습니다. + 모델은 “특정 종목 전용”이 아니라 여러 종목/날짜 패턴을 함께 학습합니다. +- 다만 종목별 고유 특성까지 강하게 반영하려면 추후 `symbol embedding` 같은 구조 변경이 필요합니다. + 현재 구현은 Kronos 입력 형식에 맞춰 가격/거래량 시퀀스와 시간 특징을 학습합니다. diff --git a/finetune_csv/config_loader.py b/finetune_csv/config_loader.py index 6bddcaeea..d3c0deec1 100644 --- a/finetune_csv/config_loader.py +++ b/finetune_csv/config_loader.py @@ -120,6 +120,11 @@ def _load_all_configs(self): data_config = self.loader.get_data_config() self.data_path = data_config.get('data_path') + self.dataset_type = data_config.get('dataset_type', 'csv') + self.group_columns = data_config.get('group_columns', ['symbol', 'session']) + self.sample_stride = data_config.get('sample_stride', 1) + self.max_samples = data_config.get('max_samples', None) + self.normalize_using = data_config.get('normalize_using', 'lookback') self.lookback_window = data_config.get('lookback_window', 512) self.predict_window = data_config.get('predict_window', 48) self.max_context = data_config.get('max_context', 512) @@ -193,6 +198,11 @@ def get_tokenizer_config(self): return { 'data_path': self.data_path, + 'dataset_type': self.dataset_type, + 'group_columns': self.group_columns, + 'sample_stride': self.sample_stride, + 'max_samples': self.max_samples, + 'normalize_using': self.normalize_using, 'lookback_window': self.lookback_window, 'predict_window': self.predict_window, 'max_context': self.max_context, @@ -219,6 +229,11 @@ def get_basemodel_config(self): return { 'data_path': self.data_path, + 'dataset_type': self.dataset_type, + 'group_columns': self.group_columns, + 'sample_stride': self.sample_stride, + 'max_samples': self.max_samples, + 'normalize_using': self.normalize_using, 'lookback_window': self.lookback_window, 'predict_window': self.predict_window, 'max_context': self.max_context, @@ -249,6 +264,8 @@ def print_config_summary(self): print("=" * 60) print(f"Experiment name: {self.exp_name}") print(f"Data path: {self.data_path}") + print(f"Dataset type: {self.dataset_type}") + print(f"Group columns: {self.group_columns}") print(f"Lookback window: {self.lookback_window}") print(f"Predict window: {self.predict_window}") print(f"Tokenizer training epochs: {self.tokenizer_epochs}") diff --git a/finetune_csv/configs/config_stom_1tick.yaml b/finetune_csv/configs/config_stom_1tick.yaml new file mode 100644 index 000000000..6d73f1a33 --- /dev/null +++ b/finetune_csv/configs/config_stom_1tick.yaml @@ -0,0 +1,64 @@ +# STOM 1tick SQLite -> grouped Kronos CSV fine-tuning template. +# 1) Inspect/export DB: +# python finetune_csv/prepare_stom_1tick.py inspect --db _database/stock_tick_back.db --lookback-window 300 --predict-window 60 +# python finetune_csv/prepare_stom_1tick.py export --db _database/stock_tick_back.db --output finetune_csv/data/stom_1tick_kline.csv --lookback-window 300 --predict-window 60 --max-tables 100 --price-mode close_only +# 2) Train: +# python finetune_csv/train_sequential.py --config finetune_csv/configs/config_stom_1tick.yaml + +data: + data_path: "finetune_csv/data/stom_1tick_kline.csv" + dataset_type: "stom_tick" # grouped dataset: prevents cross-symbol/cross-day windows + group_columns: ["symbol", "session"] + lookback_window: 300 # 300 seconds = last 5 minutes + predict_window: 60 # 60 seconds = next 1 minute + max_context: 512 # Kronos-small/base context. Kronos-mini can use 2048. + clip: 5.0 + sample_stride: 1 # Increase to reduce samples/training time. + max_samples: null # Set e.g. 200000 for a fast pilot run. + normalize_using: "lookback" # Avoids future leakage from prediction window. + train_ratio: 0.85 # Split by symbol-session groups, not raw rows. + val_ratio: 0.15 + test_ratio: 0.0 + +training: + tokenizer_epochs: 0 # Keep pretrained tokenizer by default. + basemodel_epochs: 3 # Pilot value; increase after smoke test. + batch_size: 32 + log_interval: 50 + num_workers: 4 + seed: 42 + tokenizer_learning_rate: 0.0002 + predictor_learning_rate: 0.000001 + adam_beta1: 0.9 + adam_beta2: 0.95 + adam_weight_decay: 0.1 + accumulation_steps: 1 + +model_paths: + # Replace these with local downloaded HuggingFace model directories, or leave model IDs if your environment can fetch. + pretrained_tokenizer: "NeoQuasar/Kronos-Tokenizer-base" + pretrained_predictor: "NeoQuasar/Kronos-small" + exp_name: "stom_1tick_lookback300_pred60" + base_path: "finetune_csv/finetuned" + base_save_path: "" + finetuned_tokenizer: "" + tokenizer_save_name: "tokenizer" + basemodel_save_name: "basemodel" + +experiment: + name: "stom_1tick_kronos_finetune" + description: "Fine-tune Kronos on STOM opening-session 1tick/1sec grouped data" + use_comet: false + train_tokenizer: false # Use pretrained tokenizer; train predictor only. + train_basemodel: true + skip_existing: false + pre_trained_tokenizer: true + pre_trained_predictor: true + +device: + use_cuda: true + device_id: 0 + +distributed: + use_ddp: false + backend: "nccl" diff --git a/finetune_csv/configs/config_stom_1tick_1000.yaml b/finetune_csv/configs/config_stom_1tick_1000.yaml new file mode 100644 index 000000000..b5baca2a0 --- /dev/null +++ b/finetune_csv/configs/config_stom_1tick_1000.yaml @@ -0,0 +1,65 @@ +# STOM 1tick 1,000-table scale-up fine-tuning config. +# +# Purpose: +# - Second scale-up after 100-table pilot and 300-table validation. +# - Validate runtime, memory, loss, prediction, and dashboard paths before all-table training. +# +# Run: +# python finetune_csv/train_sequential.py --config finetune_csv/configs/config_stom_1tick_1000.yaml + +data: + data_path: "finetune_csv/data/stom_1tick_kline_1000.csv" + dataset_type: "stom_tick" + group_columns: ["symbol", "session"] + lookback_window: 300 + predict_window: 60 + max_context: 512 + clip: 5.0 + sample_stride: 10 + max_samples: 200000 + normalize_using: "lookback" + train_ratio: 0.85 + val_ratio: 0.15 + test_ratio: 0.0 + +training: + tokenizer_epochs: 0 + basemodel_epochs: 1 + batch_size: 8 + log_interval: 250 + num_workers: 0 + seed: 42 + tokenizer_learning_rate: 0.0002 + predictor_learning_rate: 0.000001 + adam_beta1: 0.9 + adam_beta2: 0.95 + adam_weight_decay: 0.1 + accumulation_steps: 1 + +model_paths: + pretrained_tokenizer: "NeoQuasar/Kronos-Tokenizer-base" + pretrained_predictor: "NeoQuasar/Kronos-small" + exp_name: "stom_1tick_1000_lookback300_pred60" + base_path: "finetune_csv/finetuned" + base_save_path: "" + finetuned_tokenizer: "" + tokenizer_save_name: "tokenizer" + basemodel_save_name: "basemodel" + +experiment: + name: "stom_1tick_1000_scaleup" + description: "Scale-up fine-tune on 1,000 STOM 1tick grouped OHLCV tables" + use_comet: false + train_tokenizer: false + train_basemodel: true + skip_existing: false + pre_trained_tokenizer: true + pre_trained_predictor: true + +device: + use_cuda: true + device_id: 0 + +distributed: + use_ddp: false + backend: "nccl" diff --git a/finetune_csv/configs/config_stom_1tick_300.yaml b/finetune_csv/configs/config_stom_1tick_300.yaml new file mode 100644 index 000000000..ec69053a3 --- /dev/null +++ b/finetune_csv/configs/config_stom_1tick_300.yaml @@ -0,0 +1,65 @@ +# STOM 1tick 300-table scale-up fine-tuning config. +# +# Purpose: +# - First scale-up after the 100-table GPU pilot. +# - Keep one epoch and bounded samples to validate runtime, memory, loss, prediction, and dashboard paths. +# +# Run: +# python finetune_csv/train_sequential.py --config finetune_csv/configs/config_stom_1tick_300.yaml + +data: + data_path: "finetune_csv/data/stom_1tick_kline_300.csv" + dataset_type: "stom_tick" + group_columns: ["symbol", "session"] + lookback_window: 300 + predict_window: 60 + max_context: 512 + clip: 5.0 + sample_stride: 10 + max_samples: 50000 + normalize_using: "lookback" + train_ratio: 0.85 + val_ratio: 0.15 + test_ratio: 0.0 + +training: + tokenizer_epochs: 0 + basemodel_epochs: 1 + batch_size: 8 + log_interval: 100 + num_workers: 0 + seed: 42 + tokenizer_learning_rate: 0.0002 + predictor_learning_rate: 0.000001 + adam_beta1: 0.9 + adam_beta2: 0.95 + adam_weight_decay: 0.1 + accumulation_steps: 1 + +model_paths: + pretrained_tokenizer: "NeoQuasar/Kronos-Tokenizer-base" + pretrained_predictor: "NeoQuasar/Kronos-small" + exp_name: "stom_1tick_300_lookback300_pred60" + base_path: "finetune_csv/finetuned" + base_save_path: "" + finetuned_tokenizer: "" + tokenizer_save_name: "tokenizer" + basemodel_save_name: "basemodel" + +experiment: + name: "stom_1tick_300_scaleup" + description: "Scale-up fine-tune on 300 STOM 1tick grouped OHLCV tables" + use_comet: false + train_tokenizer: false + train_basemodel: true + skip_existing: false + pre_trained_tokenizer: true + pre_trained_predictor: true + +device: + use_cuda: true + device_id: 0 + +distributed: + use_ddp: false + backend: "nccl" diff --git a/finetune_csv/configs/config_stom_1tick_all.yaml b/finetune_csv/configs/config_stom_1tick_all.yaml new file mode 100644 index 000000000..3d195177b --- /dev/null +++ b/finetune_csv/configs/config_stom_1tick_all.yaml @@ -0,0 +1,66 @@ +# STOM 1tick all-table bounded fine-tuning config. +# +# Purpose: +# - Final scale-up after 100-table pilot, 300-table validation, and 1,000-table validation. +# - Use a bounded sample count first to validate runtime, memory, loss, prediction, and dashboard paths. +# - Do not switch to unbounded/all-sample training until this bounded pass is reviewed. +# +# Run: +# python finetune_csv/train_sequential.py --config finetune_csv/configs/config_stom_1tick_all.yaml + +data: + data_path: "finetune_csv/data/stom_1tick_kline_all_bounded.csv" + dataset_type: "stom_tick" + group_columns: ["symbol", "session"] + lookback_window: 300 + predict_window: 60 + max_context: 512 + clip: 5.0 + sample_stride: 10 + max_samples: 300000 + normalize_using: "lookback" + train_ratio: 0.85 + val_ratio: 0.15 + test_ratio: 0.0 + +training: + tokenizer_epochs: 0 + basemodel_epochs: 1 + batch_size: 8 + log_interval: 500 + num_workers: 0 + seed: 42 + tokenizer_learning_rate: 0.0002 + predictor_learning_rate: 0.000001 + adam_beta1: 0.9 + adam_beta2: 0.95 + adam_weight_decay: 0.1 + accumulation_steps: 1 + +model_paths: + pretrained_tokenizer: "NeoQuasar/Kronos-Tokenizer-base" + pretrained_predictor: "NeoQuasar/Kronos-small" + exp_name: "stom_1tick_all_lookback300_pred60" + base_path: "finetune_csv/finetuned" + base_save_path: "" + finetuned_tokenizer: "" + tokenizer_save_name: "tokenizer" + basemodel_save_name: "basemodel" + +experiment: + name: "stom_1tick_all_scaleup_bounded" + description: "Bounded fine-tune on all STOM 1tick grouped OHLCV tables using clipped contiguous rows per group" + use_comet: false + train_tokenizer: false + train_basemodel: true + skip_existing: false + pre_trained_tokenizer: true + pre_trained_predictor: true + +device: + use_cuda: true + device_id: 0 + +distributed: + use_ddp: false + backend: "nccl" diff --git a/finetune_csv/configs/config_stom_1tick_pilot.yaml b/finetune_csv/configs/config_stom_1tick_pilot.yaml new file mode 100644 index 000000000..3ea82ca94 --- /dev/null +++ b/finetune_csv/configs/config_stom_1tick_pilot.yaml @@ -0,0 +1,65 @@ +# STOM 1tick GPU smoke/pilot fine-tuning config. +# +# Purpose: +# - Prove that the exported STOM grouped OHLCV CSV can train on RTX 4080 SUPER. +# - Keep runtime bounded before expanding to larger table/sample counts. +# +# Run: +# python finetune_csv/train_sequential.py --config finetune_csv/configs/config_stom_1tick_pilot.yaml + +data: + data_path: "finetune_csv/data/stom_1tick_kline.csv" + dataset_type: "stom_tick" + group_columns: ["symbol", "session"] + lookback_window: 300 + predict_window: 60 + max_context: 512 + clip: 5.0 + sample_stride: 20 + max_samples: 4096 + normalize_using: "lookback" + train_ratio: 0.85 + val_ratio: 0.15 + test_ratio: 0.0 + +training: + tokenizer_epochs: 0 + basemodel_epochs: 1 + batch_size: 8 + log_interval: 10 + num_workers: 0 + seed: 42 + tokenizer_learning_rate: 0.0002 + predictor_learning_rate: 0.000001 + adam_beta1: 0.9 + adam_beta2: 0.95 + adam_weight_decay: 0.1 + accumulation_steps: 1 + +model_paths: + pretrained_tokenizer: "NeoQuasar/Kronos-Tokenizer-base" + pretrained_predictor: "NeoQuasar/Kronos-small" + exp_name: "stom_1tick_gpu_pilot_lookback300_pred60" + base_path: "finetune_csv/finetuned" + base_save_path: "" + finetuned_tokenizer: "" + tokenizer_save_name: "tokenizer" + basemodel_save_name: "basemodel" + +experiment: + name: "stom_1tick_gpu_pilot" + description: "Short GPU pilot fine-tune on exported STOM 1tick grouped OHLCV data" + use_comet: false + train_tokenizer: false + train_basemodel: true + skip_existing: false + pre_trained_tokenizer: true + pre_trained_predictor: true + +device: + use_cuda: true + device_id: 0 + +distributed: + use_ddp: false + backend: "nccl" diff --git a/finetune_csv/finetune_base_model.py b/finetune_csv/finetune_base_model.py index d21c22dbf..77b48292d 100644 --- a/finetune_csv/finetune_base_model.py +++ b/finetune_csv/finetune_base_model.py @@ -4,12 +4,12 @@ import time import pickle import random -import pandas as pd -import numpy as np import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from torch.utils.data.distributed import DistributedSampler +import pandas as pd +import numpy as np from time import gmtime, strftime import logging from logging.handlers import RotatingFileHandler @@ -17,9 +17,12 @@ import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP -sys.path.append('../') -from model import Kronos, KronosTokenizer, KronosPredictor +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + from config_loader import CustomFinetuneConfig +from stom_tick_dataset import GroupedKlineDataset class CustomKlineDataset(Dataset): @@ -132,6 +135,40 @@ def __getitem__(self, idx): return x_tensor, x_stamp_tensor +def build_dataset_from_config(config, data_type='train', seed=None): + dataset_type = getattr(config, 'dataset_type', 'csv') + dataset_seed = config.seed if seed is None else seed + + if dataset_type in {'grouped_csv', 'stom_tick', 'multi_symbol_csv'}: + return GroupedKlineDataset( + data_path=config.data_path, + data_type=data_type, + lookback_window=config.lookback_window, + predict_window=config.predict_window, + clip=config.clip, + seed=dataset_seed, + train_ratio=config.train_ratio, + val_ratio=config.val_ratio, + test_ratio=config.test_ratio, + group_columns=getattr(config, 'group_columns', ['symbol', 'session']), + sample_stride=getattr(config, 'sample_stride', 1), + max_samples=getattr(config, 'max_samples', None), + normalize_using=getattr(config, 'normalize_using', 'lookback'), + ) + + return CustomKlineDataset( + data_path=config.data_path, + data_type=data_type, + lookback_window=config.lookback_window, + predict_window=config.predict_window, + clip=config.clip, + seed=dataset_seed, + train_ratio=config.train_ratio, + val_ratio=config.val_ratio, + test_ratio=config.test_ratio + ) + + def setup_logging(exp_name: str, log_dir: str, rank: int = 0) -> logging.Logger: @@ -182,29 +219,9 @@ def create_dataloaders(config): if not dist.is_available() or not dist.is_initialized() or dist.get_rank() == 0: print("Creating data loaders...") - train_dataset = CustomKlineDataset( - data_path=config.data_path, - data_type='train', - lookback_window=config.lookback_window, - predict_window=config.predict_window, - clip=config.clip, - seed=config.seed, - train_ratio=config.train_ratio, - val_ratio=config.val_ratio, - test_ratio=config.test_ratio - ) + train_dataset = build_dataset_from_config(config, data_type='train', seed=config.seed) - val_dataset = CustomKlineDataset( - data_path=config.data_path, - data_type='val', - lookback_window=config.lookback_window, - predict_window=config.predict_window, - clip=config.clip, - seed=config.seed + 1, - train_ratio=config.train_ratio, - val_ratio=config.val_ratio, - test_ratio=config.test_ratio - ) + val_dataset = build_dataset_from_config(config, data_type='val', seed=config.seed + 1) use_ddp = dist.is_available() and dist.is_initialized() train_sampler = DistributedSampler(train_dataset, num_replicas=dist.get_world_size(), rank=dist.get_rank(), shuffle=True) if use_ddp else None @@ -388,8 +405,14 @@ def main(): logger.info("Loading pretrained model or random initialization...") print("Loading pretrained model or random initialization...") + from model import Kronos, KronosTokenizer + if getattr(config, 'pre_trained_tokenizer', True): - tokenizer = KronosTokenizer.from_pretrained(config.finetuned_tokenizer_path) + tokenizer_load_path = config.finetuned_tokenizer_path + if not getattr(config, 'train_tokenizer', True) and getattr(config, 'pretrained_tokenizer_path', None): + tokenizer_load_path = config.pretrained_tokenizer_path + print(f"Loading tokenizer: {tokenizer_load_path}") + tokenizer = KronosTokenizer.from_pretrained(tokenizer_load_path) else: import json, os print("pre_trained_tokenizer=False, randomly initializing Tokenizer architecture for training") @@ -452,7 +475,7 @@ def main(): logger.info(f"Learning rate: {config.predictor_learning_rate}") logger.info(f"Training epochs: {config.basemodel_epochs}") logger.info(f"Device: {device}") - logger.info(f"Tokenizer path: {config.finetuned_tokenizer_path}") + logger.info(f"Tokenizer path: {tokenizer_load_path if getattr(config, 'pre_trained_tokenizer', True) else config.pretrained_tokenizer_path}") logger.info(f"Pretrained model path: {config.pretrained_predictor_path}") logger.info("Starting fine-tuning training...") diff --git a/finetune_csv/finetune_tokenizer.py b/finetune_csv/finetune_tokenizer.py index e160f12e4..9c2a06cb1 100644 --- a/finetune_csv/finetune_tokenizer.py +++ b/finetune_csv/finetune_tokenizer.py @@ -3,11 +3,11 @@ import json import time import random -import numpy as np import torch import torch.nn.functional as F from torch.utils.data import DataLoader from torch.utils.data.distributed import DistributedSampler +import numpy as np from time import gmtime, strftime import datetime import logging @@ -15,9 +15,12 @@ import torch.distributed as dist from torch.nn.parallel import DistributedDataParallel as DDP -sys.path.append("../") +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + from model import KronosTokenizer -from finetune_base_model import CustomKlineDataset +from finetune_base_model import build_dataset_from_config from config_loader import CustomFinetuneConfig @@ -94,29 +97,9 @@ def create_dataloaders(config): if not dist.is_available() or not dist.is_initialized() or dist.get_rank() == 0: print("Creating tokenizer training data loaders...") - train_dataset = CustomKlineDataset( - data_path=config.data_path, - data_type="train", - lookback_window=config.lookback_window, - predict_window=config.predict_window, - clip=config.clip, - seed=config.seed, - train_ratio=config.train_ratio, - val_ratio=config.val_ratio, - test_ratio=config.test_ratio - ) + train_dataset = build_dataset_from_config(config, data_type="train", seed=config.seed) - val_dataset = CustomKlineDataset( - data_path=config.data_path, - data_type="val", - lookback_window=config.lookback_window, - predict_window=config.predict_window, - clip=config.clip, - seed=config.seed + 1, - train_ratio=config.train_ratio, - val_ratio=config.val_ratio, - test_ratio=config.test_ratio - ) + val_dataset = build_dataset_from_config(config, data_type="val", seed=config.seed + 1) use_ddp = dist.is_available() and dist.is_initialized() train_sampler = DistributedSampler(train_dataset, num_replicas=dist.get_world_size(), rank=dist.get_rank(), shuffle=True) if use_ddp else None diff --git a/finetune_csv/prepare_stom_1tick.py b/finetune_csv/prepare_stom_1tick.py new file mode 100644 index 000000000..0ac0cbbaa --- /dev/null +++ b/finetune_csv/prepare_stom_1tick.py @@ -0,0 +1,12 @@ +"""Command-line wrapper for STOM 1tick DB inspection/export. + +Examples: + python finetune_csv/prepare_stom_1tick.py inspect --db _database/stock_tick_back.db + python finetune_csv/prepare_stom_1tick.py export --db _database/stock_tick_back.db --output finetune_csv/data/stom_1tick_kline.csv +""" + +from stom_tick_dataset import main + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune_csv/stom_ohlcv_pipeline.py b/finetune_csv/stom_ohlcv_pipeline.py new file mode 100644 index 000000000..4d262355a --- /dev/null +++ b/finetune_csv/stom_ohlcv_pipeline.py @@ -0,0 +1,205 @@ +import argparse +import importlib.util +import json +import os +import platform +import sys +from pathlib import Path +from typing import Any, Dict, Optional, Sequence + +try: + from config_loader import CustomFinetuneConfig +except Exception: # pragma: no cover - config loader diagnostics are reported by env-check + CustomFinetuneConfig = None + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_DB_PATH = PROJECT_ROOT / "_database" / "stock_tick_back.db" +DEFAULT_CONFIG_PATH = PROJECT_ROOT / "finetune_csv" / "configs" / "config_stom_1tick.yaml" +DEFAULT_PILOT_CSV = PROJECT_ROOT / "finetune_csv" / "data" / "stom_1tick_kline_pilot.csv" + + +def _module_available(name: str) -> bool: + return importlib.util.find_spec(name) is not None + + +def _torch_status() -> Dict[str, Any]: + try: + import torch + + cuda_available = bool(torch.cuda.is_available()) + devices = [] + if cuda_available: + for idx in range(torch.cuda.device_count()): + props = torch.cuda.get_device_properties(idx) + devices.append( + { + "index": idx, + "name": props.name, + "total_memory_gb": round(props.total_memory / (1024**3), 2), + } + ) + return { + "available": True, + "version": getattr(torch, "__version__", "unknown"), + "cuda_available": cuda_available, + "cuda_version": getattr(torch.version, "cuda", None), + "devices": devices, + } + except Exception as exc: + return {"available": False, "error": str(exc)} + + +def env_check( + db_path: Path = DEFAULT_DB_PATH, + config_path: Path = DEFAULT_CONFIG_PATH, + output_path: Optional[Path] = None, +) -> Dict[str, Any]: + """Return JSON-serializable environment readiness for STOM OHLCV training.""" + + db_path = Path(db_path) + config_path = Path(config_path) + dependencies = { + "numpy": _module_available("numpy"), + "pandas": _module_available("pandas"), + "torch": _module_available("torch"), + "huggingface_hub": _module_available("huggingface_hub"), + "flask": _module_available("flask"), + "plotly": _module_available("plotly"), + } + status = { + "python": { + "executable": sys.executable, + "version": sys.version, + "platform": platform.platform(), + }, + "paths": { + "project_root": str(PROJECT_ROOT), + "db_path": str(db_path), + "db_exists": db_path.exists(), + "db_size_bytes": db_path.stat().st_size if db_path.exists() else None, + "config_path": str(config_path), + "config_exists": config_path.exists(), + }, + "dependencies": dependencies, + "torch": _torch_status(), + "recommendations": [], + } + + if not status["torch"].get("cuda_available"): + status["recommendations"].append( + "CUDA is not available from the current Python. Install a CUDA-enabled PyTorch build before full GPU training." + ) + if not dependencies.get("huggingface_hub"): + status["recommendations"].append( + "huggingface_hub is missing. Install requirements before loading Kronos pretrained models." + ) + if not dependencies.get("flask") or not dependencies.get("plotly"): + status["recommendations"].append( + "webui dependencies are incomplete. Run: python -m pip install -r webui/requirements.txt" + ) + if not db_path.exists(): + status["recommendations"].append("STOM DB was not found. Check _database/stock_tick_back.db.") + + if CustomFinetuneConfig is not None and config_path.exists(): + try: + cfg = CustomFinetuneConfig(str(config_path)) + status["config_summary"] = { + "data_path": cfg.data_path, + "dataset_type": getattr(cfg, "dataset_type", None), + "lookback_window": cfg.lookback_window, + "predict_window": cfg.predict_window, + "batch_size": cfg.batch_size, + "basemodel_epochs": cfg.basemodel_epochs, + "pretrained_tokenizer_path": cfg.pretrained_tokenizer_path, + "pretrained_predictor_path": cfg.pretrained_predictor_path, + } + except Exception as exc: + status["config_summary_error"] = str(exc) + + if output_path: + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(status, ensure_ascii=False, indent=2), encoding="utf-8") + + return status + + +def build_commands( + db_path: Path = DEFAULT_DB_PATH, + pilot_csv: Path = DEFAULT_PILOT_CSV, + config_path: Path = DEFAULT_CONFIG_PATH, + lookback_window: int = 300, + predict_window: int = 60, + max_tables: int = 100, + price_mode: str = "close_only", +) -> Dict[str, str]: + db = str(db_path) + csv = str(pilot_csv) + cfg = str(config_path) + return { + "1_env_check": "python finetune_csv/stom_ohlcv_pipeline.py env-check", + "2_inspect": ( + f"python finetune_csv/prepare_stom_1tick.py inspect --db {db} " + f"--lookback-window {lookback_window} --predict-window {predict_window} " + f"--max-tables 0 --price-mode {price_mode}" + ), + "3_export_pilot": ( + f"python finetune_csv/prepare_stom_1tick.py export --db {db} --output {csv} " + f"--lookback-window {lookback_window} --predict-window {predict_window} " + f"--max-tables {max_tables} --price-mode {price_mode}" + ), + "4_train_pilot": f"python finetune_csv/train_sequential.py --config {cfg}", + "5_eval_baseline_or_model": ( + f"python finetune_csv/stom_prediction_eval.py --data {csv} " + f"--output webui/stom_predictions/pilot_predictions.csv " + f"--lookback-window {lookback_window} --predict-window {predict_window} " + "--max-windows 20 --mode baseline" + ), + "6_dashboard": "python webui/run.py # open http://localhost:7070/stom", + } + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description="STOM OHLCV Kronos training pipeline helper.") + subparsers = parser.add_subparsers(dest="command", required=True) + + env_parser = subparsers.add_parser("env-check", help="Check environment readiness.") + env_parser.add_argument("--db", default=str(DEFAULT_DB_PATH)) + env_parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH)) + env_parser.add_argument("--json-output", default=None) + + commands_parser = subparsers.add_parser("commands", help="Print the recommended staged commands.") + commands_parser.add_argument("--db", default=str(DEFAULT_DB_PATH)) + commands_parser.add_argument("--pilot-csv", default=str(DEFAULT_PILOT_CSV)) + commands_parser.add_argument("--config", default=str(DEFAULT_CONFIG_PATH)) + commands_parser.add_argument("--lookback-window", type=int, default=300) + commands_parser.add_argument("--predict-window", type=int, default=60) + commands_parser.add_argument("--max-tables", type=int, default=100) + commands_parser.add_argument("--price-mode", choices=["close_only", "db_ohlc"], default="close_only") + + args = parser.parse_args(argv) + + if args.command == "env-check": + payload = env_check( + db_path=Path(args.db), + config_path=Path(args.config), + output_path=Path(args.json_output) if args.json_output else None, + ) + else: + payload = build_commands( + db_path=Path(args.db), + pilot_csv=Path(args.pilot_csv), + config_path=Path(args.config), + lookback_window=args.lookback_window, + predict_window=args.predict_window, + max_tables=args.max_tables, + price_mode=args.price_mode, + ) + + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune_csv/stom_prediction_eval.py b/finetune_csv/stom_prediction_eval.py new file mode 100644 index 000000000..a35870a43 --- /dev/null +++ b/finetune_csv/stom_prediction_eval.py @@ -0,0 +1,252 @@ +import argparse +import json +import math +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + + +FEATURE_COLUMNS = ["open", "high", "low", "close", "volume", "amount"] + + +def load_grouped_ohlcv(path: Path) -> pd.DataFrame: + df = pd.read_csv(path, dtype={"symbol": str, "session": str}) + required = {"symbol", "session", "timestamps", *FEATURE_COLUMNS} + missing = sorted(required - set(df.columns)) + if missing: + raise ValueError(f"Grouped OHLCV file missing columns: {missing}") + df["timestamps"] = pd.to_datetime(df["timestamps"]) + for col in FEATURE_COLUMNS: + df[col] = pd.to_numeric(df[col], errors="coerce") + df = df.dropna(subset=["timestamps", *FEATURE_COLUMNS]) + return df.sort_values(["symbol", "session", "timestamps"]).reset_index(drop=True) + + +def persistence_prediction(history: pd.DataFrame, pred_len: int, future_timestamps: pd.Series) -> pd.DataFrame: + last = history.iloc[-1] + pred = pd.DataFrame( + { + "timestamps": future_timestamps.reset_index(drop=True), + "open": float(last["close"]), + "high": float(last["close"]), + "low": float(last["close"]), + "close": float(last["close"]), + "volume": float(last.get("volume", 0.0)), + "amount": float(last.get("amount", 0.0)), + } + ) + return pred.iloc[:pred_len].copy() + + +def kronos_prediction( + history: pd.DataFrame, + future_timestamps: pd.Series, + pred_len: int, + model_path: str, + tokenizer_path: str, + device: str = "cpu", + max_context: int = 512, + temperature: float = 1.0, + top_p: float = 0.9, + sample_count: int = 1, +) -> pd.DataFrame: + import torch + from model import Kronos, KronosPredictor, KronosTokenizer + + tokenizer = KronosTokenizer.from_pretrained(tokenizer_path) + model = Kronos.from_pretrained(model_path) + torch_device = torch.device(device) + tokenizer = tokenizer.to(torch_device) + model = model.to(torch_device) + predictor = KronosPredictor(model, tokenizer, max_context=max_context, device=str(torch_device)) + + return predictor.predict( + df=history[FEATURE_COLUMNS], + x_timestamp=history["timestamps"], + y_timestamp=future_timestamps, + pred_len=pred_len, + T=temperature, + top_p=top_p, + sample_count=sample_count, + verbose=False, + ) + + +def _window_positions(group_len: int, lookback_window: int, predict_window: int, stride: int) -> List[int]: + max_start = group_len - lookback_window - predict_window + if max_start < 0: + return [] + return list(range(0, max_start + 1, max(1, stride))) + + +def _safe_pct(numerator: float, denominator: float) -> float: + if denominator == 0 or math.isnan(denominator): + return 0.0 + return numerator / denominator * 100.0 + + +def evaluate_predictions( + data_path: Path, + output_path: Path, + lookback_window: int = 300, + predict_window: int = 60, + max_windows: int = 50, + stride: int = 60, + mode: str = "baseline", + model_path: Optional[str] = None, + tokenizer_path: Optional[str] = None, + device: str = "cpu", + max_context: int = 512, +) -> Dict[str, Any]: + df = load_grouped_ohlcv(data_path) + rows: List[Dict[str, Any]] = [] + window_count = 0 + + for (symbol, session), group in df.groupby(["symbol", "session"], sort=True): + positions = _window_positions(len(group), lookback_window, predict_window, stride) + if not positions: + continue + + group = group.reset_index(drop=True) + for start_idx in positions: + if window_count >= max_windows: + break + history = group.iloc[start_idx : start_idx + lookback_window].copy() + actual = group.iloc[start_idx + lookback_window : start_idx + lookback_window + predict_window].copy() + future_timestamps = actual["timestamps"].reset_index(drop=True) + + if mode == "kronos": + if not model_path or not tokenizer_path: + raise ValueError("mode=kronos requires --model-path and --tokenizer-path") + pred = kronos_prediction( + history=history, + future_timestamps=future_timestamps, + pred_len=predict_window, + model_path=model_path, + tokenizer_path=tokenizer_path, + device=device, + max_context=max_context, + ) + if "timestamps" not in pred.columns: + pred = pred.copy() + pred["timestamps"] = future_timestamps.values + else: + pred = persistence_prediction(history, predict_window, future_timestamps) + + actual = actual.reset_index(drop=True) + pred = pred.reset_index(drop=True) + t0_close = float(history["close"].iloc[-1]) + pred_close = float(pred["close"].iloc[-1]) + actual_close = float(actual["close"].iloc[-1]) + pred_return = _safe_pct(pred_close - t0_close, t0_close) + actual_return = _safe_pct(actual_close - t0_close, t0_close) + + for horizon_idx in range(min(len(actual), len(pred))): + p_close = float(pred["close"].iloc[horizon_idx]) + a_close = float(actual["close"].iloc[horizon_idx]) + rows.append( + { + "window_id": window_count, + "symbol": symbol, + "session": session, + "asof_timestamp": history["timestamps"].iloc[-1].isoformat(), + "target_timestamp": actual["timestamps"].iloc[horizon_idx].isoformat(), + "horizon_step": horizon_idx + 1, + "horizon_seconds": horizon_idx + 1, + "actual_close_t0": t0_close, + "pred_close": p_close, + "actual_close": a_close, + "error": p_close - a_close, + "abs_error": abs(p_close - a_close), + "pred_return_window": pred_return, + "actual_return_window": actual_return, + "direction_hit_window": int(np.sign(pred_return) == np.sign(actual_return)), + "mode": mode, + } + ) + window_count += 1 + + if window_count >= max_windows: + break + + if not rows: + raise ValueError("No prediction windows were generated. Check data length/window settings.") + + output_path.parent.mkdir(parents=True, exist_ok=True) + out_df = pd.DataFrame(rows) + out_df.to_csv(output_path, index=False, encoding="utf-8-sig") + + metrics = summarize_prediction_frame(out_df) + metrics.update( + { + "data_path": str(data_path), + "output_path": str(output_path), + "mode": mode, + "lookback_window": lookback_window, + "predict_window": predict_window, + "windows": int(out_df["window_id"].nunique()), + "rows": len(out_df), + } + ) + metrics_path = output_path.with_suffix(".metrics.json") + metrics_path.write_text(json.dumps(metrics, ensure_ascii=False, indent=2), encoding="utf-8") + return metrics + + +def summarize_prediction_frame(df: pd.DataFrame) -> Dict[str, Any]: + error = pd.to_numeric(df["error"], errors="coerce") + abs_error = pd.to_numeric(df["abs_error"], errors="coerce") + actual = pd.to_numeric(df["actual_close"], errors="coerce").replace(0, np.nan) + mape = (abs_error / actual).replace([np.inf, -np.inf], np.nan).mean() * 100.0 + latest = df.sort_values(["window_id", "horizon_step"]).groupby("window_id").tail(1) + return { + "mae": float(abs_error.mean()), + "rmse": float(np.sqrt((error**2).mean())), + "mape": float(0.0 if np.isnan(mape) else mape), + "direction_accuracy": float(pd.to_numeric(latest["direction_hit_window"], errors="coerce").mean()), + "avg_pred_return": float(pd.to_numeric(latest["pred_return_window"], errors="coerce").mean()), + "avg_actual_return": float(pd.to_numeric(latest["actual_return_window"], errors="coerce").mean()), + } + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Generate STOM actual-vs-predicted evaluation rows.") + parser.add_argument("--data", required=True, help="Grouped STOM OHLCV CSV.") + parser.add_argument("--output", required=True, help="Output prediction CSV.") + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--predict-window", type=int, default=60) + parser.add_argument("--max-windows", type=int, default=50) + parser.add_argument("--stride", type=int, default=60) + parser.add_argument("--mode", choices=["baseline", "kronos"], default="baseline") + parser.add_argument("--model-path", default=None) + parser.add_argument("--tokenizer-path", default=None) + parser.add_argument("--device", default="cpu") + parser.add_argument("--max-context", type=int, default=512) + args = parser.parse_args(argv) + + metrics = evaluate_predictions( + data_path=Path(args.data), + output_path=Path(args.output), + lookback_window=args.lookback_window, + predict_window=args.predict_window, + max_windows=args.max_windows, + stride=args.stride, + mode=args.mode, + model_path=args.model_path, + tokenizer_path=args.tokenizer_path, + device=args.device, + max_context=args.max_context, + ) + print(json.dumps(metrics, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune_csv/stom_tick_dataset.py b/finetune_csv/stom_tick_dataset.py new file mode 100644 index 000000000..8b9479e76 --- /dev/null +++ b/finetune_csv/stom_tick_dataset.py @@ -0,0 +1,697 @@ +import argparse +import json +import os +import random +import sqlite3 +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + + +FEATURE_COLUMNS = ["open", "high", "low", "close", "volume", "amount"] +TIME_FEATURE_COLUMNS = ["minute", "hour", "weekday", "day", "month"] +DEFAULT_GROUP_COLUMNS = ["symbol", "session"] + + +def _normalize_session_bound(value: Optional[str], label: str) -> Optional[str]: + if value in (None, ""): + return None + text = str(value).replace("-", "").strip() + if len(text) != 8 or not text.isdigit(): + raise ValueError(f"{label} must be YYYYMMDD or YYYY-MM-DD, got: {value}") + return text + + +def _quote_ident(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def connect_readonly(db_path: os.PathLike | str) -> sqlite3.Connection: + """Open a SQLite database in read-only/query-only mode.""" + + path = Path(db_path).resolve() + if not path.exists(): + raise FileNotFoundError(f"SQLite DB not found: {path}") + + conn = sqlite3.connect(f"file:{path.as_posix()}?mode=ro", uri=True) + conn.execute("PRAGMA query_only=ON") + return conn + + +def list_stock_tables(conn: sqlite3.Connection, max_tables: Optional[int] = None) -> List[str]: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ).fetchall() + tables = [row[0] for row in rows] + if max_tables and max_tables > 0: + tables = tables[:max_tables] + return tables + + +def get_table_columns(conn: sqlite3.Connection, table_name: str) -> List[str]: + return [row[1] for row in conn.execute(f"PRAGMA table_info({_quote_ident(table_name)})")] + + +def _first_existing(columns: Sequence[str], candidates: Sequence[str]) -> Optional[str]: + column_set = set(columns) + for candidate in candidates: + if candidate in column_set: + return candidate + return None + + +def infer_stom_column_mapping(columns: Sequence[str], price_mode: str = "db_ohlc") -> Dict[str, Any]: + """Infer how a STOM tick table maps to Kronos OHLCV columns. + + price_mode: + - db_ohlc: use DB open/high/low columns when present, close from 종가/현재가. + - close_only: map open/high/low/close all from the close/current price column. + """ + + if price_mode not in {"db_ohlc", "close_only"}: + raise ValueError("price_mode must be one of: db_ohlc, close_only") + + timestamp_col = _first_existing(columns, ["index", "timestamps", "timestamp", "datetime", "일시"]) + close_col = _first_existing(columns, ["종가", "현재가", "close", "Close"]) + if timestamp_col is None or close_col is None: + missing = [] + if timestamp_col is None: + missing.append("timestamp/index") + if close_col is None: + missing.append("close/current price") + raise ValueError(f"Missing required STOM columns: {', '.join(missing)}") + + if price_mode == "close_only": + open_col = high_col = low_col = close_col + else: + open_col = _first_existing(columns, ["시가", "open", "Open"]) or close_col + high_col = _first_existing(columns, ["고가", "high", "High"]) or close_col + low_col = _first_existing(columns, ["저가", "low", "Low"]) or close_col + + volume_col = _first_existing(columns, ["volume", "Volume", "거래량", "초당거래량"]) + buy_qty_col = _first_existing(columns, ["초당매수수량", "매수수량"]) + sell_qty_col = _first_existing(columns, ["초당매도수량", "매도수량"]) + + amount_col = _first_existing(columns, ["amount", "Amount", "거래대금", "초당거래대금"]) + + warnings: List[str] = [] + if "종가" not in columns and close_col == "현재가": + warnings.append("종가 column not found; using 현재가 as close.") + if price_mode == "db_ohlc" and {"시가", "고가", "저가"}.issubset(set(columns)): + warnings.append( + "Using DB 시가/고가/저가 as OHLC. If these are cumulative session fields, " + "use --price-mode close_only for tick-last-price training." + ) + if volume_col is None and not (buy_qty_col and sell_qty_col): + warnings.append("No direct volume column; volume will fall back to 0 if buy/sell quantities are absent.") + if amount_col is None: + warnings.append("No amount column; amount will be derived from close * volume.") + + return { + "timestamp": timestamp_col, + "open": open_col, + "high": high_col, + "low": low_col, + "close": close_col, + "volume": volume_col, + "buy_qty": buy_qty_col, + "sell_qty": sell_qty_col, + "amount": amount_col, + "warnings": warnings, + "price_mode": price_mode, + } + + +def _timestamp_series(raw: pd.Series) -> pd.Series: + if np.issubdtype(raw.dtype, np.number): + return pd.to_datetime(raw.astype("Int64").astype(str), format="%Y%m%d%H%M%S", errors="coerce") + parsed = pd.to_datetime(raw, errors="coerce") + if parsed.isna().all(): + parsed = pd.to_datetime(raw.astype(str), format="%Y%m%d%H%M%S", errors="coerce") + return parsed + + +def _required_sql_columns(mapping: Dict[str, Any]) -> List[str]: + cols = { + mapping["timestamp"], + mapping["open"], + mapping["high"], + mapping["low"], + mapping["close"], + } + for optional_key in ("volume", "buy_qty", "sell_qty", "amount"): + value = mapping.get(optional_key) + if value: + cols.add(value) + return sorted(cols) + + +def read_stom_table_as_kline( + conn: sqlite3.Connection, + table_name: str, + price_mode: str = "db_ohlc", + time_start: str = "090000", + time_end: str = "093000", + session_start: Optional[str] = None, + session_end: Optional[str] = None, +) -> Tuple[pd.DataFrame, Dict[str, Any]]: + """Read one STOM symbol table and convert it to Kronos OHLCV rows.""" + + session_start = _normalize_session_bound(session_start, "session_start") + session_end = _normalize_session_bound(session_end, "session_end") + if session_start and session_end and session_start > session_end: + raise ValueError("session_start must be <= session_end") + + columns = get_table_columns(conn, table_name) + mapping = infer_stom_column_mapping(columns, price_mode=price_mode) + sql_columns = _required_sql_columns(mapping) + select_clause = ", ".join(_quote_ident(col) for col in sql_columns) + order_col = _quote_ident(mapping["timestamp"]) + timestamp_session_expr = ( + f"substr(replace(replace(replace(CAST({order_col} AS TEXT), '-', ''), ':', ''), ' ', ''), 1, 8)" + ) + where_clauses = [] + params: List[str] = [] + if session_start: + where_clauses.append(f"{timestamp_session_expr} >= ?") + params.append(session_start) + if session_end: + where_clauses.append(f"{timestamp_session_expr} <= ?") + params.append(session_end) + where_sql = f" WHERE {' AND '.join(where_clauses)}" if where_clauses else "" + source = pd.read_sql_query( + f"SELECT {select_clause} FROM {_quote_ident(table_name)}{where_sql} ORDER BY {order_col}", + conn, + params=params, + ) + + timestamps = _timestamp_series(source[mapping["timestamp"]]) + close = pd.to_numeric(source[mapping["close"]], errors="coerce") + if price_mode == "close_only": + open_ = high = low = close + else: + open_ = pd.to_numeric(source[mapping["open"]], errors="coerce") + high = pd.to_numeric(source[mapping["high"]], errors="coerce") + low = pd.to_numeric(source[mapping["low"]], errors="coerce") + + if mapping.get("volume"): + volume = pd.to_numeric(source[mapping["volume"]], errors="coerce").fillna(0.0) + elif mapping.get("buy_qty") and mapping.get("sell_qty"): + buy_qty = pd.to_numeric(source[mapping["buy_qty"]], errors="coerce").fillna(0.0) + sell_qty = pd.to_numeric(source[mapping["sell_qty"]], errors="coerce").fillna(0.0) + volume = buy_qty + sell_qty + else: + volume = pd.Series(np.zeros(len(source)), index=source.index, dtype="float64") + + if mapping.get("amount"): + amount = pd.to_numeric(source[mapping["amount"]], errors="coerce").fillna(0.0) + else: + amount = close.fillna(0.0) * volume.fillna(0.0) + + frame = pd.DataFrame( + { + "symbol": table_name, + "timestamps": timestamps, + "open": open_, + "high": high, + "low": low, + "close": close, + "volume": volume, + "amount": amount, + } + ) + frame = frame.dropna(subset=["timestamps", "open", "high", "low", "close"]) + frame = frame[(frame[["open", "high", "low", "close"]] > 0).all(axis=1)] + + if time_start or time_end: + hhmmss = frame["timestamps"].dt.strftime("%H%M%S") + if time_start: + frame = frame[hhmmss >= time_start] + if time_end: + frame = frame[hhmmss <= time_end] + + frame["session"] = frame["timestamps"].dt.strftime("%Y%m%d") + if session_start: + frame = frame[frame["session"] >= session_start] + if session_end: + frame = frame[frame["session"] <= session_end] + frame = frame.sort_values(["session", "timestamps"]).drop_duplicates( + subset=["symbol", "session", "timestamps"], keep="last" + ) + return frame[DEFAULT_GROUP_COLUMNS + ["timestamps"] + FEATURE_COLUMNS].reset_index(drop=True), mapping + + +def inspect_stom_tick_db( + db_path: os.PathLike | str, + max_tables: int = 20, + lookback_window: int = 300, + predict_window: int = 60, + price_mode: str = "db_ohlc", +) -> Dict[str, Any]: + """Inspect a STOM SQLite DB and report whether it can produce training windows.""" + + min_rows = lookback_window + predict_window + 1 + db_file = Path(db_path) + conn = connect_readonly(db_file) + try: + all_tables = list_stock_tables(conn, max_tables=None) + scan_tables = all_tables[:max_tables] if max_tables and max_tables > 0 else all_tables + compatible_tables = 0 + eligible_groups = 0 + table_summaries = [] + warnings = set() + + for table in scan_tables: + try: + columns = get_table_columns(conn, table) + mapping = infer_stom_column_mapping(columns, price_mode=price_mode) + warnings.update(mapping.get("warnings", [])) + row_count = conn.execute(f"SELECT COUNT(*) FROM {_quote_ident(table)}").fetchone()[0] + minmax = conn.execute( + f"SELECT MIN({_quote_ident(mapping['timestamp'])}), MAX({_quote_ident(mapping['timestamp'])}) " + f"FROM {_quote_ident(table)}" + ).fetchone() + group_rows = conn.execute( + f"SELECT substr(CAST({_quote_ident(mapping['timestamp'])} AS TEXT), 1, 8) AS session, " + f"COUNT(*) AS rows FROM {_quote_ident(table)} " + f"GROUP BY session HAVING rows >= ? ORDER BY session", + (min_rows,), + ).fetchall() + compatible_tables += 1 + eligible_groups += len(group_rows) + table_summaries.append( + { + "table": table, + "row_count": row_count, + "min_timestamp": minmax[0], + "max_timestamp": minmax[1], + "eligible_sessions": len(group_rows), + "columns": columns, + "mapping": {k: v for k, v in mapping.items() if k != "warnings"}, + } + ) + except Exception as exc: # pragma: no cover - included in JSON report for real DBs + table_summaries.append({"table": table, "error": str(exc)}) + + return { + "db_path": str(db_file), + "db_size_bytes": db_file.stat().st_size if db_file.exists() else None, + "table_count": len(all_tables), + "scanned_table_count": len(scan_tables), + "compatible_table_count": compatible_tables, + "min_rows_per_group": min_rows, + "eligible_group_count": eligible_groups, + "trainable": compatible_tables > 0 and eligible_groups > 0, + "price_mode": price_mode, + "warnings": sorted(warnings), + "tables": table_summaries, + } + finally: + conn.close() + + +def export_stom_tick_db_to_csv( + db_path: os.PathLike | str, + output_path: os.PathLike | str, + max_tables: int = 0, + tables: Optional[Sequence[str]] = None, + lookback_window: int = 300, + predict_window: int = 60, + price_mode: str = "db_ohlc", + time_start: str = "090000", + time_end: str = "093000", + session_start: Optional[str] = None, + session_end: Optional[str] = None, + max_rows_per_group: int = 0, +) -> Dict[str, Any]: + """Convert STOM per-symbol SQLite tables to a grouped Kronos training CSV.""" + + session_start = _normalize_session_bound(session_start, "session_start") + session_end = _normalize_session_bound(session_end, "session_end") + if session_start and session_end and session_start > session_end: + raise ValueError("session_start must be <= session_end") + + min_rows = lookback_window + predict_window + 1 + if max_rows_per_group and max_rows_per_group > 0 and max_rows_per_group < min_rows: + raise ValueError( + f"max_rows_per_group={max_rows_per_group} is smaller than required window rows={min_rows}." + ) + output_file = Path(output_path) + output_file.parent.mkdir(parents=True, exist_ok=True) + + conn = connect_readonly(db_path) + try: + selected_tables = list(tables) if tables else list_stock_tables(conn, max_tables=None) + if max_tables and max_tables > 0: + selected_tables = selected_tables[:max_tables] + + if output_file.exists(): + output_file.unlink() + + written_rows = 0 + written_groups = 0 + skipped_groups = 0 + clipped_groups = 0 + table_reports = [] + wrote_header = False + + for table in selected_tables: + try: + frame, mapping = read_stom_table_as_kline( + conn, + table, + price_mode=price_mode, + time_start=time_start, + time_end=time_end, + session_start=session_start, + session_end=session_end, + ) + kept_parts = [] + for (_, _), group in frame.groupby(DEFAULT_GROUP_COLUMNS, sort=True): + if len(group) >= min_rows: + if max_rows_per_group and max_rows_per_group > 0 and len(group) > max_rows_per_group: + group = group.head(max_rows_per_group) + clipped_groups += 1 + kept_parts.append(group) + else: + skipped_groups += 1 + + if not kept_parts: + table_reports.append( + { + "table": table, + "written_rows": 0, + "written_groups": 0, + "skipped_reason": f"no group with at least {min_rows} rows", + } + ) + continue + + out = pd.concat(kept_parts, ignore_index=True) + out.to_csv( + output_file, + mode="a", + header=not wrote_header, + index=False, + encoding="utf-8-sig", + ) + wrote_header = True + written_rows += len(out) + written_groups += len(kept_parts) + table_reports.append( + { + "table": table, + "written_rows": len(out), + "written_groups": len(kept_parts), + "mapping": {k: v for k, v in mapping.items() if k != "warnings"}, + "warnings": mapping.get("warnings", []), + } + ) + except Exception as exc: # pragma: no cover - included in JSON report for real DBs + table_reports.append({"table": table, "error": str(exc)}) + + return { + "db_path": str(db_path), + "output_path": str(output_file), + "selected_table_count": len(selected_tables), + "written_rows": written_rows, + "written_groups": written_groups, + "skipped_groups": skipped_groups, + "clipped_groups": clipped_groups, + "max_rows_per_group": max_rows_per_group, + "min_rows_per_group": min_rows, + "price_mode": price_mode, + "session_start": session_start, + "session_end": session_end, + "trainable_csv_created": written_rows > 0 and written_groups > 0, + "tables": table_reports, + } + finally: + conn.close() + + +class GroupedKlineDataset: + """Kronos OHLCV dataset that never crosses symbol/session boundaries.""" + + def __init__( + self, + data_path: os.PathLike | str, + data_type: str = "train", + lookback_window: int = 90, + predict_window: int = 10, + clip: float = 5.0, + seed: int = 100, + train_ratio: float = 0.7, + val_ratio: float = 0.15, + test_ratio: float = 0.15, + group_columns: Optional[Sequence[str]] = None, + sample_stride: int = 1, + max_samples: Optional[int] = None, + normalize_using: str = "lookback", + ): + self.data_path = str(data_path) + self.data_type = data_type + self.lookback_window = lookback_window + self.predict_window = predict_window + self.window = lookback_window + predict_window + 1 + self.clip = clip + self.seed = seed + self.train_ratio = train_ratio + self.val_ratio = val_ratio + self.test_ratio = test_ratio + self.group_columns = list(group_columns or DEFAULT_GROUP_COLUMNS) + self.sample_stride = max(1, int(sample_stride)) + self.max_samples = max_samples + self.normalize_using = normalize_using + self.feature_list = FEATURE_COLUMNS + self.time_feature_list = TIME_FEATURE_COLUMNS + self.py_rng = random.Random(seed) + + if normalize_using not in {"lookback", "window"}: + raise ValueError("normalize_using must be one of: lookback, window") + + self._load_groups() + self._split_groups() + self._build_sample_index() + print( + f"[{data_type.upper()}][GROUPED] Groups: {len(self.groups)}, " + f"Available samples: {len(self.sample_index)}" + ) + + def _load_groups(self): + dtype = {column: str for column in self.group_columns} + df = pd.read_csv(self.data_path, dtype=dtype) + required = set(self.group_columns + ["timestamps"] + self.feature_list) + missing = sorted(required - set(df.columns)) + if missing: + raise ValueError(f"Grouped CSV missing required columns: {missing}") + + df["timestamps"] = pd.to_datetime(df["timestamps"]) + df = df.sort_values(self.group_columns + ["timestamps"]).reset_index(drop=True) + + for feature in self.feature_list: + df[feature] = pd.to_numeric(df[feature], errors="coerce") + df[self.feature_list] = df.groupby(self.group_columns, sort=False)[self.feature_list].ffill() + df[self.feature_list] = df.groupby(self.group_columns, sort=False)[self.feature_list].bfill() + df = df.dropna(subset=self.feature_list + ["timestamps"]) + + df["minute"] = df["timestamps"].dt.minute + df["hour"] = df["timestamps"].dt.hour + df["weekday"] = df["timestamps"].dt.weekday + df["day"] = df["timestamps"].dt.day + df["month"] = df["timestamps"].dt.month + + groups = [] + for group_key, group_df in df.groupby(self.group_columns, sort=True): + if len(group_df) < self.window: + continue + if not isinstance(group_key, tuple): + group_key = (group_key,) + values = group_df[self.feature_list + self.time_feature_list].values.astype(np.float32) + timestamps = group_df["timestamps"].reset_index(drop=True) + groups.append( + { + "key": tuple(str(part) for part in group_key), + "first_timestamp": timestamps.iloc[0], + "last_timestamp": timestamps.iloc[-1], + "values": values, + "timestamps": timestamps, + "rows": len(group_df), + } + ) + + if not groups: + raise ValueError( + f"No grouped CSV segment has enough rows for window={self.window}. " + f"Check lookback_window/predict_window or export more data." + ) + self.all_groups = sorted(groups, key=lambda item: (item["first_timestamp"], item["key"])) + + def _split_groups(self): + total = len(self.all_groups) + train_end = int(total * self.train_ratio) + val_end = int(total * (self.train_ratio + self.val_ratio)) + + if self.train_ratio > 0 and train_end == 0 and total > 0: + train_end = 1 + if self.val_ratio > 0 and val_end <= train_end and total > train_end: + val_end = train_end + 1 + + if self.data_type == "train": + groups = self.all_groups[:train_end] + elif self.data_type == "val": + groups = self.all_groups[train_end:val_end] + elif self.data_type == "test": + groups = self.all_groups[val_end:] + else: + raise ValueError("data_type must be one of: train, val, test") + + if not groups: + raise ValueError( + f"No groups available for split '{self.data_type}'. " + f"Ratios train={self.train_ratio}, val={self.val_ratio}, test={self.test_ratio}, " + f"total_groups={total}." + ) + self.groups = groups + + def _build_sample_index(self): + sample_index = [] + for group_idx, group in enumerate(self.groups): + max_start = group["rows"] - self.window + for start_idx in range(0, max_start + 1, self.sample_stride): + sample_index.append((group_idx, start_idx)) + + if self.max_samples is not None and len(sample_index) > self.max_samples: + rng = random.Random(self.seed) + sample_index = sorted(rng.sample(sample_index, int(self.max_samples))) + + self.sample_index = sample_index + if not self.sample_index: + raise ValueError("No samples available after applying grouped dataset filters.") + self.n_samples = len(self.sample_index) + + def set_epoch_seed(self, epoch): + epoch_seed = self.seed + epoch + self.py_rng.seed(epoch_seed) + self.current_epoch = epoch + + def __len__(self): + return len(self.sample_index) + + def _resolve_index(self, idx: int) -> Tuple[int, int]: + if self.data_type == "train": + epoch = getattr(self, "current_epoch", 0) + mapped_idx = (idx * 9973 + (epoch + 1) * 104729) % len(self.sample_index) + return self.sample_index[mapped_idx] + return self.sample_index[idx % len(self.sample_index)] + + def sample_metadata(self, idx: int) -> Dict[str, Any]: + group_idx, start_idx = self._resolve_index(idx) + group = self.groups[group_idx] + end_idx = start_idx + self.window + return { + "group_key": group["key"], + "start_idx": start_idx, + "end_idx": end_idx, + "start_timestamp": str(group["timestamps"].iloc[start_idx]), + "end_timestamp": str(group["timestamps"].iloc[end_idx - 1]), + } + + def get_numpy(self, idx: int) -> Tuple[np.ndarray, np.ndarray]: + group_idx, start_idx = self._resolve_index(idx) + group = self.groups[group_idx] + end_idx = start_idx + self.window + window_data = group["values"][start_idx:end_idx] + + x = window_data[:, : len(self.feature_list)].astype(np.float32) + x_stamp = window_data[:, len(self.feature_list) :].astype(np.float32) + + norm_ref = x[: self.lookback_window] if self.normalize_using == "lookback" else x + x_mean, x_std = np.mean(norm_ref, axis=0), np.std(norm_ref, axis=0) + x = (x - x_mean) / (x_std + 1e-5) + x = np.clip(x, -self.clip, self.clip) + + return x, x_stamp + + def __getitem__(self, idx): + import torch + + x, x_stamp = self.get_numpy(idx) + return torch.from_numpy(x), torch.from_numpy(x_stamp) + + +def _write_json_if_requested(payload: Dict[str, Any], json_output: Optional[str]): + if not json_output: + return + output_path = Path(json_output) + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser(description="Prepare STOM 1tick SQLite data for Kronos finetuning.") + subparsers = parser.add_subparsers(dest="command", required=True) + + inspect_parser = subparsers.add_parser("inspect", help="Inspect DB schema and trainability.") + inspect_parser.add_argument("--db", required=True, help="Path to STOM SQLite DB.") + inspect_parser.add_argument("--max-tables", type=int, default=20, help="Tables to scan. 0 means all.") + inspect_parser.add_argument("--lookback-window", type=int, default=300) + inspect_parser.add_argument("--predict-window", type=int, default=60) + inspect_parser.add_argument("--price-mode", choices=["db_ohlc", "close_only"], default="db_ohlc") + inspect_parser.add_argument("--json-output", default=None) + + export_parser = subparsers.add_parser("export", help="Export grouped Kronos OHLCV CSV.") + export_parser.add_argument("--db", required=True, help="Path to STOM SQLite DB.") + export_parser.add_argument("--output", required=True, help="Output CSV path.") + export_parser.add_argument("--max-tables", type=int, default=100, help="Tables to export. 0 means all.") + export_parser.add_argument("--tables", nargs="*", default=None, help="Optional explicit table/symbol names.") + export_parser.add_argument("--lookback-window", type=int, default=300) + export_parser.add_argument("--predict-window", type=int, default=60) + export_parser.add_argument("--price-mode", choices=["db_ohlc", "close_only"], default="db_ohlc") + export_parser.add_argument("--time-start", default="090000") + export_parser.add_argument("--time-end", default="093000") + export_parser.add_argument("--session-start", default=None, help="Optional inclusive YYYYMMDD session lower bound.") + export_parser.add_argument("--session-end", default=None, help="Optional inclusive YYYYMMDD session upper bound.") + export_parser.add_argument( + "--max-rows-per-group", + type=int, + default=0, + help="Clip each symbol/session group to this many contiguous rows after filtering. 0 means keep all rows.", + ) + export_parser.add_argument("--json-output", default=None) + + args = parser.parse_args(argv) + + if args.command == "inspect": + payload = inspect_stom_tick_db( + db_path=args.db, + max_tables=args.max_tables, + lookback_window=args.lookback_window, + predict_window=args.predict_window, + price_mode=args.price_mode, + ) + else: + payload = export_stom_tick_db_to_csv( + db_path=args.db, + output_path=args.output, + max_tables=args.max_tables, + tables=args.tables, + lookback_window=args.lookback_window, + predict_window=args.predict_window, + price_mode=args.price_mode, + time_start=args.time_start, + time_end=args.time_end, + session_start=args.session_start, + session_end=args.session_end, + max_rows_per_group=args.max_rows_per_group, + ) + + print(json.dumps(payload, ensure_ascii=False, indent=2)) + _write_json_if_requested(payload, getattr(args, "json_output", None)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/finetune_csv/train_sequential.py b/finetune_csv/train_sequential.py index 533f66a13..2b7d6b735 100644 --- a/finetune_csv/train_sequential.py +++ b/finetune_csv/train_sequential.py @@ -7,7 +7,10 @@ from torch.utils.data import DataLoader import torch.distributed as dist -sys.path.append('../') +PROJECT_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), os.pardir)) +if PROJECT_ROOT not in sys.path: + sys.path.insert(0, PROJECT_ROOT) + from model import Kronos, KronosTokenizer, KronosPredictor from config_loader import CustomFinetuneConfig @@ -150,9 +153,12 @@ def train_basemodel_phase(self): print("Starting Basemodel Fine-tuning Phase") print("="*60) - if getattr(self.config, 'pre_trained_tokenizer', True): - if not os.path.exists(self.config.finetuned_tokenizer_path): - raise FileNotFoundError(f"Fine-tuned tokenizer does not exist: {self.config.finetuned_tokenizer_path}") + tokenizer_load_path = self.config.finetuned_tokenizer_path + if getattr(self.config, 'pre_trained_tokenizer', True) and not getattr(self.config, 'train_tokenizer', True): + tokenizer_load_path = self.config.pretrained_tokenizer_path + elif getattr(self.config, 'pre_trained_tokenizer', True): + if not os.path.exists(tokenizer_load_path): + raise FileNotFoundError(f"Fine-tuned tokenizer does not exist: {tokenizer_load_path}") _, basemodel_exists = self._check_existing_models() if basemodel_exists and self.config.skip_existing: @@ -165,10 +171,10 @@ def train_basemodel_phase(self): set_seed(self.config.seed) if getattr(self.config, 'pre_trained_tokenizer', True): - logger.info("Loading fine-tuned tokenizer...") + logger.info(f"Loading tokenizer: {tokenizer_load_path}") if self.rank == 0: - print("Loading fine-tuned tokenizer...") - tokenizer = KronosTokenizer.from_pretrained(self.config.finetuned_tokenizer_path) + print(f"Loading tokenizer: {tokenizer_load_path}") + tokenizer = KronosTokenizer.from_pretrained(tokenizer_load_path) else: if self.rank == 0: print("pre_trained_tokenizer=False, randomly initializing Tokenizer architecture for Predictor training") @@ -237,7 +243,7 @@ def train_basemodel_phase(self): logger.info(f"Learning rate: {self.config.predictor_learning_rate}") logger.info(f"Training epochs: {self.config.basemodel_epochs}") logger.info(f"Device: {self.device}") - logger.info(f"Tokenizer path: {self.config.finetuned_tokenizer_path}") + logger.info(f"Tokenizer path: {tokenizer_load_path}") logger.info(f"Pretrained model path: {self.config.pretrained_predictor_path}") logger.info("Starting fine-tuning training...") diff --git a/requirements.txt b/requirements.txt index 598a3dedc..e6bc9de7a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,8 @@ numpy pandas torch>=2.0.0 +gymnasium>=0.29.1,<1.3.0 +stable-baselines3==2.8.0 einops==0.8.1 huggingface_hub==0.33.1 diff --git a/stom_rl/AGENTS.md b/stom_rl/AGENTS.md new file mode 100644 index 000000000..9bdb0a311 --- /dev/null +++ b/stom_rl/AGENTS.md @@ -0,0 +1,54 @@ +# stom_rl Knowledge + +## Overview + +`stom_rl/` contains STOM trading research: gap-up rules, supervised gates, +portfolio/RL environments, orderbook RL readiness, and experiment CLIs. + +## Current Boundary + +- Main useful baseline is the `ts_imb` opening gap-up RULE strategy. +- RL/orderbook code is experimental unless it beats explicit baselines under + cost-aware OOS gates. +- Skip-gate and state-exit gate have documented full-universe `NO-GO` results; + do not rerun/tune them without a new preregistered hypothesis. + +## Key Files + +| File | Role | +|---|---| +| `gap_up_backtest.py` | Main gap-up rule/backtest machinery. | +| `marketable_fill.py` | Marketable-fill accounting for buy/exit assumptions. | +| `skip_gate.py` | Entry skip-gate; documented `NO-GO`. | +| `state_exit_gate.py` | 30-second early-exit gate; documented `NO-GO`. | +| `orderbook_rl_env.py` | Orderbook RL environment and readiness scan. | +| `orderbook_sb3_adapter.py` | SB3 adapter with invalid-action constraints. | +| `orderbook_sb3_smoke.py` | Orderbook RL smoke/evaluation CLI. | +| `portfolio_*` | Portfolio-level RL/walk-forward experiments. | + +## Rules + +- Label each strategy as `RULE`, `supervised gate`, `portfolio RL`, or + `orderbook RL`. Do not blur these labels. +- Cost-aware net return is the default evaluation target. +- Do not claim alpha from `n_folds < 5` or from a single favorable split. +- Include no-trade/rule/baseline comparisons for any new RL result. +- For sparse action spaces, track invalid-action rate or use masked/constrained + actions. Plain PPO/DQN churn is not a useful success signal. +- Generated artifacts should be written under `.omx/artifacts/` or + `webui/rl_runs/`, not mixed into source directories. + +## Verification + +```powershell +py -3.11 -m pytest tests/test_stom_rl_gap_up_backtest.py tests/test_stom_rl_marketable_fill.py -q +py -3.11 -m pytest tests/test_stom_rl_orderbook_env.py tests/test_stom_rl_orderbook_sb3.py -q +py -3.11 -m pytest tests/test_stom_rl_skip_gate.py tests/test_stom_rl_state_exit_gate.py -q +``` + +## Anti-Patterns + +- Retuning after seeing OOS/full-universe results. +- Calling a rule-generated equity curve "RL". +- Hiding `NO-GO` behind dashboard cosmetics. +- Evaluating high-frequency trades without spread/marketable-fill assumptions. diff --git a/stom_rl/__init__.py b/stom_rl/__init__.py new file mode 100644 index 000000000..5abc4ade2 --- /dev/null +++ b/stom_rl/__init__.py @@ -0,0 +1,11 @@ +"""STOM tick/back-data reinforcement-learning utilities. + +The package is intentionally independent from Kronos model code. It may reuse +existing STOM data artifacts as inputs, but it does not depend on Kronos +tokenizers, predictors, or checkpoints. + +Import concrete utilities from submodules, for example +``stom_rl.episode_manifest`` or ``stom_rl.trading_env``. Keeping the package +initializer side-effect-free also avoids double-import warnings when running +``python -m`` submodules. +""" diff --git a/stom_rl/accounting.py b/stom_rl/accounting.py new file mode 100644 index 000000000..e9560ce2d --- /dev/null +++ b/stom_rl/accounting.py @@ -0,0 +1,225 @@ +"""Shared portfolio accounting primitives for STOM RL. + +The portfolio pages use one source of truth for cash, holdings, NAV, and trade +costs. The module is intentionally small and deterministic so the single +symbol environment, portfolio environment, risk gate, and paper replay can all +share the same invariants without introducing a broker dependency. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from typing import Dict, Mapping, Optional + + +FLOAT_TOLERANCE = 1e-8 + + +@dataclass +class PositionLot: + """Long-only position state for one symbol.""" + + symbol: str + quantity: float = 0.0 + average_price: float = 0.0 + + def market_value(self, price: float) -> float: + return float(self.quantity) * float(price) + + def to_dict(self, price: Optional[float] = None) -> Dict[str, float | str]: + payload: Dict[str, float | str] = { + "symbol": self.symbol, + "quantity": float(self.quantity), + "average_price": float(self.average_price), + } + if price is not None: + payload["market_price"] = float(price) + payload["market_value"] = self.market_value(float(price)) + payload["unrealized_pnl"] = (float(price) - self.average_price) * self.quantity + return payload + + +@dataclass(frozen=True) +class TradeFill: + """Executed trade record with explicit cost/slippage accounting.""" + + timestamp: str + symbol: str + side: str + price: float + quantity: float + gross_value: float + cost: float + cash_after: float + realized_pnl: float = 0.0 + + def to_dict(self) -> Dict[str, float | str]: + return asdict(self) + + +@dataclass +class PortfolioAccount: + """Long-only cash account with explicit NAV invariants. + + Costs are applied exactly once per fill as ``gross_value * cost_pct`` where + ``cost_pct = (cost_bps + slippage_bps) / 10_000``. Margin is deliberately + unsupported; buy orders that would make cash negative are rejected. + """ + + initial_cash: float = 1_000_000.0 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + cash: Optional[float] = None + positions: Dict[str, PositionLot] = field(default_factory=dict) + realized_pnl: float = 0.0 + trade_count: int = 0 + + def __post_init__(self) -> None: + if self.initial_cash <= 0: + raise ValueError("initial_cash must be positive") + if self.cash is None: + self.cash = float(self.initial_cash) + if self.cash < -FLOAT_TOLERANCE: + raise ValueError("cash cannot be negative") + + @property + def cost_pct(self) -> float: + return (float(self.cost_bps) + float(self.slippage_bps)) / 10_000.0 + + def clone(self) -> "PortfolioAccount": + return PortfolioAccount( + initial_cash=float(self.initial_cash), + cost_bps=float(self.cost_bps), + slippage_bps=float(self.slippage_bps), + cash=float(self.cash or 0.0), + positions={symbol: PositionLot(pos.symbol, pos.quantity, pos.average_price) for symbol, pos in self.positions.items()}, + realized_pnl=float(self.realized_pnl), + trade_count=int(self.trade_count), + ) + + def position(self, symbol: str) -> PositionLot: + return self.positions.get(str(symbol), PositionLot(str(symbol))) + + def holdings_value(self, prices: Mapping[str, float]) -> float: + value = 0.0 + for symbol, position in self.positions.items(): + if position.quantity <= FLOAT_TOLERANCE: + continue + if symbol not in prices: + raise KeyError(f"Missing mark price for held symbol: {symbol}") + value += position.market_value(float(prices[symbol])) + return float(value) + + def nav(self, prices: Mapping[str, float]) -> float: + return float(self.cash or 0.0) + self.holdings_value(prices) + + def assert_invariants(self, prices: Mapping[str, float]) -> None: + if (self.cash or 0.0) < -FLOAT_TOLERANCE: + raise AssertionError(f"cash is negative: {self.cash}") + for symbol, position in self.positions.items(): + if position.quantity < -FLOAT_TOLERANCE: + raise AssertionError(f"{symbol} quantity is negative: {position.quantity}") + if position.quantity > FLOAT_TOLERANCE and position.average_price <= 0: + raise AssertionError(f"{symbol} average_price must be positive") + expected_nav = float(self.cash or 0.0) + self.holdings_value(prices) + actual_nav = self.nav(prices) + if abs(actual_nav - expected_nav) > FLOAT_TOLERANCE: + raise AssertionError(f"NAV drift: actual={actual_nav}, expected={expected_nav}") + + def buy( + self, + *, + symbol: str, + price: float, + quantity: Optional[float] = None, + notional: Optional[float] = None, + timestamp: str = "", + ) -> TradeFill: + symbol = str(symbol) + price = float(price) + if price <= 0: + raise ValueError("price must be positive") + if quantity is None: + if notional is None: + raise ValueError("quantity or notional is required") + quantity = float(notional) / price + quantity = float(quantity) + if quantity <= 0: + raise ValueError("quantity must be positive") + gross = price * quantity + cost = gross * self.cost_pct + cash_needed = gross + cost + if cash_needed > float(self.cash or 0.0) + FLOAT_TOLERANCE: + raise ValueError("buy would make cash negative") + + previous = self.positions.get(symbol, PositionLot(symbol)) + new_qty = previous.quantity + quantity + new_avg = ((previous.quantity * previous.average_price) + gross) / new_qty + self.positions[symbol] = PositionLot(symbol=symbol, quantity=float(new_qty), average_price=float(new_avg)) + self.cash = float(self.cash or 0.0) - cash_needed + self.trade_count += 1 + return TradeFill( + timestamp=timestamp, + symbol=symbol, + side="buy", + price=price, + quantity=quantity, + gross_value=gross, + cost=cost, + cash_after=float(self.cash), + ) + + def sell( + self, + *, + symbol: str, + price: float, + quantity: Optional[float] = None, + timestamp: str = "", + ) -> TradeFill: + symbol = str(symbol) + price = float(price) + if price <= 0: + raise ValueError("price must be positive") + position = self.positions.get(symbol) + if position is None or position.quantity <= FLOAT_TOLERANCE: + raise ValueError(f"no position to sell for {symbol}") + sell_qty = position.quantity if quantity is None else float(quantity) + if sell_qty <= 0: + raise ValueError("quantity must be positive") + if sell_qty > position.quantity + FLOAT_TOLERANCE: + raise ValueError("sell quantity exceeds position quantity") + + sell_qty = min(sell_qty, position.quantity) + gross = price * sell_qty + cost = gross * self.cost_pct + realized = (price - position.average_price) * sell_qty - cost + self.cash = float(self.cash or 0.0) + gross - cost + remaining = position.quantity - sell_qty + if remaining <= FLOAT_TOLERANCE: + self.positions.pop(symbol, None) + else: + self.positions[symbol] = PositionLot(symbol=symbol, quantity=float(remaining), average_price=position.average_price) + self.realized_pnl += realized + self.trade_count += 1 + return TradeFill( + timestamp=timestamp, + symbol=symbol, + side="sell", + price=price, + quantity=sell_qty, + gross_value=gross, + cost=cost, + cash_after=float(self.cash), + realized_pnl=realized, + ) + + def snapshot(self, prices: Mapping[str, float]) -> Dict[str, object]: + return { + "cash": float(self.cash or 0.0), + "holdings_value": self.holdings_value(prices), + "nav": self.nav(prices), + "realized_pnl": float(self.realized_pnl), + "trade_count": int(self.trade_count), + "positions": [position.to_dict(prices.get(symbol)) for symbol, position in sorted(self.positions.items())], + } diff --git a/stom_rl/baselines.py b/stom_rl/baselines.py new file mode 100644 index 000000000..a401fadb0 --- /dev/null +++ b/stom_rl/baselines.py @@ -0,0 +1,587 @@ +"""Baseline strategy runner for the STOM independent RL lab. + +Page 4 establishes non-RL reference strategies before any learning model is +trained. The runner uses ``StomTickTradingEnv`` for the same action/episode +contract that future agents will see, while separately tracking realized +trade/equity metrics that are easier to compare across baseline policies. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import asdict, dataclass, field +from math import exp, log +from pathlib import Path +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple + +import numpy as np + +from .episode_manifest import DEFAULT_OUTPUT_DIR +from .trading_env import ( + ACTION_BUY, + ACTION_HOLD, + ACTION_NAMES, + ACTION_SELL, + StomTickTradingEnv, + StomTickTradingEnvConfig, +) + + +DEFAULT_BASELINE_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_1s_2025_baselines" +DEFAULT_POLICIES = ( + "no_trade", + "random", + "buy_and_hold", + "momentum", + "mean_reversion", + "volume_filter", +) + + +@dataclass(frozen=True) +class BaselineRunConfig: + """Configuration for running deterministic baseline strategies.""" + + manifest_path: str = str(DEFAULT_OUTPUT_DIR / "episode_manifest.json") + output_dir: str = str(DEFAULT_BASELINE_OUTPUT_DIR) + split: str = "test" + policies: Tuple[str, ...] = DEFAULT_POLICIES + max_episodes: int = 25 + seed: int = 100 + lookback_window: int = 300 + reward_horizon_seconds: int = 300 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + momentum_window: int = 30 + signal_threshold_bps: float = 0.0 + volume_window: int = 30 + amount_multiplier: float = 1.5 + max_steps_per_episode: int = 0 + episode_ids: Tuple[str, ...] = field(default_factory=tuple) + sessions: Tuple[str, ...] = field(default_factory=tuple) + write_artifacts: bool = True + + +@dataclass +class AccountState: + """Simple long-only accounting separate from dense RL reward.""" + + cost_pct: float + equity: float = 1.0 + position: int = 0 + entry_price: float = 0.0 + entry_timestamp: str = "" + entry_equity_before_cost: float = 1.0 + entry_equity_after_cost: float = 1.0 + trades: List[Dict[str, Any]] = field(default_factory=list) + forced_exit_count: int = 0 + + def mark_equity(self, price: float) -> float: + if self.position == 0: + return float(self.equity) + return float(self.entry_equity_after_cost * (price / self.entry_price)) + + def apply_action( + self, + *, + action: int, + price: float, + timestamp: str, + episode_id: str, + policy: str, + forced: bool = False, + ) -> Optional[Dict[str, Any]]: + if action == ACTION_BUY and self.position == 0: + self.entry_price = float(price) + self.entry_timestamp = timestamp + self.entry_equity_before_cost = float(self.equity) + self.equity *= 1.0 - self.cost_pct + self.entry_equity_after_cost = float(self.equity) + self.position = 1 + return None + if action == ACTION_SELL and self.position == 1: + gross_return = (float(price) - self.entry_price) / self.entry_price + equity_before_entry = self.entry_equity_before_cost + exit_equity = self.entry_equity_after_cost * (float(price) / self.entry_price) * (1.0 - self.cost_pct) + trade_net_return = (exit_equity / equity_before_entry) - 1.0 + self.equity = float(exit_equity) + trade = { + "policy": policy, + "episode_id": episode_id, + "entry_timestamp": self.entry_timestamp, + "exit_timestamp": timestamp, + "entry_price": self.entry_price, + "exit_price": float(price), + "gross_return_pct": gross_return * 100.0, + "net_return_pct": trade_net_return * 100.0, + "cost_pct": self.cost_pct * 100.0, + "forced_exit": bool(forced), + } + self.trades.append(trade) + if forced: + self.forced_exit_count += 1 + self.position = 0 + self.entry_price = 0.0 + self.entry_timestamp = "" + self.entry_equity_before_cost = self.equity + self.entry_equity_after_cost = self.equity + return trade + return None + + +PolicyFn = Callable[[StomTickTradingEnv, Mapping[str, Any], np.random.Generator, BaselineRunConfig], int] + + +def _recent_return(env: StomTickTradingEnv, window: int) -> float: + idx = env.current_idx + start_idx = max(0, idx - int(window)) + past = env._close_at(start_idx) + current = env._close_at(idx) + return float((current - past) / past) if past else 0.0 + + +def _recent_amount_ratio(env: StomTickTradingEnv, window: int) -> float: + idx = env.current_idx + start_idx = max(0, idx - int(window)) + hist = env.frame.iloc[start_idx:idx] + if hist.empty: + return 0.0 + current_amount = float(env.frame["amount"].iloc[idx]) + avg_amount = float(hist["amount"].mean()) + return current_amount / avg_amount if avg_amount > 0 else 0.0 + + +def policy_no_trade(env: StomTickTradingEnv, info: Mapping[str, Any], rng: np.random.Generator, config: BaselineRunConfig) -> int: + return ACTION_HOLD + + +def policy_random(env: StomTickTradingEnv, info: Mapping[str, Any], rng: np.random.Generator, config: BaselineRunConfig) -> int: + if env.position == 0: + return int(rng.choice([ACTION_HOLD, ACTION_BUY])) + return int(rng.choice([ACTION_HOLD, ACTION_SELL])) + + +def policy_buy_and_hold(env: StomTickTradingEnv, info: Mapping[str, Any], rng: np.random.Generator, config: BaselineRunConfig) -> int: + if env.position == 0 and env.current_idx == env.config.lookback_window: + return ACTION_BUY + return ACTION_HOLD + + +def policy_momentum(env: StomTickTradingEnv, info: Mapping[str, Any], rng: np.random.Generator, config: BaselineRunConfig) -> int: + threshold = float(config.signal_threshold_bps) / 10_000.0 + recent = _recent_return(env, config.momentum_window) + if env.position == 0 and recent > threshold: + return ACTION_BUY + if env.position == 1 and recent <= -threshold: + return ACTION_SELL + return ACTION_HOLD + + +def policy_mean_reversion(env: StomTickTradingEnv, info: Mapping[str, Any], rng: np.random.Generator, config: BaselineRunConfig) -> int: + threshold = float(config.signal_threshold_bps) / 10_000.0 + recent = _recent_return(env, config.momentum_window) + if env.position == 0 and recent < -threshold: + return ACTION_BUY + if env.position == 1 and recent >= threshold: + return ACTION_SELL + return ACTION_HOLD + + +def policy_volume_filter(env: StomTickTradingEnv, info: Mapping[str, Any], rng: np.random.Generator, config: BaselineRunConfig) -> int: + recent = _recent_return(env, config.momentum_window) + amount_ratio = _recent_amount_ratio(env, config.volume_window) + threshold = float(config.signal_threshold_bps) / 10_000.0 + if env.position == 0 and recent > threshold and amount_ratio >= config.amount_multiplier: + return ACTION_BUY + if env.position == 1 and (recent <= -threshold or amount_ratio < 1.0): + return ACTION_SELL + return ACTION_HOLD + + +POLICY_REGISTRY: Dict[str, PolicyFn] = { + "no_trade": policy_no_trade, + "random": policy_random, + "buy_and_hold": policy_buy_and_hold, + "momentum": policy_momentum, + "mean_reversion": policy_mean_reversion, + "volume_filter": policy_volume_filter, +} + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _max_drawdown_pct(equity_values: Sequence[float]) -> float: + peak = 1.0 + max_dd = 0.0 + for value in equity_values: + peak = max(peak, float(value)) + if peak > 0: + max_dd = min(max_dd, (float(value) / peak) - 1.0) + return max_dd * 100.0 + + +def _safe_compounded_return_pct(final_equities: Sequence[float]) -> float: + if not final_equities: + return 0.0 + total_log = sum(log(max(float(value), 1e-12)) for value in final_equities) + if total_log > 50: + return float("inf") + if total_log < -50: + return -100.0 + return (exp(total_log) - 1.0) * 100.0 + + +def _selected_episode_indices(episodes: Sequence[Mapping[str, Any]], config: BaselineRunConfig) -> List[int]: + episode_ids = {str(episode_id) for episode_id in config.episode_ids} + sessions = {str(session) for session in config.sessions} + selected = [ + idx + for idx, episode in enumerate(episodes) + if (not episode_ids or str(episode.get("episode_id")) in episode_ids) + and (not sessions or str(episode.get("session")) in sessions) + ] + if config.max_episodes and config.max_episodes > 0: + return selected[: int(config.max_episodes)] + return selected + + +def _force_close_if_needed( + env: StomTickTradingEnv, + account: AccountState, + policy_name: str, + episode_id: str, +) -> Optional[Dict[str, Any]]: + if account.position == 0: + return None + idx = min(env.current_idx, env.max_action_idx) + price = env._close_at(idx) + timestamp = env._timestamp_at(idx) + return account.apply_action( + action=ACTION_SELL, + price=price, + timestamp=timestamp, + episode_id=episode_id, + policy=policy_name, + forced=True, + ) + + +def _run_policy(policy_name: str, config: BaselineRunConfig) -> Dict[str, Any]: + if policy_name not in POLICY_REGISTRY: + raise ValueError(f"Unknown baseline policy: {policy_name}") + policy = POLICY_REGISTRY[policy_name] + rng = np.random.default_rng(config.seed + sum(ord(ch) for ch in policy_name)) + env_config = StomTickTradingEnvConfig( + manifest_path=config.manifest_path, + split=config.split, + seed=config.seed, + lookback_window=config.lookback_window, + reward_horizon_seconds=config.reward_horizon_seconds, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + reward_mode="horizon", + ) + probe_env = StomTickTradingEnv(env_config) + selected_indices = _selected_episode_indices(probe_env.episodes, config) + + action_rows: List[Dict[str, Any]] = [] + equity_rows: List[Dict[str, Any]] = [] + trade_rows: List[Dict[str, Any]] = [] + episode_rows: List[Dict[str, Any]] = [] + aggregate_equity_curve = [1.0] + action_count = 0 + invalid_action_count = 0 + + for episode_index in selected_indices: + env = StomTickTradingEnv(StomTickTradingEnvConfig(**{**asdict(env_config), "episode_index": episode_index})) + _, info = env.reset(seed=config.seed + episode_index) + episode_id = str(info["episode_id"]) + account = AccountState(cost_pct=(config.cost_bps + config.slippage_bps) / 10_000.0) + terminated = False + truncated = False + step_counter = 0 + while not (terminated or truncated): + if config.max_steps_per_episode and step_counter >= config.max_steps_per_episode: + break + price = env._close_at(env.current_idx) + timestamp = env._timestamp_at(env.current_idx) + action = policy(env, info, rng, config) + _, reward, terminated, truncated, step_info = env.step(action) + trade = account.apply_action( + action=action, + price=price, + timestamp=timestamp, + episode_id=episode_id, + policy=policy_name, + ) + if trade: + trade_rows.append(trade) + mark_equity = account.mark_equity(price) + action_rows.append( + { + "policy": policy_name, + "episode_id": episode_id, + "symbol": info["symbol"], + "session": info["session"], + "step_idx": info["current_idx"], + "timestamp": timestamp, + "price": price, + "action": action, + "action_name": ACTION_NAMES[action], + "position_after": account.position, + "env_reward": reward, + "mark_equity": mark_equity, + "invalid_action": step_info["invalid_action"], + } + ) + equity_rows.append( + { + "policy": policy_name, + "episode_id": episode_id, + "timestamp": timestamp, + "equity": mark_equity, + "position": account.position, + } + ) + action_count += 1 + invalid_action_count += int(bool(step_info["invalid_action"])) + info = step_info + step_counter += 1 + + forced_trade = _force_close_if_needed(env, account, policy_name, episode_id) + if forced_trade: + trade_rows.append(forced_trade) + episode_return_pct = (account.equity - 1.0) * 100.0 + episode_rows.append( + { + "policy": policy_name, + "episode_id": episode_id, + "symbol": info["symbol"], + "session": info["session"], + "final_equity": account.equity, + "episode_return_pct": episode_return_pct, + "trade_count": len(account.trades), + "forced_exit_count": account.forced_exit_count, + "steps": step_counter, + } + ) + aggregate_equity_curve.append(aggregate_equity_curve[-1] * max(account.equity, 1e-12)) + + final_equities = [float(row["final_equity"]) for row in episode_rows] + trade_returns = [float(row["net_return_pct"]) for row in trade_rows] + hit_count = sum(1 for value in trade_returns if value > 0) + summary = { + "policy": policy_name, + "split": config.split, + "episode_count": len(episode_rows), + "action_count": action_count, + "trade_count": len(trade_rows), + "forced_exit_count": sum(int(row["forced_exit"]) for row in trade_rows), + "avg_episode_net_return_pct": float(np.mean([row["episode_return_pct"] for row in episode_rows])) + if episode_rows + else 0.0, + "median_episode_net_return_pct": float(np.median([row["episode_return_pct"] for row in episode_rows])) + if episode_rows + else 0.0, + "compounded_return_pct": _safe_compounded_return_pct(final_equities), + "avg_trade_net_return_pct": float(np.mean(trade_returns)) if trade_returns else 0.0, + "hit_rate": hit_count / len(trade_returns) if trade_returns else 0.0, + "invalid_action_rate": invalid_action_count / action_count if action_count else 0.0, + "max_drawdown_pct": _max_drawdown_pct(aggregate_equity_curve), + } + return { + "summary": summary, + "episodes": episode_rows, + "trades": trade_rows, + "actions": action_rows, + "equity": equity_rows, + } + + +def run_baselines(config: BaselineRunConfig) -> Dict[str, Any]: + """Run configured baseline policies and optionally write artifacts.""" + + output_dir = Path(config.output_dir) + per_policy: Dict[str, Any] = {} + all_summaries: List[Dict[str, Any]] = [] + for policy_name in config.policies: + result = _run_policy(policy_name, config) + per_policy[policy_name] = result + all_summaries.append(result["summary"]) + if config.write_artifacts: + policy_dir = output_dir / policy_name + _write_csv( + policy_dir / "actions.csv", + result["actions"], + [ + "policy", + "episode_id", + "symbol", + "session", + "step_idx", + "timestamp", + "price", + "action", + "action_name", + "position_after", + "env_reward", + "mark_equity", + "invalid_action", + ], + ) + _write_csv( + policy_dir / "trades.csv", + result["trades"], + [ + "policy", + "episode_id", + "entry_timestamp", + "exit_timestamp", + "entry_price", + "exit_price", + "gross_return_pct", + "net_return_pct", + "cost_pct", + "forced_exit", + ], + ) + _write_csv( + policy_dir / "equity.csv", + result["equity"], + ["policy", "episode_id", "timestamp", "equity", "position"], + ) + _write_csv( + policy_dir / "episodes.csv", + result["episodes"], + [ + "policy", + "episode_id", + "symbol", + "session", + "final_equity", + "episode_return_pct", + "trade_count", + "forced_exit_count", + "steps", + ], + ) + + ranking = sorted(all_summaries, key=lambda item: item["avg_episode_net_return_pct"], reverse=True) + payload = { + "mode": "stom_rl_baseline_run", + "config": asdict(config), + "summary": { + "policy_count": len(config.policies), + "episode_limit": config.max_episodes, + "split": config.split, + "best_policy_by_avg_episode_net": ranking[0]["policy"] if ranking else None, + "policies": all_summaries, + "ranking": ranking, + }, + "artifacts": { + "output_dir": str(output_dir), + "summary_json": str(output_dir / "baseline_summary.json"), + "summary_csv": str(output_dir / "baseline_summary.csv"), + }, + } + if config.write_artifacts: + _write_json(output_dir / "baseline_summary.json", payload) + _write_csv( + output_dir / "baseline_summary.csv", + all_summaries, + [ + "policy", + "split", + "episode_count", + "action_count", + "trade_count", + "forced_exit_count", + "avg_episode_net_return_pct", + "median_episode_net_return_pct", + "compounded_return_pct", + "avg_trade_net_return_pct", + "hit_rate", + "invalid_action_rate", + "max_drawdown_pct", + ], + ) + return payload + + +def _parse_policies(raw: str) -> Tuple[str, ...]: + policies = tuple(part.strip() for part in raw.split(",") if part.strip()) + unknown = [policy for policy in policies if policy not in POLICY_REGISTRY] + if unknown: + raise ValueError(f"Unknown policies: {unknown}. Available: {sorted(POLICY_REGISTRY)}") + return policies + + +def _parse_list_arg(raw: str) -> Tuple[str, ...]: + return tuple(part.strip() for part in raw.split(",") if part.strip()) + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> BaselineRunConfig: + parser = argparse.ArgumentParser(description="Run STOM RL baseline strategies.") + parser.add_argument("--manifest", default=str(DEFAULT_OUTPUT_DIR / "episode_manifest.json")) + parser.add_argument("--output-dir", default=str(DEFAULT_BASELINE_OUTPUT_DIR)) + parser.add_argument("--split", default="test") + parser.add_argument("--policies", default=",".join(DEFAULT_POLICIES)) + parser.add_argument("--max-episodes", type=int, default=25) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--reward-horizon-seconds", type=int, default=300) + parser.add_argument("--cost-bps", type=float, default=25.0) + parser.add_argument("--slippage-bps", type=float, default=0.0) + parser.add_argument("--momentum-window", type=int, default=30) + parser.add_argument("--signal-threshold-bps", type=float, default=0.0) + parser.add_argument("--volume-window", type=int, default=30) + parser.add_argument("--amount-multiplier", type=float, default=1.5) + parser.add_argument("--max-steps-per-episode", type=int, default=0) + parser.add_argument("--episode-ids", default="", help="Comma-separated episode_id filter.") + parser.add_argument("--sessions", default="", help="Comma-separated session filter such as 20250103,20250106.") + parser.add_argument("--no-write", action="store_true") + args = parser.parse_args(argv) + return BaselineRunConfig( + manifest_path=args.manifest, + output_dir=args.output_dir, + split=args.split, + policies=_parse_policies(args.policies), + max_episodes=args.max_episodes, + seed=args.seed, + lookback_window=args.lookback_window, + reward_horizon_seconds=args.reward_horizon_seconds, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + momentum_window=args.momentum_window, + signal_threshold_bps=args.signal_threshold_bps, + volume_window=args.volume_window, + amount_multiplier=args.amount_multiplier, + max_steps_per_episode=args.max_steps_per_episode, + episode_ids=_parse_list_arg(args.episode_ids), + sessions=_parse_list_arg(args.sessions), + write_artifacts=not args.no_write, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + config = _parse_args(argv) + payload = run_baselines(config) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/candidate_gen.py b/stom_rl/candidate_gen.py new file mode 100644 index 000000000..bb54043fc --- /dev/null +++ b/stom_rl/candidate_gen.py @@ -0,0 +1,204 @@ +"""Page 9 — real candidate generation from a Page 7.5 multi-symbol panel. + +This is a *thin* module: it does not re-implement panel building or screening. +It glues the Page 7.5 panel (:mod:`stom_rl.panel_join`) to the Page 8 safe +screener (:mod:`stom_rl.condition_screener`) and adds a per-timestamp top-K +distribution report. + +Pipeline +-------- +1. A long-format panel ``timestamp, symbol, <14 canonical features>`` is built + by :func:`stom_rl.panel_join.build_panel_from_db` (per-day-chunk, never a + full scan) or supplied directly / via CSV. +2. :func:`stom_rl.condition_screener.screen_frame` applies one rule JSON and + returns candidates carrying the **T+1 fill contract** (``price`` = decision + close at ``T``; ``fill_price`` = next-bar close; last bar → ``fill_price`` + NaN / ``fillable == False``). See the screener module docstring for the + authoritative contract; Page 10/11/12 reuse it. +3. :func:`build_topk_report` summarises, per decision timestamp, how many + symbols passed and the rank_score distribution (for top-K position sizing). + +Leakage guard: the panel rows are point-in-time correct (as-of backward join), +``price`` is the close at ``T``, and ``fill_price`` is drawn strictly from a +*later* bar (T+1). No candidate feature uses data after its decision time. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +import pandas as pd + +from stom_rl.condition_screener import ( + ConditionRule, + load_rules, + screen_frame, + write_candidates, +) +from stom_rl.panel_join import build_panel_from_db +from stom_rl.symbol_norm import read_candidates_csv + + +def generate_candidates( + panel: pd.DataFrame, + rules: Sequence[ConditionRule], + *, + strategy_side: str = "buy", + drop_unfillable: bool = False, +) -> pd.DataFrame: + """Screen a Page 7.5 panel into candidates with the T+1 fill contract. + + A thin pass-through to :func:`screen_frame`; kept as a named entry point so + the Page 9 pipeline (and tests) have a stable seam over the panel→candidate + boundary. The panel must carry ``timestamp``, ``symbol`` and a price column + (``close`` for the canonical panel). + """ + + return screen_frame( + panel, + rules, + strategy_side=strategy_side, + drop_unfillable=drop_unfillable, + ) + + +def build_topk_report(candidates: pd.DataFrame, top_k: int = 3) -> pd.DataFrame: + """Per-timestamp passing-symbol count + rank_score distribution. + + For every decision timestamp the report records how many symbols passed, the + rank_score min/mean/max, the top-K symbols (highest rank_score first), and + how many of the passing rows are fillable (have a real T+1 fill). This is + the input a position-sizer needs to know whether a timestamp even has enough + candidates to fill K positions. + """ + + columns = [ + "timestamp", + "passing_symbols", + "fillable_symbols", + "rank_score_min", + "rank_score_mean", + "rank_score_max", + f"top_{top_k}_symbols", + f"top_{top_k}_rank_scores", + ] + if candidates.empty: + return pd.DataFrame(columns=columns) + + records: List[Dict[str, Any]] = [] + for timestamp, group in candidates.groupby("timestamp", sort=True): + ordered = group.sort_values(["rank_score", "symbol"], ascending=[False, True]) + head = ordered.head(int(top_k)) + fillable_count = ( + int(ordered["fillable"].sum()) if "fillable" in ordered.columns else int(len(ordered)) + ) + records.append( + { + "timestamp": str(timestamp), + "passing_symbols": int(len(ordered)), + "fillable_symbols": fillable_count, + "rank_score_min": float(ordered["rank_score"].min()), + "rank_score_mean": float(ordered["rank_score"].mean()), + "rank_score_max": float(ordered["rank_score"].max()), + f"top_{top_k}_symbols": list(head["symbol"]), + f"top_{top_k}_rank_scores": [float(v) for v in head["rank_score"]], + } + ) + return pd.DataFrame(records, columns=columns).reset_index(drop=True) + + +def write_topk_report(path: Path, report: pd.DataFrame) -> None: + """Write the top-K report as JSON (``.json``) or CSV (any other suffix).""" + + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + if path.suffix.lower() == ".json": + path.write_text( + json.dumps(report.to_dict("records"), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8-sig", + ) + else: + report.to_csv(path, index=False, encoding="utf-8-sig") + + +def _load_panel(input_csv: Optional[str]) -> pd.DataFrame: + if not input_csv: + raise ValueError("--input-csv is required when --db is not given") + return read_candidates_csv(input_csv) + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Page 9 — generate real portfolio candidates from a Page 7.5 panel.", + ) + source = parser.add_argument_group("panel source (CSV or DB window)") + source.add_argument("--input-csv", default=None, help="Page 7.5 panel CSV (long format).") + source.add_argument("--db", default=None, help="STOM tick DB path (per-day-chunk window only).") + source.add_argument("--tables", default=None, help="Comma-separated symbol tables (with --db).") + source.add_argument("--session", default=None, help="Session date YYYYMMDD (with --db).") + source.add_argument("--time-start", default="090000", help="Window start HHMMSS (with --db).") + source.add_argument("--time-end", default="093000", help="Window end HHMMSS (with --db).") + source.add_argument( + "--freq", + default="1s", + choices=["1s", "1min", "session"], + help="Bar frequency for the DB feed path (default 1s; 1min resamples the RL " + "source; session = ONE bar per (symbol, session) — the Story B1 caveated " + "daily proxy, NOT a clean daily bar).", + ) + + parser.add_argument("--rules", required=True, help="Page 8 rule JSON path.") + parser.add_argument("--output", required=True, help="Candidate CSV/JSONL output path.") + parser.add_argument("--topk-report", default=None, help="Optional top-K report path (.json/.csv).") + parser.add_argument("--top-k", type=int, default=3, help="Top-K size for the distribution report.") + parser.add_argument( + "--drop-unfillable", + action="store_true", + help="Drop last-bar (no T+1) candidates instead of flagging them.", + ) + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = _parse_args(argv) + rules = load_rules(Path(args.rules)) + + if args.db and args.tables: + tables = [t.strip() for t in str(args.tables).split(",") if t.strip()] + panel, _report = build_panel_from_db( + db_path=args.db, + tables=tables, + session=args.session, + time_start=args.time_start, + time_end=args.time_end, + freq=args.freq, + ) + else: + panel = _load_panel(args.input_csv) + + candidates = generate_candidates(panel, rules, drop_unfillable=bool(args.drop_unfillable)) + write_candidates(Path(args.output), candidates) + + fillable = int(candidates["fillable"].sum()) if "fillable" in candidates.columns and not candidates.empty else 0 + summary: Dict[str, Any] = { + "candidate_count": int(len(candidates)), + "fillable_count": fillable, + "unfillable_count": int(len(candidates)) - fillable, + "output": str(args.output), + } + + if args.topk_report: + report = build_topk_report(candidates, top_k=int(args.top_k)) + write_topk_report(Path(args.topk_report), report) + summary["topk_report"] = str(args.topk_report) + summary["timestamps_with_candidates"] = int(len(report)) + + print(json.dumps(summary, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/condition_screener.py b/stom_rl/condition_screener.py new file mode 100644 index 000000000..0ceb58c80 --- /dev/null +++ b/stom_rl/condition_screener.py @@ -0,0 +1,403 @@ +"""Safe condition screener for portfolio candidate generation. + +The screener turns STOM feature rows into a deterministic candidate schema: +``timestamp, symbol, condition_id, passed, rank_score, price, fill_price, +feature...``. Condition expressions are parsed with ``ast`` and interpreted +from a whitelist; there is no dynamic code execution path. + +candidate.price time contract (Page 9, P0 leakage gate — defined once here so +Page 10/11/12 reuse it) +----------------------------------------------------------------------------- +* The decision / feature row is the **bar close at decision time T**. Column + ``price`` therefore always carries the close observed *at* ``T`` (point-in-time + correct: no future value participates in the decision). +* **Fill must occur at the NEXT bar (T+1)**, never at the decision bar. Column + ``fill_price`` carries the symbol's next-available-bar price on the panel grid + (the close at the very next timestamp at which that symbol is observed). +* The **last bar per symbol has no T+1** and therefore ``fill_price`` is ``NaN``; + such a candidate is *unfillable* (``fillable == False``). Callers either drop + or flag these rows — they can never be executed because there is no future bar + to fill against. + +This contract is the blocking guarantee against decision/fill collapse +(look-ahead): ``fill_price`` is strictly drawn from a timestamp *after* the +decision timestamp ``T``. +""" + +import argparse +import ast +import json +import operator +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Sequence + +import pandas as pd + +from stom_rl.symbol_norm import read_candidates_csv + + +DEFAULT_ALLOWED_FUNCTIONS: Dict[str, Callable[..., float]] = { + "abs": abs, + "max": max, + "min": min, + "round": round, +} +SELL_ONLY_VARIABLES = {"holding_qty", "position_qty", "position_weight", "보유수량", "보유비중"} +FORBIDDEN_TOKENS = ( + "__", + "import", + "lambda", + "globals", + "locals", + "getattr", + "setattr", + "delattr", + "subprocess", + "os.", + "sys.", +) + + +@dataclass(frozen=True) +class ConditionRule: + condition_id: str + expression: str + rank_expression: str = "rank_score" + side: str = "buy" + + +class SafeExpression: + """Whitelist AST interpreter for numeric and boolean row expressions.""" + + _binary_ops = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.FloorDiv: operator.floordiv, + ast.Mod: operator.mod, + ast.Pow: operator.pow, + } + _unary_ops = {ast.UAdd: operator.pos, ast.USub: operator.neg, ast.Not: operator.not_} + _compare_ops = { + ast.Eq: operator.eq, + ast.NotEq: operator.ne, + ast.Lt: operator.lt, + ast.LtE: operator.le, + ast.Gt: operator.gt, + ast.GtE: operator.ge, + } + + def __init__( + self, + expression: str, + *, + allowed_names: Iterable[str], + allowed_functions: Optional[Mapping[str, Callable[..., float]]] = None, + ) -> None: + self.expression = str(expression) + lowered = self.expression.lower() + forbidden = [token for token in FORBIDDEN_TOKENS if token in lowered] + if forbidden: + raise ValueError(f"Forbidden token(s) in condition expression: {forbidden}") + self.allowed_names = set(map(str, allowed_names)) + self.allowed_functions = dict(allowed_functions or DEFAULT_ALLOWED_FUNCTIONS) + self.tree = ast.parse(self.expression, mode="eval") + self.referenced_names = self._collect_names(self.tree) + unknown = sorted( + name + for name in self.referenced_names + if name not in self.allowed_names and name not in self.allowed_functions + ) + if unknown: + raise ValueError(f"Unknown variable(s) in condition expression: {unknown}") + self._validate(self.tree) + + def _collect_names(self, node: ast.AST) -> set[str]: + return {child.id for child in ast.walk(node) if isinstance(child, ast.Name)} + + def _validate(self, node: ast.AST) -> None: + allowed_nodes = ( + ast.Expression, + ast.BoolOp, + ast.BinOp, + ast.UnaryOp, + ast.Compare, + ast.Call, + ast.Name, + ast.Load, + ast.Constant, + ast.And, + ast.Or, + ast.Add, + ast.Sub, + ast.Mult, + ast.Div, + ast.FloorDiv, + ast.Mod, + ast.Pow, + ast.UAdd, + ast.USub, + ast.Not, + ast.Eq, + ast.NotEq, + ast.Lt, + ast.LtE, + ast.Gt, + ast.GtE, + ) + for child in ast.walk(node): + if not isinstance(child, allowed_nodes): + raise ValueError(f"Disallowed expression node: {type(child).__name__}") + if isinstance(child, ast.Call): + if not isinstance(child.func, ast.Name) or child.func.id not in self.allowed_functions: + raise ValueError("Only whitelisted function calls are allowed") + if child.keywords: + raise ValueError("Keyword arguments are not allowed in condition expressions") + + def calculate(self, row: Mapping[str, Any]) -> Any: + return self._calculate_node(self.tree.body, row) + + def _calculate_node(self, node: ast.AST, row: Mapping[str, Any]) -> Any: + if isinstance(node, ast.Constant): + if isinstance(node.value, (int, float, bool)): + return node.value + raise ValueError("Only numeric and boolean constants are allowed") + if isinstance(node, ast.Name): + if node.id not in self.allowed_names: + raise ValueError(f"Unknown variable: {node.id}") + return row.get(node.id, 0.0) + if isinstance(node, ast.UnaryOp): + return self._unary_ops[type(node.op)](self._calculate_node(node.operand, row)) + if isinstance(node, ast.BinOp): + left = self._calculate_node(node.left, row) + right = self._calculate_node(node.right, row) + return self._binary_ops[type(node.op)](left, right) + if isinstance(node, ast.BoolOp): + values = [bool(self._calculate_node(value, row)) for value in node.values] + return all(values) if isinstance(node.op, ast.And) else any(values) + if isinstance(node, ast.Compare): + left = self._calculate_node(node.left, row) + for op, comparator in zip(node.ops, node.comparators): + right = self._calculate_node(comparator, row) + if not self._compare_ops[type(op)](left, right): + return False + left = right + return True + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + func = self.allowed_functions[node.func.id] + args = [self._calculate_node(arg, row) for arg in node.args] + return func(*args) + raise ValueError(f"Unsupported expression node: {type(node).__name__}") + + +def _timestamp_column(frame: pd.DataFrame) -> str: + for column in ("timestamp", "date", "timestamps", "datetime"): + if column in frame.columns: + return column + raise ValueError("candidate source requires a timestamp/date column") + + +def _symbol_column(frame: pd.DataFrame) -> str: + if "symbol" in frame.columns: + return "symbol" + if "instrument" in frame.columns: + return "instrument" + raise ValueError("candidate source requires a symbol or instrument column") + + +def _price_column(frame: pd.DataFrame) -> str: + for column in ("price", "close", "last", "현재가"): + if column in frame.columns: + return column + raise ValueError("candidate source requires price/close column") + + +_EMPTY_CANDIDATE_COLUMNS = [ + "timestamp", + "symbol", + "condition_id", + "passed", + "rank_score", + "price", + "fill_price", + "fillable", +] + + +def screen_frame( + frame: pd.DataFrame, + rules: Sequence[ConditionRule], + *, + feature_columns: Optional[Sequence[str]] = None, + strategy_side: str = "buy", + drop_unfillable: bool = False, +) -> pd.DataFrame: + """Apply safe rules to a feature frame and return portfolio candidates. + + Output schema: ``timestamp, symbol, condition_id, passed, rank_score, price, + fill_price, fillable, feature_*``. + + * ``price`` is the decision-bar close at ``T`` (point-in-time correct). + * ``fill_price`` is that symbol's *next* observed bar price (T+1) on the input + grid — the T+1 fill contract documented in the module docstring. The last + bar per symbol has no T+1 → ``fill_price`` is ``NaN`` and ``fillable`` is + ``False``. + * ``rank_score`` default (when no ``rank_score`` column and no explicit + ``rank_expression``) is computed **per symbol** (groupby) to avoid + cross-symbol contamination at symbol boundaries; rules that supply an + explicit ``rank_expression`` are evaluated as-is per row. + + With ``drop_unfillable=True`` the last-bar (unfillable) candidates are + removed from the result instead of flagged. + """ + + if frame.empty: + return pd.DataFrame(columns=_EMPTY_CANDIDATE_COLUMNS) + timestamp_col = _timestamp_column(frame) + symbol_col = _symbol_column(frame) + price_col = _price_column(frame) + numeric = frame.copy() + numeric[timestamp_col] = pd.to_datetime(numeric[timestamp_col], errors="coerce") + numeric = ( + numeric.dropna(subset=[timestamp_col]) + .sort_values([symbol_col, timestamp_col], kind="mergesort") + .reset_index(drop=True) + ) + + # T+1 fill contract: per symbol, fill_price = the NEXT bar's price. Sorting + # by (symbol, timestamp) then shifting -1 within each symbol group gives the + # strictly-later bar; the last bar per symbol shifts to NaN (unfillable). + price_numeric = pd.to_numeric(numeric[price_col], errors="coerce") + numeric["fill_price"] = price_numeric.groupby(numeric[symbol_col]).shift(-1) + numeric["fillable"] = numeric["fill_price"].notna() + + all_names = set(numeric.columns) + has_rank_column = "rank_score" in all_names + if not has_rank_column: + # Per-symbol default rank_score (P1 fix): pct_change within each symbol so + # symbol A's first row is never a pct_change off symbol B's last row. + numeric["rank_score"] = ( + price_numeric.groupby(numeric[symbol_col]) + .pct_change(fill_method=None) + .fillna(0.0) + ) + all_names.add("rank_score") + + # The screening loop is driven in (timestamp, symbol) order for stable output. + numeric = numeric.sort_values([timestamp_col, symbol_col], kind="mergesort").reset_index(drop=True) + + reserved = {timestamp_col, symbol_col, "fill_price", "fillable"} + features = list(feature_columns or [col for col in numeric.columns if col not in reserved]) + rows: List[Dict[str, Any]] = [] + for rule in rules: + condition = SafeExpression(rule.expression, allowed_names=all_names) + ranker = SafeExpression(rule.rank_expression, allowed_names=all_names) + if strategy_side == "buy" and SELL_ONLY_VARIABLES & condition.referenced_names: + raise ValueError("Buy strategy condition cannot reference sell-only holding variables") + for _, source_row in numeric.iterrows(): + context = source_row.to_dict() + passed = bool(condition.calculate(context)) + if not passed: + continue + rank_score = float(ranker.calculate(context)) + fill_value = context.get("fill_price") + fillable = bool(pd.notna(fill_value)) + if drop_unfillable and not fillable: + continue + row = { + "timestamp": pd.Timestamp(context[timestamp_col]).isoformat(), + "symbol": str(context[symbol_col]), + "condition_id": rule.condition_id, + "passed": True, + "rank_score": rank_score, + "price": float(context[price_col]), + "fill_price": float(fill_value) if fillable else float("nan"), + "fillable": fillable, + } + for column in features: + if column in context and column not in reserved: + value = context[column] + try: + row[f"feature_{column}"] = float(value) + except (TypeError, ValueError): + row[f"feature_{column}"] = value + rows.append(row) + + candidates = pd.DataFrame(rows) + if candidates.empty: + return pd.DataFrame(columns=_EMPTY_CANDIDATE_COLUMNS) + return candidates.sort_values(["timestamp", "rank_score", "symbol"], ascending=[True, False, True]).reset_index(drop=True) + + +def load_rules(path: Path) -> List[ConditionRule]: + payload = json.loads(Path(path).read_text(encoding="utf-8-sig")) + if isinstance(payload, list): + raw_rules = payload + elif isinstance(payload, Mapping): + raw_rules = payload.get("rules", []) + else: + raise ValueError("Rule file must be a list or an object with a 'rules' list") + return [ConditionRule(**raw) for raw in raw_rules] + + +def write_candidates(path: Path, candidates: pd.DataFrame) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.suffix.lower() == ".jsonl": + path.write_text( + "\n".join(json.dumps(row, ensure_ascii=False) for row in candidates.to_dict("records")) + "\n", + encoding="utf-8-sig", + ) + else: + candidates.to_csv(path, index=False, encoding="utf-8-sig") + + +def screen_sqlite_table( + db_path: Path, + table: str, + rules: Sequence[ConditionRule], + *, + limit: int = 0, +) -> pd.DataFrame: + conn = sqlite3.connect(f"file:{Path(db_path).resolve().as_posix()}?mode=ro", uri=True) + conn.execute("PRAGMA query_only=ON") + try: + sql = f'SELECT * FROM "{str(table).replace(chr(34), chr(34) + chr(34))}"' + if limit and limit > 0: + sql += f" LIMIT {int(limit)}" + frame = pd.read_sql_query(sql, conn) + finally: + conn.close() + return screen_frame(frame, rules) + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Generate STOM portfolio candidates from safe condition rules.") + parser.add_argument("--input-csv", default=None) + parser.add_argument("--db", default=None) + parser.add_argument("--table", default=None) + parser.add_argument("--rules", required=True) + parser.add_argument("--output", required=True) + parser.add_argument("--limit", type=int, default=0) + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = _parse_args(argv) + rules = load_rules(Path(args.rules)) + if args.input_csv: + frame = read_candidates_csv(args.input_csv) + candidates = screen_frame(frame, rules) + elif args.db and args.table: + candidates = screen_sqlite_table(Path(args.db), args.table, rules, limit=args.limit) + else: + raise ValueError("Pass either --input-csv or both --db and --table") + write_candidates(Path(args.output), candidates) + print(json.dumps({"candidate_count": int(len(candidates)), "output": args.output}, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/contextual_bandit.py b/stom_rl/contextual_bandit.py new file mode 100644 index 000000000..1409d514f --- /dev/null +++ b/stom_rl/contextual_bandit.py @@ -0,0 +1,616 @@ +"""First independent STOM RL model: a fixed-horizon contextual bandit. + +This is page 6 of the independent RL lab. It intentionally avoids Kronos and +new heavy RL dependencies. The model learns a simple buy-vs-hold decision from +historical STOM episodes: + +* context = current/past 1-second OHLCV-derived features only; +* action = buy when predicted 300-second net reward is above threshold, else + hold; +* reward target = fixed-horizon long return after round-trip cost; +* evaluation = replay on unseen split with trade/action/equity artifacts. + +The implementation is deliberately small but complete enough to create, save, +load, and use a trained model artifact. Later pages can replace this policy +with DQN/PPO while preserving the same artifact and cost-gate comparison shape. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import asdict, dataclass +from math import exp, log +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .episode_manifest import DEFAULT_OUTPUT_DIR, load_episode_manifest + + +DEFAULT_BANDIT_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_1s_2025_contextual_bandit" +FEATURE_COLUMNS = [ + "ret_1s", + "ret_5s", + "ret_30s", + "ret_60s", + "ret_120s", + "ret_300s", + "range_30s", + "range_120s", + "close_vs_ma_30s", + "close_vs_ma_120s", + "volume_ratio_30s", + "amount_ratio_30s", + "seconds_from_episode_start", +] + + +@dataclass(frozen=True) +class ContextualBanditConfig: + """Configuration for training and evaluating the first STOM RL model.""" + + manifest_path: str = str(DEFAULT_OUTPUT_DIR / "episode_manifest.json") + output_dir: str = str(DEFAULT_BANDIT_OUTPUT_DIR) + train_split: str = "train" + eval_split: str = "test" + max_train_episodes: int = 25 + max_eval_episodes: int = 25 + train_sample_stride: int = 10 + eval_sample_stride: int = 1 + seed: int = 100 + lookback_window: int = 300 + reward_horizon_seconds: int = 300 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + ridge_alpha: float = 1.0 + decision_threshold_bps: float = 0.0 + max_trades_per_episode: float = 50.0 + max_drawdown_pct: float = 20.0 + min_avg_episode_net_pct: float = 0.0 + min_trade_count: int = 1 + write_artifacts: bool = True + + +@dataclass +class ContextualBanditModel: + """Serializable ridge-regression contextual bandit model.""" + + feature_columns: List[str] + feature_mean: List[float] + feature_std: List[float] + weights: List[float] + intercept: float + train_summary: Dict[str, Any] + + def predict_score(self, raw_features: Sequence[float]) -> float: + features = np.asarray(raw_features, dtype=np.float64) + mean = np.asarray(self.feature_mean, dtype=np.float64) + std = np.asarray(self.feature_std, dtype=np.float64) + weights = np.asarray(self.weights, dtype=np.float64) + scaled = (features - mean) / np.maximum(std, 1e-9) + return float(self.intercept + scaled @ weights) + + def to_dict(self) -> Dict[str, Any]: + return { + "model_type": "stom_fixed_horizon_contextual_bandit_ridge", + "feature_columns": self.feature_columns, + "feature_mean": self.feature_mean, + "feature_std": self.feature_std, + "weights": self.weights, + "intercept": self.intercept, + "train_summary": self.train_summary, + } + + @classmethod + def from_dict(cls, payload: Mapping[str, Any]) -> "ContextualBanditModel": + return cls( + feature_columns=list(payload["feature_columns"]), + feature_mean=[float(v) for v in payload["feature_mean"]], + feature_std=[float(v) for v in payload["feature_std"]], + weights=[float(v) for v in payload["weights"]], + intercept=float(payload["intercept"]), + train_summary=dict(payload.get("train_summary", {})), + ) + + +def save_model(path: Path, model: ContextualBanditModel, config: ContextualBanditConfig) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + payload = {"config": asdict(config), "model": model.to_dict()} + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def load_model(path: Path) -> ContextualBanditModel: + payload = json.loads(path.read_text(encoding="utf-8-sig")) + return ContextualBanditModel.from_dict(payload["model"]) + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _load_episode_frame(path: Path) -> pd.DataFrame: + frame = pd.read_csv(path) + required = {"date", "open", "high", "low", "close", "volume", "amount"} + missing = sorted(required - set(frame.columns)) + if missing: + raise ValueError(f"Episode CSV missing required columns: {missing}") + frame = frame.copy() + frame["date"] = pd.to_datetime(frame["date"], errors="coerce") + frame = frame.dropna(subset=["date"]).sort_values("date").reset_index(drop=True) + for column in ["open", "high", "low", "close", "volume", "amount"]: + frame[column] = pd.to_numeric(frame[column], errors="coerce") + frame[["open", "high", "low", "close", "volume", "amount"]] = frame[ + ["open", "high", "low", "close", "volume", "amount"] + ].ffill().bfill() + frame = frame.dropna(subset=["open", "high", "low", "close", "volume", "amount"]) + frame = frame[frame["close"] > 0].reset_index(drop=True) + if frame.empty: + raise ValueError(f"Episode CSV has no valid rows: {path}") + return frame + + +def _episodes_for_split(manifest_path: str, split: str, max_episodes: int) -> List[Dict[str, Any]]: + manifest = load_episode_manifest(manifest_path) + episodes = [dict(episode) for episode in manifest.get("episodes", []) if episode.get("split") == split] + if max_episodes and max_episodes > 0: + return episodes[: int(max_episodes)] + return episodes + + +def _safe_ratio(numerator: float, denominator: float) -> float: + return float(numerator / denominator) if denominator else 0.0 + + +def _return_at(frame: pd.DataFrame, idx: int, window: int) -> float: + past_idx = max(0, int(idx) - int(window)) + past = float(frame["close"].iloc[past_idx]) + current = float(frame["close"].iloc[int(idx)]) + return _safe_ratio(current - past, past) + + +def _rolling_range(frame: pd.DataFrame, idx: int, window: int) -> float: + start = max(0, int(idx) - int(window) + 1) + chunk = frame.iloc[start : int(idx) + 1] + low = float(chunk["low"].min()) + high = float(chunk["high"].max()) + close = float(frame["close"].iloc[int(idx)]) + return _safe_ratio(high - low, close) + + +def _current_vs_ma(frame: pd.DataFrame, idx: int, window: int) -> float: + start = max(0, int(idx) - int(window) + 1) + chunk = frame.iloc[start : int(idx) + 1] + current = float(frame["close"].iloc[int(idx)]) + ma = float(chunk["close"].mean()) + return _safe_ratio(current - ma, ma) + + +def _current_vs_avg(frame: pd.DataFrame, idx: int, column: str, window: int) -> float: + start = max(0, int(idx) - int(window)) + hist = frame.iloc[start:int(idx)] + current = float(frame[column].iloc[int(idx)]) + avg = float(hist[column].mean()) if not hist.empty else current + return _safe_ratio(current, avg) if avg > 0 else 0.0 + + +def _raw_features(frame: pd.DataFrame, idx: int, lookback_window: int) -> List[float]: + episode_start = max(0, int(idx) - int(lookback_window)) + seconds_from_start = float(int(idx) - episode_start) + return [ + _return_at(frame, idx, 1), + _return_at(frame, idx, 5), + _return_at(frame, idx, 30), + _return_at(frame, idx, 60), + _return_at(frame, idx, 120), + _return_at(frame, idx, 300), + _rolling_range(frame, idx, 30), + _rolling_range(frame, idx, 120), + _current_vs_ma(frame, idx, 30), + _current_vs_ma(frame, idx, 120), + _current_vs_avg(frame, idx, "volume", 30), + _current_vs_avg(frame, idx, "amount", 30), + seconds_from_start / max(float(lookback_window), 1.0), + ] + + +def _round_trip_net_return(frame: pd.DataFrame, idx: int, horizon: int, cost_pct: float) -> float: + entry = float(frame["close"].iloc[int(idx)]) + exit_price = float(frame["close"].iloc[int(idx) + int(horizon)]) + return float((exit_price / entry) * (1.0 - cost_pct) * (1.0 - cost_pct) - 1.0) + + +def _iter_sample_indices(frame: pd.DataFrame, config: ContextualBanditConfig, stride: int) -> range: + start = int(config.lookback_window) + stop = int(len(frame) - config.reward_horizon_seconds) + if stop <= start: + return range(0) + return range(start, stop, max(1, int(stride))) + + +def _collect_training_samples(config: ContextualBanditConfig) -> Tuple[np.ndarray, np.ndarray, Dict[str, Any]]: + features: List[List[float]] = [] + targets: List[float] = [] + episodes = _episodes_for_split(config.manifest_path, config.train_split, config.max_train_episodes) + cost_pct = (float(config.cost_bps) + float(config.slippage_bps)) / 10_000.0 + skipped = 0 + for episode in episodes: + frame = _load_episode_frame(Path(episode["source_csv"])) + indices = _iter_sample_indices(frame, config, config.train_sample_stride) + if not indices: + skipped += 1 + continue + for idx in indices: + features.append(_raw_features(frame, idx, config.lookback_window)) + targets.append(_round_trip_net_return(frame, idx, config.reward_horizon_seconds, cost_pct)) + if not features: + raise ValueError("No training samples collected; check split, episode length, and lookback/horizon.") + x = np.asarray(features, dtype=np.float64) + y = np.asarray(targets, dtype=np.float64) + summary = { + "train_split": config.train_split, + "train_episode_count": len(episodes), + "skipped_episode_count": skipped, + "train_sample_count": int(len(y)), + "target_mean_pct": float(np.mean(y) * 100.0), + "target_median_pct": float(np.median(y) * 100.0), + "target_positive_rate": float(np.mean(y > 0.0)), + } + return x, y, summary + + +def train_contextual_bandit(config: ContextualBanditConfig) -> ContextualBanditModel: + """Fit a ridge model that predicts fixed-horizon net buy reward.""" + + x, y, summary = _collect_training_samples(config) + mean = x.mean(axis=0) + std = np.maximum(x.std(axis=0), 1e-9) + x_scaled = (x - mean) / std + design = np.column_stack([np.ones(len(x_scaled)), x_scaled]) + penalty = float(config.ridge_alpha) * np.eye(design.shape[1], dtype=np.float64) + penalty[0, 0] = 0.0 + coef = np.linalg.solve(design.T @ design + penalty, design.T @ y) + predictions = design @ coef + residual = predictions - y + summary.update( + { + "ridge_alpha": float(config.ridge_alpha), + "train_rmse_pct": float(np.sqrt(np.mean(residual * residual)) * 100.0), + "predicted_positive_rate": float(np.mean(predictions > (config.decision_threshold_bps / 10_000.0))), + } + ) + return ContextualBanditModel( + feature_columns=list(FEATURE_COLUMNS), + feature_mean=[float(v) for v in mean], + feature_std=[float(v) for v in std], + weights=[float(v) for v in coef[1:]], + intercept=float(coef[0]), + train_summary=summary, + ) + + +def _max_drawdown_pct(equity_values: Sequence[float]) -> float: + peak = 1.0 + max_dd = 0.0 + for value in equity_values: + value = float(value) + peak = max(peak, value) + if peak > 0: + max_dd = min(max_dd, (value / peak) - 1.0) + return max_dd * 100.0 + + +def _safe_compounded_return_pct(final_equities: Sequence[float]) -> float: + if not final_equities: + return 0.0 + total_log = sum(log(max(float(value), 1e-12)) for value in final_equities) + if total_log > 50: + return float("inf") + if total_log < -50: + return -100.0 + return (exp(total_log) - 1.0) * 100.0 + + +def evaluate_contextual_bandit( + model: ContextualBanditModel, + config: ContextualBanditConfig, +) -> Dict[str, Any]: + """Replay the learned fixed-horizon policy on the eval split.""" + + episodes = _episodes_for_split(config.manifest_path, config.eval_split, config.max_eval_episodes) + threshold = float(config.decision_threshold_bps) / 10_000.0 + cost_pct = (float(config.cost_bps) + float(config.slippage_bps)) / 10_000.0 + action_rows: List[Dict[str, Any]] = [] + trade_rows: List[Dict[str, Any]] = [] + equity_rows: List[Dict[str, Any]] = [] + episode_rows: List[Dict[str, Any]] = [] + aggregate_equity_curve = [1.0] + + for episode in episodes: + frame = _load_episode_frame(Path(episode["source_csv"])) + idx_values = list(_iter_sample_indices(frame, config, config.eval_sample_stride)) + if not idx_values: + continue + episode_id = str(episode["episode_id"]) + symbol = str(episode.get("symbol")) + session = str(episode.get("session")) + episode_equity = 1.0 + episode_base_equity = aggregate_equity_curve[-1] + trade_count = 0 + action_count = 0 + i = 0 + while i < len(idx_values): + idx = idx_values[i] + raw = _raw_features(frame, idx, config.lookback_window) + score = model.predict_score(raw) + timestamp = pd.Timestamp(frame["date"].iloc[idx]).isoformat() + price = float(frame["close"].iloc[idx]) + action = "buy" if score > threshold else "hold" + action_rows.append( + { + "episode_id": episode_id, + "symbol": symbol, + "session": session, + "step_idx": idx, + "timestamp": timestamp, + "price": price, + "predicted_net_return_pct": score * 100.0, + "decision_threshold_pct": threshold * 100.0, + "action": action, + "equity_before": episode_equity, + } + ) + action_count += 1 + if action == "buy": + exit_idx = int(idx) + int(config.reward_horizon_seconds) + exit_price = float(frame["close"].iloc[exit_idx]) + exit_timestamp = pd.Timestamp(frame["date"].iloc[exit_idx]).isoformat() + gross_return = (exit_price - price) / price + net_return = (exit_price / price) * (1.0 - cost_pct) * (1.0 - cost_pct) - 1.0 + episode_equity *= 1.0 + net_return + trade_count += 1 + trade_rows.append( + { + "episode_id": episode_id, + "symbol": symbol, + "session": session, + "entry_timestamp": timestamp, + "exit_timestamp": exit_timestamp, + "entry_idx": idx, + "exit_idx": exit_idx, + "entry_price": price, + "exit_price": exit_price, + "predicted_net_return_pct": score * 100.0, + "gross_return_pct": gross_return * 100.0, + "net_return_pct": net_return * 100.0, + "cost_pct": cost_pct * 100.0, + } + ) + equity_rows.append( + { + "episode_id": episode_id, + "symbol": symbol, + "session": session, + "timestamp": exit_timestamp, + "equity": episode_equity, + "trade_count": trade_count, + } + ) + aggregate_equity_curve.append(episode_base_equity * max(episode_equity, 1e-12)) + next_allowed = exit_idx + 1 + while i < len(idx_values) and idx_values[i] < next_allowed: + i += 1 + continue + equity_rows.append( + { + "episode_id": episode_id, + "symbol": symbol, + "session": session, + "timestamp": timestamp, + "equity": episode_equity, + "trade_count": trade_count, + } + ) + i += 1 + if not trade_count: + aggregate_equity_curve.append(episode_base_equity) + episode_rows.append( + { + "episode_id": episode_id, + "symbol": symbol, + "session": session, + "final_equity": episode_equity, + "episode_return_pct": (episode_equity - 1.0) * 100.0, + "trade_count": trade_count, + "action_count": action_count, + } + ) + + final_equities = [float(row["final_equity"]) for row in episode_rows] + trade_returns = [float(row["net_return_pct"]) for row in trade_rows] + hit_count = sum(1 for value in trade_returns if value > 0) + episode_count = len(episode_rows) + trade_count = len(trade_rows) + trades_per_episode = trade_count / episode_count if episode_count else 0.0 + avg_net = float(np.mean([row["episode_return_pct"] for row in episode_rows])) if episode_rows else 0.0 + max_dd = _max_drawdown_pct(aggregate_equity_curve) + passes_cost_gate = bool( + avg_net > float(config.min_avg_episode_net_pct) + and max_dd >= -abs(float(config.max_drawdown_pct)) + and trades_per_episode <= float(config.max_trades_per_episode) + and trade_count >= int(config.min_trade_count) + ) + summary = { + "policy": "contextual_bandit", + "eval_split": config.eval_split, + "episode_count": episode_count, + "action_count": len(action_rows), + "trade_count": trade_count, + "trades_per_episode": trades_per_episode, + "avg_episode_net_return_pct": avg_net, + "median_episode_net_return_pct": float(np.median([row["episode_return_pct"] for row in episode_rows])) + if episode_rows + else 0.0, + "compounded_return_pct": _safe_compounded_return_pct(final_equities), + "avg_trade_net_return_pct": float(np.mean(trade_returns)) if trade_returns else 0.0, + "hit_rate": hit_count / len(trade_returns) if trade_returns else 0.0, + "max_drawdown_pct": max_dd, + "passes_cost_gate": passes_cost_gate, + "threshold_bps": float(config.decision_threshold_bps), + "cost_bps": float(config.cost_bps), + "slippage_bps": float(config.slippage_bps), + } + return { + "summary": summary, + "episodes": episode_rows, + "trades": trade_rows, + "actions": action_rows, + "equity": equity_rows, + } + + +def run_contextual_bandit(config: ContextualBanditConfig) -> Dict[str, Any]: + """Train, save, evaluate, and optionally write all model artifacts.""" + + output_dir = Path(config.output_dir) + model = train_contextual_bandit(config) + evaluation = evaluate_contextual_bandit(model, config) + payload = { + "mode": "stom_rl_contextual_bandit", + "config": asdict(config), + "model": model.to_dict(), + "eval_summary": evaluation["summary"], + "artifacts": { + "output_dir": str(output_dir), + "config_json": str(output_dir / "config.json"), + "model_json": str(output_dir / "model.json"), + "train_metrics_jsonl": str(output_dir / "train_metrics.jsonl"), + "eval_summary_json": str(output_dir / "eval_summary.json"), + "actions_csv": str(output_dir / "actions.csv"), + "trades_csv": str(output_dir / "trades.csv"), + "equity_curve_csv": str(output_dir / "equity_curve.csv"), + "episodes_csv": str(output_dir / "episodes.csv"), + }, + } + if config.write_artifacts: + output_dir.mkdir(parents=True, exist_ok=True) + _write_json(output_dir / "config.json", {"config": asdict(config)}) + save_model(output_dir / "model.json", model, config) + with (output_dir / "train_metrics.jsonl").open("w", encoding="utf-8-sig") as f: + f.write(json.dumps(model.train_summary, ensure_ascii=False) + "\n") + _write_json(output_dir / "eval_summary.json", payload) + _write_csv( + output_dir / "actions.csv", + evaluation["actions"], + [ + "episode_id", + "symbol", + "session", + "step_idx", + "timestamp", + "price", + "predicted_net_return_pct", + "decision_threshold_pct", + "action", + "equity_before", + ], + ) + _write_csv( + output_dir / "trades.csv", + evaluation["trades"], + [ + "episode_id", + "symbol", + "session", + "entry_timestamp", + "exit_timestamp", + "entry_idx", + "exit_idx", + "entry_price", + "exit_price", + "predicted_net_return_pct", + "gross_return_pct", + "net_return_pct", + "cost_pct", + ], + ) + _write_csv( + output_dir / "equity_curve.csv", + evaluation["equity"], + ["episode_id", "symbol", "session", "timestamp", "equity", "trade_count"], + ) + _write_csv( + output_dir / "episodes.csv", + evaluation["episodes"], + ["episode_id", "symbol", "session", "final_equity", "episode_return_pct", "trade_count", "action_count"], + ) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> ContextualBanditConfig: + parser = argparse.ArgumentParser(description="Train/evaluate STOM contextual-bandit RL prototype.") + parser.add_argument("--manifest", default=str(DEFAULT_OUTPUT_DIR / "episode_manifest.json")) + parser.add_argument("--output-dir", default=str(DEFAULT_BANDIT_OUTPUT_DIR)) + parser.add_argument("--train-split", default="train") + parser.add_argument("--eval-split", default="test") + parser.add_argument("--max-train-episodes", type=int, default=25) + parser.add_argument("--max-eval-episodes", type=int, default=25) + parser.add_argument("--train-sample-stride", type=int, default=10) + parser.add_argument("--eval-sample-stride", type=int, default=1) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--reward-horizon-seconds", type=int, default=300) + parser.add_argument("--cost-bps", type=float, default=25.0) + parser.add_argument("--slippage-bps", type=float, default=0.0) + parser.add_argument("--ridge-alpha", type=float, default=1.0) + parser.add_argument("--decision-threshold-bps", type=float, default=0.0) + parser.add_argument("--max-trades-per-episode", type=float, default=50.0) + parser.add_argument("--max-drawdown-pct", type=float, default=20.0) + parser.add_argument("--min-avg-episode-net-pct", type=float, default=0.0) + parser.add_argument("--min-trade-count", type=int, default=1) + parser.add_argument("--no-write", action="store_true") + args = parser.parse_args(argv) + return ContextualBanditConfig( + manifest_path=args.manifest, + output_dir=args.output_dir, + train_split=args.train_split, + eval_split=args.eval_split, + max_train_episodes=args.max_train_episodes, + max_eval_episodes=args.max_eval_episodes, + train_sample_stride=args.train_sample_stride, + eval_sample_stride=args.eval_sample_stride, + seed=args.seed, + lookback_window=args.lookback_window, + reward_horizon_seconds=args.reward_horizon_seconds, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + ridge_alpha=args.ridge_alpha, + decision_threshold_bps=args.decision_threshold_bps, + max_trades_per_episode=args.max_trades_per_episode, + max_drawdown_pct=args.max_drawdown_pct, + min_avg_episode_net_pct=args.min_avg_episode_net_pct, + min_trade_count=args.min_trade_count, + write_artifacts=not args.no_write, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_contextual_bandit(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/cost_gate.py b/stom_rl/cost_gate.py new file mode 100644 index 000000000..11fb8c05f --- /dev/null +++ b/stom_rl/cost_gate.py @@ -0,0 +1,431 @@ +"""Reward and cost-gate reporting for the STOM independent RL lab. + +Page 5 evaluates whether baseline strategies survive realistic transaction +costs before any RL model is trained. The report deliberately stays separate +from model training: it reuses the page-4 baseline runner, sweeps cost/slippage +scenarios, and records pass/fail evidence for net return, drawdown, turnover, +and rolling validation stability. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +from .baselines import DEFAULT_POLICIES, BaselineRunConfig, run_baselines +from .episode_manifest import DEFAULT_OUTPUT_DIR, load_episode_manifest + + +DEFAULT_COST_GATE_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_1s_2025_cost_gate" + + +@dataclass(frozen=True) +class CostGateConfig: + """Configuration for cost/slippage/rolling validation checks.""" + + manifest_path: str = str(DEFAULT_OUTPUT_DIR / "episode_manifest.json") + output_dir: str = str(DEFAULT_COST_GATE_OUTPUT_DIR) + split: str = "test" + policies: Tuple[str, ...] = DEFAULT_POLICIES + cost_bps_values: Tuple[float, ...] = (5.0, 10.0, 15.0, 25.0) + slippage_bps_values: Tuple[float, ...] = (0.0,) + target_cost_bps: float = 25.0 + target_slippage_bps: float = 0.0 + max_episodes: int = 25 + seed: int = 100 + lookback_window: int = 300 + reward_horizon_seconds: int = 300 + momentum_window: int = 30 + signal_threshold_bps: float = 0.0 + volume_window: int = 30 + amount_multiplier: float = 1.5 + max_steps_per_episode: int = 0 + sessions: Tuple[str, ...] = field(default_factory=tuple) + min_avg_episode_net_pct: float = 0.0 + max_drawdown_pct: float = 20.0 + max_trades_per_episode: float = 50.0 + min_trade_count: int = 1 + rolling_sessions_per_fold: int = 6 + rolling_max_folds: int = 3 + rolling_max_episodes_per_fold: int = 25 + min_positive_fold_rate: float = 0.5 + write_policy_artifacts: bool = False + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _float_label(value: float) -> str: + value = float(value) + if value.is_integer(): + return str(int(value)) + return str(value).replace(".", "p") + + +def _scenario_id(cost_bps: float, slippage_bps: float) -> str: + return f"cost_{_float_label(cost_bps)}bp_slip_{_float_label(slippage_bps)}bp" + + +def _parse_float_tuple(raw: str) -> Tuple[float, ...]: + return tuple(float(part.strip()) for part in raw.split(",") if part.strip()) + + +def _parse_str_tuple(raw: str) -> Tuple[str, ...]: + return tuple(part.strip() for part in raw.split(",") if part.strip()) + + +def _manifest_sessions(config: CostGateConfig) -> List[str]: + manifest = load_episode_manifest(config.manifest_path) + sessions = [ + str(episode.get("session")) + for episode in manifest.get("episodes", []) + if episode.get("split") == config.split + and (not config.sessions or str(episode.get("session")) in set(config.sessions)) + ] + return sorted(set(sessions)) + + +def _chunks(items: Sequence[str], chunk_size: int, max_chunks: int) -> List[Tuple[str, ...]]: + chunk_size = max(1, int(chunk_size)) + chunks = [tuple(items[idx : idx + chunk_size]) for idx in range(0, len(items), chunk_size)] + if max_chunks and max_chunks > 0: + return chunks[: int(max_chunks)] + return chunks + + +def _baseline_config( + config: CostGateConfig, + *, + output_dir: Path, + cost_bps: float, + slippage_bps: float, + max_episodes: int, + sessions: Tuple[str, ...] = (), + write_artifacts: Optional[bool] = None, +) -> BaselineRunConfig: + return BaselineRunConfig( + manifest_path=config.manifest_path, + output_dir=str(output_dir), + split=config.split, + policies=config.policies, + max_episodes=max_episodes, + seed=config.seed, + lookback_window=config.lookback_window, + reward_horizon_seconds=config.reward_horizon_seconds, + cost_bps=cost_bps, + slippage_bps=slippage_bps, + momentum_window=config.momentum_window, + signal_threshold_bps=config.signal_threshold_bps, + volume_window=config.volume_window, + amount_multiplier=config.amount_multiplier, + max_steps_per_episode=config.max_steps_per_episode, + sessions=sessions or config.sessions, + write_artifacts=config.write_policy_artifacts if write_artifacts is None else write_artifacts, + ) + + +def _scenario_row(summary: Mapping[str, Any], config: CostGateConfig, cost_bps: float, slippage_bps: float) -> Dict[str, Any]: + episode_count = int(summary.get("episode_count", 0) or 0) + trade_count = int(summary.get("trade_count", 0) or 0) + turnover = trade_count / episode_count if episode_count else 0.0 + avg_net = float(summary.get("avg_episode_net_return_pct", 0.0) or 0.0) + mdd = float(summary.get("max_drawdown_pct", 0.0) or 0.0) + passes_net = avg_net > config.min_avg_episode_net_pct + passes_mdd = mdd >= -abs(float(config.max_drawdown_pct)) + passes_turnover = turnover <= float(config.max_trades_per_episode) + passes_trade_count = trade_count >= int(config.min_trade_count) + return { + "scenario_id": _scenario_id(cost_bps, slippage_bps), + "cost_bps": float(cost_bps), + "slippage_bps": float(slippage_bps), + "policy": summary["policy"], + "episode_count": episode_count, + "trade_count": trade_count, + "trades_per_episode": turnover, + "avg_episode_net_return_pct": avg_net, + "median_episode_net_return_pct": float(summary.get("median_episode_net_return_pct", 0.0) or 0.0), + "compounded_return_pct": float(summary.get("compounded_return_pct", 0.0) or 0.0), + "avg_trade_net_return_pct": float(summary.get("avg_trade_net_return_pct", 0.0) or 0.0), + "hit_rate": float(summary.get("hit_rate", 0.0) or 0.0), + "invalid_action_rate": float(summary.get("invalid_action_rate", 0.0) or 0.0), + "max_drawdown_pct": mdd, + "passes_net": passes_net, + "passes_mdd": passes_mdd, + "passes_turnover": passes_turnover, + "passes_trade_count": passes_trade_count, + "passes_scenario_gate": bool(passes_net and passes_mdd and passes_turnover and passes_trade_count), + } + + +def _run_cost_scenarios(config: CostGateConfig, output_dir: Path) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + raw_results: Dict[str, Any] = {} + for cost_bps in config.cost_bps_values: + for slippage_bps in config.slippage_bps_values: + sid = _scenario_id(cost_bps, slippage_bps) + result = run_baselines( + _baseline_config( + config, + output_dir=output_dir / "policy_artifacts" / sid, + cost_bps=cost_bps, + slippage_bps=slippage_bps, + max_episodes=config.max_episodes, + ) + ) + raw_results[sid] = result["summary"] + for summary in result["summary"]["policies"]: + rows.append(_scenario_row(summary, config, cost_bps, slippage_bps)) + return rows, raw_results + + +def _run_rolling_validation(config: CostGateConfig, output_dir: Path) -> Tuple[List[Dict[str, Any]], Dict[str, float]]: + sessions = _manifest_sessions(config) + folds = _chunks(sessions, config.rolling_sessions_per_fold, config.rolling_max_folds) + rows: List[Dict[str, Any]] = [] + positive_counts = {policy: 0 for policy in config.policies} + observed_counts = {policy: 0 for policy in config.policies} + for fold_index, fold_sessions in enumerate(folds, start=1): + result = run_baselines( + _baseline_config( + config, + output_dir=output_dir / "rolling" / f"fold_{fold_index:02d}", + cost_bps=config.target_cost_bps, + slippage_bps=config.target_slippage_bps, + max_episodes=config.rolling_max_episodes_per_fold, + sessions=tuple(fold_sessions), + write_artifacts=False, + ) + ) + for summary in result["summary"]["policies"]: + avg_net = float(summary.get("avg_episode_net_return_pct", 0.0) or 0.0) + policy = str(summary["policy"]) + observed_counts[policy] += 1 + if avg_net > config.min_avg_episode_net_pct: + positive_counts[policy] += 1 + rows.append( + { + "fold": fold_index, + "sessions": ",".join(fold_sessions), + "cost_bps": config.target_cost_bps, + "slippage_bps": config.target_slippage_bps, + "policy": policy, + "episode_count": int(summary.get("episode_count", 0) or 0), + "trade_count": int(summary.get("trade_count", 0) or 0), + "avg_episode_net_return_pct": avg_net, + "max_drawdown_pct": float(summary.get("max_drawdown_pct", 0.0) or 0.0), + "hit_rate": float(summary.get("hit_rate", 0.0) or 0.0), + "positive_fold": avg_net > config.min_avg_episode_net_pct, + } + ) + positive_rates = { + policy: (positive_counts[policy] / observed_counts[policy] if observed_counts[policy] else 0.0) + for policy in config.policies + } + return rows, positive_rates + + +def _gate_summary( + config: CostGateConfig, + scenario_rows: Sequence[Mapping[str, Any]], + rolling_positive_rates: Mapping[str, float], +) -> List[Dict[str, Any]]: + target_sid = _scenario_id(config.target_cost_bps, config.target_slippage_bps) + target_rows = [row for row in scenario_rows if row["scenario_id"] == target_sid] + summaries: List[Dict[str, Any]] = [] + for row in target_rows: + positive_fold_rate = float(rolling_positive_rates.get(str(row["policy"]), 0.0)) + passes_rolling = positive_fold_rate >= float(config.min_positive_fold_rate) + summaries.append( + { + "policy": row["policy"], + "target_scenario_id": target_sid, + "avg_episode_net_return_pct": row["avg_episode_net_return_pct"], + "max_drawdown_pct": row["max_drawdown_pct"], + "trades_per_episode": row["trades_per_episode"], + "hit_rate": row["hit_rate"], + "positive_fold_rate": positive_fold_rate, + "passes_target_scenario_gate": row["passes_scenario_gate"], + "passes_rolling_gate": passes_rolling, + "passes_cost_gate": bool(row["passes_scenario_gate"] and passes_rolling), + } + ) + return sorted(summaries, key=lambda item: item["avg_episode_net_return_pct"], reverse=True) + + +def run_cost_gate(config: CostGateConfig) -> Dict[str, Any]: + """Run cost/slippage scenario and rolling-validation gates.""" + + output_dir = Path(config.output_dir) + scenario_rows, raw_scenarios = _run_cost_scenarios(config, output_dir) + rolling_rows, positive_rates = _run_rolling_validation(config, output_dir) + gate_rows = _gate_summary(config, scenario_rows, positive_rates) + passing = [row for row in gate_rows if row["passes_cost_gate"]] + payload = { + "mode": "stom_rl_cost_gate", + "config": asdict(config), + "summary": { + "scenario_count": len(config.cost_bps_values) * len(config.slippage_bps_values), + "policy_count": len(config.policies), + "target_cost_bps": config.target_cost_bps, + "target_slippage_bps": config.target_slippage_bps, + "passing_policy_count": len(passing), + "passing_policies": [row["policy"] for row in passing], + "best_policy_at_target_cost": gate_rows[0]["policy"] if gate_rows else None, + "gate_rows": gate_rows, + }, + "scenario_rows": scenario_rows, + "rolling_rows": rolling_rows, + "raw_scenarios": raw_scenarios, + "artifacts": { + "output_dir": str(output_dir), + "report_json": str(output_dir / "cost_gate_report.json"), + "scenario_csv": str(output_dir / "scenario_summary.csv"), + "rolling_csv": str(output_dir / "rolling_folds.csv"), + "gate_csv": str(output_dir / "gate_summary.csv"), + }, + } + _write_json(output_dir / "cost_gate_report.json", payload) + _write_csv( + output_dir / "scenario_summary.csv", + scenario_rows, + [ + "scenario_id", + "cost_bps", + "slippage_bps", + "policy", + "episode_count", + "trade_count", + "trades_per_episode", + "avg_episode_net_return_pct", + "median_episode_net_return_pct", + "compounded_return_pct", + "avg_trade_net_return_pct", + "hit_rate", + "invalid_action_rate", + "max_drawdown_pct", + "passes_net", + "passes_mdd", + "passes_turnover", + "passes_trade_count", + "passes_scenario_gate", + ], + ) + _write_csv( + output_dir / "rolling_folds.csv", + rolling_rows, + [ + "fold", + "sessions", + "cost_bps", + "slippage_bps", + "policy", + "episode_count", + "trade_count", + "avg_episode_net_return_pct", + "max_drawdown_pct", + "hit_rate", + "positive_fold", + ], + ) + _write_csv( + output_dir / "gate_summary.csv", + gate_rows, + [ + "policy", + "target_scenario_id", + "avg_episode_net_return_pct", + "max_drawdown_pct", + "trades_per_episode", + "hit_rate", + "positive_fold_rate", + "passes_target_scenario_gate", + "passes_rolling_gate", + "passes_cost_gate", + ], + ) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> CostGateConfig: + parser = argparse.ArgumentParser(description="Run STOM RL reward/cost-gate scenarios.") + parser.add_argument("--manifest", default=str(DEFAULT_OUTPUT_DIR / "episode_manifest.json")) + parser.add_argument("--output-dir", default=str(DEFAULT_COST_GATE_OUTPUT_DIR)) + parser.add_argument("--split", default="test") + parser.add_argument("--policies", default=",".join(DEFAULT_POLICIES)) + parser.add_argument("--cost-bps-values", default="5,10,15,25") + parser.add_argument("--slippage-bps-values", default="0") + parser.add_argument("--target-cost-bps", type=float, default=25.0) + parser.add_argument("--target-slippage-bps", type=float, default=0.0) + parser.add_argument("--max-episodes", type=int, default=25) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--reward-horizon-seconds", type=int, default=300) + parser.add_argument("--momentum-window", type=int, default=30) + parser.add_argument("--signal-threshold-bps", type=float, default=0.0) + parser.add_argument("--volume-window", type=int, default=30) + parser.add_argument("--amount-multiplier", type=float, default=1.5) + parser.add_argument("--max-steps-per-episode", type=int, default=0) + parser.add_argument("--sessions", default="") + parser.add_argument("--min-avg-episode-net-pct", type=float, default=0.0) + parser.add_argument("--max-drawdown-pct", type=float, default=20.0) + parser.add_argument("--max-trades-per-episode", type=float, default=50.0) + parser.add_argument("--min-trade-count", type=int, default=1) + parser.add_argument("--rolling-sessions-per-fold", type=int, default=6) + parser.add_argument("--rolling-max-folds", type=int, default=3) + parser.add_argument("--rolling-max-episodes-per-fold", type=int, default=25) + parser.add_argument("--min-positive-fold-rate", type=float, default=0.5) + parser.add_argument("--write-policy-artifacts", action="store_true") + args = parser.parse_args(argv) + return CostGateConfig( + manifest_path=args.manifest, + output_dir=args.output_dir, + split=args.split, + policies=_parse_str_tuple(args.policies), + cost_bps_values=_parse_float_tuple(args.cost_bps_values), + slippage_bps_values=_parse_float_tuple(args.slippage_bps_values), + target_cost_bps=args.target_cost_bps, + target_slippage_bps=args.target_slippage_bps, + max_episodes=args.max_episodes, + seed=args.seed, + lookback_window=args.lookback_window, + reward_horizon_seconds=args.reward_horizon_seconds, + momentum_window=args.momentum_window, + signal_threshold_bps=args.signal_threshold_bps, + volume_window=args.volume_window, + amount_multiplier=args.amount_multiplier, + max_steps_per_episode=args.max_steps_per_episode, + sessions=_parse_str_tuple(args.sessions), + min_avg_episode_net_pct=args.min_avg_episode_net_pct, + max_drawdown_pct=args.max_drawdown_pct, + max_trades_per_episode=args.max_trades_per_episode, + min_trade_count=args.min_trade_count, + rolling_sessions_per_fold=args.rolling_sessions_per_fold, + rolling_max_folds=args.rolling_max_folds, + rolling_max_episodes_per_fold=args.rolling_max_episodes_per_fold, + min_positive_fold_rate=args.min_positive_fold_rate, + write_policy_artifacts=args.write_policy_artifacts, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_cost_gate(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/daily_ohlcv_dataset.py b/stom_rl/daily_ohlcv_dataset.py new file mode 100644 index 000000000..eb59b8a13 --- /dev/null +++ b/stom_rl/daily_ohlcv_dataset.py @@ -0,0 +1,942 @@ +"""Daily OHLCV dataset builder for research-only daily modeling. + +The builder consumes the read-only daily OHLCV database plus the conservative D1 +universe manifest and emits auditable feature/label/split artifacts. It does +not train models, place orders, or imply profit/liveness; price-basis and +universe WATCH provenance are carried into every manifest. +""" + +from __future__ import annotations + +import csv +import hashlib +import json +import math +import re +import sqlite3 +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +from .daily_ohlcv_db import ( + ADJUSTMENT_POLICY, + CORE_COLUMNS, + DECISION_GRADE_RETURN_STATUS, + DEFAULT_DAILY_DB_PATH, + EXPECTED_COLUMNS, + PRICE_BASIS, + PRICE_BASIS_EVIDENCE, + PRICE_BASIS_STATUS, + REPO_ROOT, + analyze_table_quality, + connect_readonly, + validate_daily_table_name, +) +from .daily_ohlcv_universe import DEFAULT_UNIVERSE_ROOT + +DEFAULT_DATASET_ROOT = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_dataset" +DATASET_SCHEMA_VERSION = 1 +DEFAULT_FEATURE_COLUMNS = [ + "return_1d", + "return_5d", + "volatility_5d", + "volume_ratio_5d", + "hl_range", + "gap_from_prev_close", + "foreign_holding_ratio", + "institutional_net_buy", +] +DEFAULT_LABEL_COLUMNS = ["future_return_1d", "future_direction_1d", "future_rank_pct_1d"] +FORBIDDEN_FEATURE_PREFIXES = ("future_", "label_", "target_") +DATASET_REQUIRED_EVIDENCE = ( + "d0_price_basis_status_and_decision_grade_return_status", + "d1_universe_manifest_sha_and_certification_status", + "chronological_train_val_test_split_with_purge_embargo", + "train_only_normalization_statistics", + "material_unknown_adjustment_windows_excluded_from_eligible_rows", + "feature_label_leakage_report", +) +DATASET_ALLOWED_USES_WITH_UPSTREAM_BLOCKERS = ( + "research_dataset_preview", + "split_and_leakage_validation", + "feature_pipeline_debugging", + "dashboard_evidence_navigation", +) +DATASET_BLOCKED_USES_WITH_UPSTREAM_BLOCKERS = ( + "model_build_or_candidate_promotion", + "decision_grade_return_labels", + "paper_forward_or_live_readiness_claims", +) +DATASET_USER_GUIDANCE = ( + { + "section": "D2 summary", + "meaning": "D2 PASS means leakage and chronological split checks passed for a generated research dataset artifact.", + "action": "Use it to inspect features, labels, splits, blocked windows, and normalization provenance.", + }, + { + "section": "Upstream blockers", + "meaning": "D0 price_basis unknown or D1 universe review incomplete still blocks model promotion even if D2 leakage/split checks pass.", + "action": "Keep upstream_gate_blockers visible until D0/D1 are independently verified.", + }, + { + "section": "Model/RL use", + "meaning": "D2 is not training, not a profit claim, and not live/broker/order readiness.", + "action": "Run D3 baselines and D5 gates before any model_build_allowed claim.", + }, +) + +DATASET_PRICE_BASIS_VERIFIED_STATUSES = {"RAW_VERIFIED", "ADJUSTED_VERIFIED", "PRICE_BASIS_VERIFIED", "VERIFIED"} +DATASET_PRICE_BASIS_VERIFIED_VALUES = {"raw", "adjusted", "split_adjusted", "total_return_adjusted"} +DATASET_UNIVERSE_VERIFIED_VERDICT = "OFFICIAL_OR_MANUAL_REVIEWED" +DATASET_OFFICIAL_METADATA_VERIFIED_STATUS = "OFFICIAL_VERIFIED" +DATASET_OFFICIAL_METADATA_COMPLETE_COVERAGE = "COMPLETE" + + +def _price_basis_verified_for_dataset() -> bool: + if str(PRICE_BASIS).lower() not in DATASET_PRICE_BASIS_VERIFIED_VALUES: + return False + if str(PRICE_BASIS_STATUS).upper() not in DATASET_PRICE_BASIS_VERIFIED_STATUSES: + return False + return not str(DECISION_GRADE_RETURN_STATUS).startswith("BLOCKED") + + +def _universe_verified_for_dataset(universe: dict[str, Any]) -> bool: + return ( + str(universe.get("verdict") or "") == DATASET_UNIVERSE_VERIFIED_VERDICT + and str(universe.get("universe_review_status") or universe.get("review_status") or "") == DATASET_UNIVERSE_VERIFIED_VERDICT + and str(universe.get("official_metadata_status") or "") == DATASET_OFFICIAL_METADATA_VERIFIED_STATUS + and str(universe.get("official_metadata_coverage_status") or "") == DATASET_OFFICIAL_METADATA_COMPLETE_COVERAGE + and str(universe.get("universe_certification_status") or "") == DATASET_UNIVERSE_VERIFIED_VERDICT + ) + + +def _upstream_gate_blockers(universe: dict[str, Any]) -> list[str]: + blockers: list[str] = [] + if not _price_basis_verified_for_dataset(): + blockers.append("D0_PRICE_BASIS_NOT_VERIFIED") + if not _universe_verified_for_dataset(universe): + blockers.append("D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED") + return blockers + + + +FEATURE_DEFINITIONS: dict[str, dict[str, Any]] = { + "return_1d": { + "description": "Close-to-previous-close return known after the current daily close.", + "uses_future": False, + "availability": "after_current_close", + }, + "return_5d": { + "description": "Five-session trailing close return known after the current daily close.", + "uses_future": False, + "availability": "after_current_close", + }, + "volatility_5d": { + "description": "Trailing standard deviation of daily returns over up to five current/past sessions.", + "uses_future": False, + "availability": "after_current_close", + }, + "volume_ratio_5d": { + "description": "Current volume divided by trailing five-session mean volume.", + "uses_future": False, + "availability": "after_current_close", + }, + "hl_range": { + "description": "Current high-low range divided by close.", + "uses_future": False, + "availability": "after_current_close", + }, + "gap_from_prev_close": { + "description": "Current open versus previous close, still same-day causal for next-day labels.", + "uses_future": False, + "availability": "after_current_close", + }, + "foreign_holding_ratio": { + "description": "Daily foreign holding ratio from the source table, carried as end-of-day evidence.", + "uses_future": False, + "availability": "after_current_close", + }, + "institutional_net_buy": { + "description": "Daily institutional net buy value from the source table, carried as end-of-day evidence.", + "uses_future": False, + "availability": "after_current_close", + }, +} + +LABEL_DEFINITIONS: dict[str, dict[str, Any]] = { + "future_return_1d": { + "description": "Forward close-to-close return over the configured horizon; label only.", + "uses_future": True, + }, + "future_direction_1d": { + "description": "Binary positive forward-return label; label only.", + "uses_future": True, + }, + "future_rank_pct_1d": { + "description": "Cross-sectional percentile rank of future_return_1d on the same date; label only.", + "uses_future": True, + }, +} + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _sha_json(payload: Any) -> str: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _file_sha(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _safe_float(value: Any) -> float | None: + try: + if value is None: + return None + result = float(value) + if math.isnan(result) or math.isinf(result): + return None + return result + except (TypeError, ValueError): + return None + + +def _safe_ratio(numerator: Any, denominator: Any) -> float | None: + num = _safe_float(numerator) + den = _safe_float(denominator) + if num is None or den is None or den == 0: + return None + return num / den + + +def _pct_change(current: Any, previous: Any) -> float | None: + ratio = _safe_ratio(current, previous) + if ratio is None: + return None + return ratio - 1.0 + + +def _mean(values: Iterable[float]) -> float | None: + clean = [float(v) for v in values if v is not None and not math.isnan(float(v))] + if not clean: + return None + return sum(clean) / len(clean) + + +def _std(values: Iterable[float]) -> float | None: + clean = [float(v) for v in values if v is not None and not math.isnan(float(v))] + if not clean: + return None + avg = sum(clean) / len(clean) + return math.sqrt(sum((value - avg) ** 2 for value in clean) / len(clean)) + + +def _validate_run_id(run_id: str) -> str: + rid = str(run_id or "").strip() + if not re.match(r"^[0-9A-Za-z_.-]+$", rid) or rid in {".", ".."} or ".." in rid.split("."): + raise ValueError("run_id contains unsafe characters") + return rid + + +def _quote_ident(table: str) -> str: + table = validate_daily_table_name(table) + return '"' + table.replace('"', '""') + '"' + + +def validate_no_feature_leakage(feature_columns: Iterable[str], label_columns: Iterable[str]) -> dict[str, Any]: + """Validate that label/future-looking columns are not used as model features.""" + + features = [str(col) for col in feature_columns] + labels = {str(col) for col in label_columns} + forbidden = sorted( + col + for col in features + if col in labels or any(col.startswith(prefix) for prefix in FORBIDDEN_FEATURE_PREFIXES) + ) + report = { + "schema_version": DATASET_SCHEMA_VERSION, + "status": "PASS" if not forbidden else "BLOCK", + "feature_columns": features, + "label_columns": sorted(labels), + "forbidden_feature_columns": forbidden, + "checks": [ + "feature_columns_do_not_overlap_label_columns", + "feature_columns_do_not_start_with_future_label_or_target_prefix", + "feature_definitions_mark_uses_future_false", + ], + } + if forbidden: + raise ValueError(f"Feature leakage detected: {forbidden}") + return report + + +def assign_chronological_splits( + dates: Iterable[str], + *, + train_fraction: float = 0.6, + val_fraction: float = 0.2, + purge_days: int = 5, + embargo_days: int = 5, +) -> dict[str, str]: + """Assign chronological train/val/test labels with blocked purge gaps.""" + + ordered = sorted({str(date) for date in dates if date is not None}) + if not ordered: + return {} + if not 0 < train_fraction < 1: + raise ValueError("train_fraction must be between 0 and 1") + if not 0 <= val_fraction < 1 or train_fraction + val_fraction >= 1: + raise ValueError("train_fraction + val_fraction must be below 1") + gap = max(0, int(purge_days), int(embargo_days)) + n_dates = len(ordered) + train_end = max(1, min(n_dates - 2, int(n_dates * train_fraction))) if n_dates >= 3 else max(1, n_dates) + val_end = max(train_end + 1, min(n_dates - 1, int(n_dates * (train_fraction + val_fraction)))) if n_dates >= 3 else n_dates + + assignments: dict[str, str] = {} + for index, date in enumerate(ordered): + if index < train_end: + split = "train" + elif index < min(n_dates, train_end + gap): + split = "blocked_purge_embargo" + elif index < val_end: + split = "val" + elif index < min(n_dates, val_end + gap): + split = "blocked_purge_embargo" + else: + split = "test" + assignments[date] = split + return assignments + + +def latest_universe_manifest_path(root: Path | str = DEFAULT_UNIVERSE_ROOT) -> Path: + candidates = sorted(Path(root).glob("*/universe.json"), key=lambda path: path.stat().st_mtime, reverse=True) + if not candidates: + raise FileNotFoundError(f"No daily OHLCV universe manifest found under {root}") + return candidates[0] + + +def load_universe_manifest(path: Path | str | None = None) -> tuple[dict[str, Any], Path, str]: + manifest_path = Path(path) if path is not None else latest_universe_manifest_path() + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + return payload, manifest_path, _file_sha(manifest_path) + + +def _included_universe_symbols(manifest: dict[str, Any], *, max_symbols: int | None = None) -> list[dict[str, Any]]: + symbols = [row for row in manifest.get("symbols") or [] if row.get("include") is True] + symbols = sorted(symbols, key=lambda row: str(row.get("table") or "")) + if max_symbols is not None: + symbols = symbols[: max(0, int(max_symbols))] + return symbols + + +def _read_table_rows(conn: sqlite3.Connection, table: str, *, max_rows_per_symbol: int | None = None) -> list[dict[str, Any]]: + qt = _quote_ident(table) + if max_rows_per_symbol is None: + query = f"SELECT * FROM {qt} ORDER BY date ASC" + rows = conn.execute(query).fetchall() + else: + limit = max(0, int(max_rows_per_symbol)) + query = f"SELECT * FROM (SELECT * FROM {qt} ORDER BY date DESC LIMIT ?) ORDER BY date ASC" + rows = conn.execute(query, (limit,)).fetchall() + return [dict(row) for row in rows] + + +def _build_symbol_panels( + rows: list[dict[str, Any]], + *, + table: str, + code: str, + horizon_days: int, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + feature_rows: list[dict[str, Any]] = [] + label_rows: list[dict[str, Any]] = [] + returns: list[float | None] = [] + volumes: list[float | None] = [] + + for index, row in enumerate(rows): + previous = rows[index - 1] if index > 0 else None + close = _safe_float(row.get("close")) + prev_close = _safe_float(previous.get("close")) if previous is not None else None + ret_1d = _pct_change(close, prev_close) + returns.append(ret_1d) + volume = _safe_float(row.get("volume")) + volumes.append(volume) + window_returns = [value for value in returns[max(0, index - 4) : index + 1] if value is not None] + window_volumes = [value for value in volumes[max(0, index - 4) : index + 1] if value is not None] + close_5 = _safe_float(rows[index - 5].get("close")) if index >= 5 else None + return_5d = _pct_change(close, close_5) + vol_5d = _std(window_returns) + mean_volume_5d = _mean(window_volumes) + volume_ratio_5d = (volume / mean_volume_5d) if volume is not None and mean_volume_5d not in {None, 0} else None + hl_range = None + high = _safe_float(row.get("high")) + low = _safe_float(row.get("low")) + if high is not None and low is not None and close not in {None, 0}: + hl_range = (high - low) / close + gap_from_prev_close = _pct_change(row.get("open"), prev_close) + feature_rows.append( + { + "date": str(row.get("date")), + "table": table, + "code": code, + "return_1d": ret_1d, + "return_5d": return_5d, + "volatility_5d": vol_5d, + "volume_ratio_5d": volume_ratio_5d, + "hl_range": hl_range, + "gap_from_prev_close": gap_from_prev_close, + "foreign_holding_ratio": _safe_float(row.get("외국인현보유비율")), + "institutional_net_buy": _safe_float(row.get("기관순매수")), + } + ) + + future = rows[index + horizon_days] if index + horizon_days < len(rows) else None + future_return = _pct_change(future.get("close"), close) if future is not None else None + label_rows.append( + { + "date": str(row.get("date")), + "table": table, + "code": code, + "future_return_1d": future_return, + "future_direction_1d": None if future_return is None else int(future_return > 0), + "future_rank_pct_1d": None, + } + ) + return feature_rows, label_rows + + +def _add_cross_sectional_label_ranks(label_rows: list[dict[str, Any]]) -> None: + by_date: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in label_rows: + if row.get("future_return_1d") is not None: + by_date[str(row["date"])].append(row) + for rows in by_date.values(): + ordered = sorted(rows, key=lambda row: float(row["future_return_1d"])) + denom = max(1, len(ordered) - 1) + for index, row in enumerate(ordered): + row["future_rank_pct_1d"] = 1.0 if len(ordered) == 1 else index / denom + + +def _build_candidate_panel(feature_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + candidate_rows: list[dict[str, Any]] = [] + by_date: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in feature_rows: + vol = _safe_float(row.get("volatility_5d")) + momentum = _safe_float(row.get("return_5d")) + score = None if momentum is None else momentum / max(abs(vol or 0.0), 1e-9) + candidate = { + "date": row["date"], + "table": row["table"], + "code": row["code"], + "candidate_score_causal_momentum": score, + "candidate_rank_within_date": None, + "label_join_key": f'{row["date"]}|{row["table"]}', + } + candidate_rows.append(candidate) + if score is not None: + by_date[str(row["date"])].append(candidate) + for rows in by_date.values(): + ordered = sorted(rows, key=lambda row: float(row["candidate_score_causal_momentum"]), reverse=True) + denom = max(1, len(ordered) - 1) + for index, row in enumerate(ordered): + row["candidate_rank_within_date"] = 1.0 if len(ordered) == 1 else 1.0 - (index / denom) + return candidate_rows + + +def _apply_splits_and_blocks( + feature_rows: list[dict[str, Any]], + label_rows: list[dict[str, Any]], + candidate_rows: list[dict[str, Any]], + *, + split_by_date: dict[str, str], + blocked_dates_by_table: dict[str, dict[str, dict[str, Any]]], +) -> list[dict[str, Any]]: + labels_by_key = {(row["date"], row["table"]): row for row in label_rows} + candidates_by_key = {(row["date"], row["table"]): row for row in candidate_rows} + assignments: list[dict[str, Any]] = [] + for row in feature_rows: + key = (row["date"], row["table"]) + split = split_by_date.get(str(row["date"]), "blocked_out_of_range") + block_reason = None + blocked_payload = blocked_dates_by_table.get(row["table"], {}).get(row["date"]) + if blocked_payload is not None: + split = "blocked_material_unknown_adjustment" + block_reason = str(blocked_payload.get("reason") or "SPLIT_LIKE_DISCONTINUITY_UNKNOWN_ADJUSTMENT").upper() + label = labels_by_key.get(key, {}) + if label.get("future_return_1d") is None and split in {"train", "val", "test"}: + split = "blocked_missing_future_label" + block_reason = "MISSING_FUTURE_LABEL" + eligible = split in {"train", "val", "test"} and block_reason is None + assignment = { + "date": row["date"], + "table": row["table"], + "code": row["code"], + "split": split, + "eligible_for_training": eligible, + "block_reason": block_reason, + } + assignments.append(assignment) + row["split"] = split + row["eligible_for_training"] = eligible + row["block_reason"] = block_reason + if label: + label["split"] = split + label["eligible_for_training"] = eligible + label["block_reason"] = block_reason + candidate = candidates_by_key.get(key) + if candidate is not None: + candidate["split"] = split + candidate["eligible_for_training"] = eligible + candidate["block_reason"] = block_reason + return assignments + + +def _normalization_stats(feature_rows: list[dict[str, Any]], feature_columns: list[str]) -> dict[str, Any]: + train_rows = [row for row in feature_rows if row.get("split") == "train" and row.get("eligible_for_training")] + features: dict[str, dict[str, Any]] = {} + for column in feature_columns: + values = [_safe_float(row.get(column)) for row in train_rows] + values = [value for value in values if value is not None] + mean = _mean(values) + std = _std(values) + features[column] = { + "mean": mean, + "std": 1.0 if std in {None, 0} else std, + "raw_std": std, + "count": len(values), + "fit_split": "train", + "status": "constant_or_missing" if std in {None, 0} else "ok", + } + return { + "schema_version": DATASET_SCHEMA_VERSION, + "fit_split": "train", + "fit_row_count": len(train_rows), + "feature_columns": feature_columns, + "features": features, + } + + +def _split_summary(assignments: list[dict[str, Any]]) -> dict[str, Any]: + counts: dict[str, int] = defaultdict(int) + split_dates: dict[str, set[str]] = defaultdict(set) + ordered_dates = sorted({str(row["date"]) for row in assignments}) + date_index = {date: index for index, date in enumerate(ordered_dates)} + for row in assignments: + split = str(row["split"]) + counts[split] += 1 + split_dates[split].add(str(row["date"])) + + date_ranges: dict[str, dict[str, Any]] = {} + for split, dates in split_dates.items(): + ordered = sorted(dates, key=lambda date: date_index[date]) + intervals: list[dict[str, str]] = [] + start = end = None + previous_index: int | None = None + for date in ordered: + index = date_index[date] + if start is None: + start = end = date + elif previous_index is not None and index == previous_index + 1: + end = date + else: + intervals.append({"start": str(start), "end": str(end)}) + start = end = date + previous_index = index + if start is not None: + intervals.append({"start": str(start), "end": str(end)}) + if split in {"train", "val", "test"} and len(intervals) == 1: + date_ranges[split] = {"start": intervals[0]["start"], "end": intervals[0]["end"], "intervals": intervals} + else: + date_ranges[split] = {"start": None, "end": None, "intervals": intervals} + return {"row_counts": dict(counts), "date_ranges": date_ranges} +def _eligible_split_chronology(assignments: list[dict[str, Any]]) -> dict[str, Any]: + split_dates = { + split: sorted({str(row["date"]) for row in assignments if row.get("split") == split}) + for split in ("train", "val", "test") + } + missing = [split for split, dates in split_dates.items() if not dates] + ordered = not missing + if ordered: + ordered = max(split_dates["train"]) < min(split_dates["val"]) < max(split_dates["val"]) < min(split_dates["test"]) + return { + "status": "PASS" if ordered else "BLOCK", + "missing_splits": missing, + "train_end": max(split_dates["train"]) if split_dates["train"] else None, + "val_start": min(split_dates["val"]) if split_dates["val"] else None, + "val_end": max(split_dates["val"]) if split_dates["val"] else None, + "test_start": min(split_dates["test"]) if split_dates["test"] else None, + } + + +def build_daily_ohlcv_dataset( + *, + daily_db_path: Path | str = DEFAULT_DAILY_DB_PATH, + universe_manifest_path: Path | str | None = None, + max_symbols: int | None = None, + max_rows_per_symbol: int | None = None, + horizon_days: int = 1, + train_fraction: float = 0.6, + val_fraction: float = 0.2, + purge_days: int = 5, + embargo_days: int = 5, + quality_table_limit: int | None = None, +) -> dict[str, Any]: + """Build causal daily feature/label panels from a read-only DB and universe manifest.""" + + if horizon_days < 1: + raise ValueError("horizon_days must be >= 1") + feature_columns = list(DEFAULT_FEATURE_COLUMNS) + label_columns = list(DEFAULT_LABEL_COLUMNS) + leakage_report = validate_no_feature_leakage(feature_columns, label_columns) + universe, universe_path, universe_file_sha = load_universe_manifest(universe_manifest_path) + included_symbols = _included_universe_symbols(universe, max_symbols=max_symbols) + + all_feature_rows: list[dict[str, Any]] = [] + all_label_rows: list[dict[str, Any]] = [] + blocked_windows: list[dict[str, Any]] = [] + blocked_dates_by_table: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) + quality_window_dates_by_table: dict[str, list[str]] = defaultdict(list) + missing_tables: list[str] = [] + schema_blocked_tables: list[str] = [] + + with connect_readonly(daily_db_path) as conn: + table_names = { + str(row[0]) + for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'" + ).fetchall() + } + quality_limit = len(included_symbols) if quality_table_limit is None else min(len(included_symbols), max(0, int(quality_table_limit))) + for index, symbol in enumerate(included_symbols): + table = validate_daily_table_name(str(symbol.get("table"))) + code = str(symbol.get("code") or table[1:]).zfill(6) + if table not in table_names: + missing_tables.append(table) + continue + if index < quality_limit: + quality = analyze_table_quality(conn, table, discontinuity_limit=200) + for window in quality.get("material_unknown_adjustment_windows") or []: + blocked = { + "table": table, + "code": code, + "date": str(window.get("date")), + "previous_date": str(window.get("previous_date")), + "reason": window.get("reason") or "split_like_price_jump_with_unknown_adjustment_basis", + "open_to_previous_close_ratio": window.get("open_to_previous_close_ratio"), + "close_to_previous_close_ratio": window.get("close_to_previous_close_ratio"), + } + blocked_windows.append(blocked) + quality_window_dates_by_table[table].append(blocked["date"]) + blocked_dates_by_table[table][blocked["date"]] = blocked + blocked_dates_by_table[table][blocked["previous_date"]] = blocked + if quality.get("quality_status") == "BLOCKED_SCHEMA": + schema_blocked_tables.append(table) + continue + rows = _read_table_rows(conn, table, max_rows_per_symbol=max_rows_per_symbol) + if not rows: + continue + if table in quality_window_dates_by_table: + dates = [str(row.get("date")) for row in rows] + date_to_index = {date: row_index for row_index, date in enumerate(dates)} + for window_date in quality_window_dates_by_table[table]: + window_index = date_to_index.get(window_date) + if window_index is None: + continue + for row_index in range(max(0, window_index - int(horizon_days)), window_index + 1): + expanded_date = dates[row_index] + existing = blocked_dates_by_table[table].get(window_date, {}) + blocked_dates_by_table[table].setdefault( + expanded_date, + { + **existing, + "date": expanded_date, + "window_date": window_date, + "reason": "forward_label_window_touches_split_like_discontinuity_unknown_adjustment", + }, + ) + features, labels = _build_symbol_panels(rows, table=table, code=code, horizon_days=horizon_days) + all_feature_rows.extend(features) + all_label_rows.extend(labels) + + _add_cross_sectional_label_ranks(all_label_rows) + candidate_rows = _build_candidate_panel(all_feature_rows) + all_dates = [row["date"] for row in all_feature_rows] + split_by_date = assign_chronological_splits( + all_dates, + train_fraction=train_fraction, + val_fraction=val_fraction, + purge_days=purge_days, + embargo_days=embargo_days, + ) + split_assignments = _apply_splits_and_blocks( + all_feature_rows, + all_label_rows, + candidate_rows, + split_by_date=split_by_date, + blocked_dates_by_table=blocked_dates_by_table, + ) + normalization_stats = _normalization_stats(all_feature_rows, feature_columns) + split_chronology = _eligible_split_chronology(split_assignments) + leakage_report = { + **leakage_report, + "feature_definition_uses_future": {name: FEATURE_DEFINITIONS[name]["uses_future"] for name in feature_columns}, + "normalization_fit_split": "train", + "split_chronology_status": split_chronology["status"], + "material_unknown_adjustment_window_policy": "exclude_from_train_val_test_and_record_blocked_windows", + "split_chronology": split_chronology, + } + split_summary = _split_summary(split_assignments) + eligible_rows = sum(1 for row in split_assignments if row.get("eligible_for_training")) + bounded_preview = max_symbols is not None or max_rows_per_symbol is not None + upstream_blockers = _upstream_gate_blockers(universe) + manifest = { + "schema_version": DATASET_SCHEMA_VERSION, + "generated_at": _utc_now(), + "guardrail": "Research-only daily OHLCV dataset; no profit guarantee, no live/broker/orders, no model readiness claim.", + "strategy_label": "daily_supervised_or_rl_research_dataset", + "daily_db_path": str(Path(daily_db_path)), + "price_basis": PRICE_BASIS, + "price_basis_evidence": PRICE_BASIS_EVIDENCE, + "adjustment_policy": ADJUSTMENT_POLICY, + "material_unknown_adjustment_window_policy": "exclude_from_train_val_test_and_record_blocked_windows", + "universe_manifest_path": str(universe_path), + "universe_manifest_sha": universe.get("manifest_sha") or universe_file_sha, + "universe_file_sha256": universe_file_sha, + "price_basis_status": PRICE_BASIS_STATUS, + "decision_grade_return_status": DECISION_GRADE_RETURN_STATUS, + "universe_verdict": universe.get("verdict"), + "universe_review_status": universe.get("universe_review_status") or universe.get("review_status"), + "official_metadata_status": universe.get("official_metadata_status"), + "official_metadata_coverage_status": universe.get("official_metadata_coverage_status"), + "universe_certification_status": universe.get("universe_certification_status"), + "feature_columns": feature_columns, + "label_columns": label_columns, + "feature_definitions": FEATURE_DEFINITIONS, + "label_definitions": LABEL_DEFINITIONS, + "horizon_days": horizon_days, + "split_policy": { + "method": "chronological_train_val_test_with_purge_embargo", + "train_fraction": train_fraction, + "val_fraction": val_fraction, + "test_fraction": 1.0 - train_fraction - val_fraction, + "purge_days": int(purge_days), + "embargo_days": int(embargo_days), + }, + "split_summary": split_summary, + "normalization_policy": "fit_train_only_apply_to_val_test_later", + "cost_assumption_round_trip_bp": 23, + "artifact_scope": "BOUNDED_PREVIEW" if bounded_preview else "FULL_REQUESTED_UNIVERSE", + "source_counts": { + "universe_symbols_total": len(universe.get("symbols") or []), + "included_symbols_total": sum(1 for row in universe.get("symbols") or [] if row.get("include") is True), + "included_symbols_used": len(included_symbols), + "max_symbols": max_symbols, + "max_rows_per_symbol": max_rows_per_symbol, + "quality_scanned_symbols": quality_limit, + "quality_scan_complete_for_used_symbols": quality_limit == len(included_symbols), + "missing_tables": len(missing_tables), + "schema_blocked_tables": len(schema_blocked_tables), + }, + "row_counts": { + "feature_rows": len(all_feature_rows), + "label_rows": len(all_label_rows), + "rl_candidate_rows": len(candidate_rows), + "split_assignment_rows": len(split_assignments), + "eligible_rows": eligible_rows, + "blocked_windows": len(blocked_windows), + }, + "missing_tables": missing_tables[:100], + "schema_blocked_tables": schema_blocked_tables[:100], + "leakage_status": leakage_report["status"], + "split_chronology_status": split_chronology["status"], + "upstream_gate_blockers": upstream_blockers, + "dataset_required_evidence": list(DATASET_REQUIRED_EVIDENCE), + "dataset_allowed_uses": list(DATASET_ALLOWED_USES_WITH_UPSTREAM_BLOCKERS), + "dataset_blocked_uses": list(DATASET_BLOCKED_USES_WITH_UPSTREAM_BLOCKERS), + "dataset_user_guidance": [dict(row) for row in DATASET_USER_GUIDANCE], + "decision_grade_status": ( + "BLOCKED_BY_UPSTREAM_D0_D1_GUARDRAILS" + if upstream_blockers + else "D2_RESEARCH_DATASET_VALIDATED" + ), + "model_readiness": ( + "DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS" + if upstream_blockers + else ( + "DATASET_RESEARCH_READY_FOR_BASELINE_ONLY" + if leakage_report["status"] == "PASS" and split_chronology["status"] == "PASS" + else "BLOCKED_DATASET_VALIDATION" + ) + ), + } + manifest["manifest_sha"] = _sha_json( + { + "schema_version": manifest["schema_version"], + "generated_at": manifest["generated_at"], + "universe_manifest_sha": manifest["universe_manifest_sha"], + "feature_columns": feature_columns, + "label_columns": label_columns, + "row_counts": manifest["row_counts"], + "split_policy": manifest["split_policy"], + "leakage_status": manifest["leakage_status"], + "split_chronology_status": manifest["split_chronology_status"], + "candidate_panel_columns": list(candidate_rows[0].keys()) if candidate_rows else [], + } + ) + return { + "manifest": manifest, + "feature_panel": all_feature_rows, + "label_panel": all_label_rows, + "rl_candidate_panel": candidate_rows, + "split_assignments": split_assignments, + "normalization_stats": normalization_stats, + "leakage_report": leakage_report, + "blocked_windows": blocked_windows, + } + + +def _write_rows_csv(path: Path, rows: list[dict[str, Any]], *, fallback_fields: list[str]) -> None: + if rows: + fields = sorted({key for row in rows for key in row.keys()}) + else: + fields = fallback_fields + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + for row in rows: + writer.writerow(row) + + +def write_dataset_artifacts( + dataset: dict[str, Any], + *, + artifact_root: Path | str | None = None, + run_id: str | None = None, + overwrite: bool = False, +) -> dict[str, Any]: + root = Path(artifact_root or DEFAULT_DATASET_ROOT).resolve() + default_root = DEFAULT_DATASET_ROOT.resolve() + try: + root.relative_to(default_root) + except ValueError: + if root != default_root: + raise ValueError("Daily OHLCV dataset artifacts must stay under webui/rl_runs/daily_ohlcv_dataset") + rid = _validate_run_id(run_id or f"dataset_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}") + out_dir = (root / rid).resolve() + try: + out_dir.relative_to(root) + except ValueError as exc: + raise ValueError("run_id escapes daily OHLCV dataset artifact root") from exc + if out_dir.exists() and any(out_dir.iterdir()) and not overwrite: + raise FileExistsError(f"Dataset artifact run_id already exists: {rid}") + out_dir.mkdir(parents=True, exist_ok=True) + + manifest_path = out_dir / "dataset_manifest.json" + feature_path = out_dir / "feature_panel.csv" + label_path = out_dir / "label_panel.csv" + candidate_path = out_dir / "rl_candidate_panel.csv" + split_path = out_dir / "split_assignments.csv" + normalization_path = out_dir / "normalization_stats.json" + leakage_path = out_dir / "leakage_report.json" + blocked_path = out_dir / "blocked_windows.csv" + + manifest = dict(dataset["manifest"]) + manifest["run_id"] = rid + manifest["artifact_dir"] = str(out_dir) + manifest["artifacts"] = { + "dataset_manifest": str(manifest_path), + "feature_panel": str(feature_path), + "label_panel": str(label_path), + "rl_candidate_panel": str(candidate_path), + "split_assignments": str(split_path), + "normalization_stats": str(normalization_path), + "leakage_report": str(leakage_path), + "blocked_windows": str(blocked_path), + } + manifest["manifest_sha"] = _sha_json( + { + "run_id": rid, + "feature_columns": manifest.get("feature_columns"), + "label_columns": manifest.get("label_columns"), + "row_counts": manifest.get("row_counts"), + "split_policy": manifest.get("split_policy"), + "universe_manifest_sha": manifest.get("universe_manifest_sha"), + "split_chronology_status": manifest.get("split_chronology_status"), + "candidate_panel_columns": list((dataset.get("rl_candidate_panel") or [{}])[0].keys()), + } + ) + + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + normalization_path.write_text(json.dumps(dataset["normalization_stats"], ensure_ascii=False, indent=2), encoding="utf-8") + leakage_path.write_text(json.dumps(dataset["leakage_report"], ensure_ascii=False, indent=2), encoding="utf-8") + _write_rows_csv(feature_path, dataset["feature_panel"], fallback_fields=["date", "table", "code", *DEFAULT_FEATURE_COLUMNS]) + _write_rows_csv(label_path, dataset["label_panel"], fallback_fields=["date", "table", "code", *DEFAULT_LABEL_COLUMNS]) + _write_rows_csv( + candidate_path, + dataset["rl_candidate_panel"], + fallback_fields=["date", "table", "code", "candidate_score_causal_momentum", "candidate_rank_within_date", "label_join_key"], + ) + _write_rows_csv( + split_path, + dataset["split_assignments"], + fallback_fields=["date", "table", "code", "split", "eligible_for_training", "block_reason"], + ) + _write_rows_csv( + blocked_path, + dataset["blocked_windows"], + fallback_fields=["table", "code", "previous_date", "date", "reason"], + ) + return { + "run_id": rid, + "artifact_dir": str(out_dir), + "dataset_manifest_path": str(manifest_path), + "feature_panel_path": str(feature_path), + "label_panel_path": str(label_path), + "rl_candidate_panel_path": str(candidate_path), + "split_assignments_path": str(split_path), + "normalization_stats_path": str(normalization_path), + "leakage_report_path": str(leakage_path), + "blocked_windows_path": str(blocked_path), + "manifest_sha": manifest["manifest_sha"], + } + + +def build_and_write_daily_ohlcv_dataset( + *, + run_id: str | None = None, + artifact_root: Path | str | None = None, + overwrite: bool = False, + **kwargs: Any, +) -> dict[str, Any]: + dataset = build_daily_ohlcv_dataset(**kwargs) + written = write_dataset_artifacts(dataset, artifact_root=artifact_root, run_id=run_id, overwrite=overwrite) + return {"dataset": dataset, "written": written} + + +__all__ = [ + "DATASET_SCHEMA_VERSION", + "DEFAULT_DATASET_ROOT", + "DEFAULT_FEATURE_COLUMNS", + "DEFAULT_LABEL_COLUMNS", + "DATASET_REQUIRED_EVIDENCE", + "DATASET_ALLOWED_USES_WITH_UPSTREAM_BLOCKERS", + "DATASET_BLOCKED_USES_WITH_UPSTREAM_BLOCKERS", + "DATASET_USER_GUIDANCE", + "FEATURE_DEFINITIONS", + "LABEL_DEFINITIONS", + "assign_chronological_splits", + "build_and_write_daily_ohlcv_dataset", + "build_daily_ohlcv_dataset", + "latest_universe_manifest_path", + "load_universe_manifest", + "validate_no_feature_leakage", + "write_dataset_artifacts", +] diff --git a/stom_rl/daily_ohlcv_db.py b/stom_rl/daily_ohlcv_db.py new file mode 100644 index 000000000..464e413c6 --- /dev/null +++ b/stom_rl/daily_ohlcv_db.py @@ -0,0 +1,664 @@ +"""Read-only analysis helpers for the daily OHLCV SQLite database. + +The daily DB is a research input. These helpers never mutate it; generated +summaries belong under ``webui/rl_runs`` and every price-derived result keeps the +adjustment provenance explicit because the source DB does not carry corporate +action metadata. +""" + +from __future__ import annotations + +import csv +import hashlib +import json +import re +import sqlite3 +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_DAILY_DB_PATH = REPO_ROOT / "_database" / "Stock_Database_ohlcv_1day.db" +DEFAULT_ARTIFACT_ROOT = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_db_summary" + +DAILY_TABLE_RE = re.compile(r"^[AQ][0-9A-Za-z]{6}$") +SYMBOL_RE = re.compile(r"^[0-9A-Za-z]{6}$") +EXPECTED_COLUMNS = [ + "date", + "open", + "high", + "low", + "close", + "volume", + "상장주식수", + "외국인주문한도수량", + "외국인현보유수량", + "외국인현보유비율", + "기관순매수", + "기관누적순매수", +] +CORE_COLUMNS = ["date", "open", "high", "low", "close", "volume"] +PRICE_BASIS = "unknown" +PRICE_BASIS_EVIDENCE = ( + "Daily OHLCV tables include price/volume columns but no explicit adjusted/raw " + "price flag, split factor, dividend, or corporate-action table was found in " + "the daily DB contract; decision-grade return labels must treat adjustment " + "status as unknown until independently verified." +) +ADJUSTMENT_POLICY = ( + "flag_split_like_discontinuities_and_block_decision_grade_metrics_until_price_basis_verified" +) +PRICE_BASIS_STATUS = "UNKNOWN_CONFIRMED" +DECISION_GRADE_RETURN_STATUS = "BLOCKED_UNTIL_PRICE_BASIS_VERIFIED" +PRICE_BASIS_COMPONENT_STATUS = { + "adjusted_price": "not_declared_in_daily_db_schema", + "raw_price": "not_declared_in_daily_db_schema", + "split_adjustment": "not_declared_no_split_factor_or_corporate_action_table", + "dividend_adjustment": "not_declared_no_dividend_or_total_return_field", +} +PRICE_BASIS_BLOCKING_IMPLICATIONS = [ + "decision_grade_return_labels_blocked_until_adjusted_or_raw_basis_is_verified", + "split_like_discontinuities_must_be_flagged_or_excluded_from_model_decision_windows", + "dashboard_must_keep_model_build_allowed_false_until_price_basis_and_gates_pass", +] +PRICE_BASIS_REQUIRED_EVIDENCE = [ + "official_or_vendor_field_declaring_adjusted_or_raw_close", + "split_factor_or_corporate_action_reference_for_split_like_windows", + "dividend_or_total_return_policy_if_returns_claim_dividend_adjustment", + "dated_audit_artifact_showing_rows_windows_and_downstream_blocker_effect", +] +PRICE_BASIS_ALLOWED_USES = [ + "read_only_db_coverage_and_quality_inspection", + "research_feature_preview_with_price_basis_unknown_label", + "split_like_window_discovery_for_future_exclusion_or_manual_review", +] +PRICE_BASIS_BLOCKED_USES = [ + "decision_grade_return_labels", + "model_build_or_candidate_promotion", + "paper_forward_or_live_readiness_claims", +] +PRICE_BASIS_USER_GUIDANCE = [ + { + "section": "D0 summary", + "can_do": "Inspect table count, date coverage, OHLC quality flags, and representative split-like windows.", + "must_not_do": "Treat D0 returns as decision-grade labels while price_basis is unknown.", + "next_action": "Provide independent adjusted/raw and split/dividend policy evidence, or keep the blocker visible.", + }, + { + "section": "D2/D3 downstream", + "can_do": "Build preview datasets and baselines with inherited price-basis warnings.", + "must_not_do": "Freeze or promote baselines without a verified price-basis policy.", + "next_action": "Rerun dataset and baseline verification after price-basis status changes.", + }, + { + "section": "D4-D9 promotion", + "can_do": "Use D4-D9 charts as research diagnostics only.", + "must_not_do": "Set model_build_allowed or paper_forward_allowed from unknown-basis evidence.", + "next_action": "Keep D0_PRICE_BASIS_NOT_VERIFIED in effective gate blockers until D0 is verified.", + }, +] + + + + +@dataclass(frozen=True) +class DailyTableRef: + table: str + code: str + prefix: str + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _db_fingerprint(path: Path) -> str: + stat = path.stat() + payload = f"{path.resolve()}|{stat.st_size}|{int(stat.st_mtime)}" + return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16] + + +def _quote_ident(name: str) -> str: + if not DAILY_TABLE_RE.match(name): + raise ValueError(f"Invalid daily table name: {name!r}") + return '"' + name.replace('"', '""') + '"' + + +def validate_daily_table_name(table: str) -> str: + candidate = str(table or "").strip() + if not DAILY_TABLE_RE.match(candidate): + raise ValueError("Daily OHLCV table names must match ^[AQ][0-9A-Za-z]{6}$") + return candidate + + +def resolve_daily_table(symbol_or_table: str, *, default_prefix: str = "A") -> DailyTableRef: + """Resolve a user supplied symbol/table while preserving leading zeros.""" + + raw = str(symbol_or_table or "").strip() + if DAILY_TABLE_RE.match(raw): + table = raw + elif SYMBOL_RE.match(raw): + if default_prefix not in {"A", "Q"}: + raise ValueError("default_prefix must be A or Q") + table = f"{default_prefix}{raw}" + else: + raise ValueError("Daily OHLCV symbol must be a 6-character code or A/Q-prefixed table") + return DailyTableRef(table=table, code=table[1:], prefix=table[0]) + + +def connect_readonly(db_path: Path | str = DEFAULT_DAILY_DB_PATH) -> sqlite3.Connection: + """Open SQLite in read-only/query-only mode.""" + + path = Path(db_path) + if not path.exists(): + raise FileNotFoundError(path) + uri = f"file:{path.resolve().as_posix()}?mode=ro" + conn = sqlite3.connect(uri, uri=True) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA query_only = ON") + return conn + + +def _list_tables(conn: sqlite3.Connection) -> list[str]: + rows = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name" + ).fetchall() + return [str(row[0]) for row in rows] + + +def list_daily_tables(db_path: Path | str = DEFAULT_DAILY_DB_PATH) -> list[str]: + with connect_readonly(db_path) as conn: + return [name for name in _list_tables(conn) if DAILY_TABLE_RE.match(name)] + + +def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: + rows = conn.execute(f"PRAGMA table_info({_quote_ident(table)})").fetchall() + return [str(row[1]) for row in rows] + + +def _schema_signature(columns: Iterable[str]) -> str: + return hashlib.sha256("|".join(columns).encode("utf-8")).hexdigest()[:16] + + +def _scalar(conn: sqlite3.Connection, query: str) -> Any: + row = conn.execute(query).fetchone() + return row[0] if row else None + + +def _safe_numeric(value: Any) -> float | None: + try: + if value is None: + return None + return float(value) + except (TypeError, ValueError): + return None + +def build_price_basis_audit( + *, + quality_scan_scope: str, + quality_scan_table_count: int, + quality_scan_total_table_count: int, + quality_scan_complete: bool, + representative_windows: list[dict[str, Any]], + split_like_table_count: int, + split_like_window_sample_count: int, + per_table_window_limit: int | None, +) -> dict[str, Any]: + """Return the explicit D0 price-basis verdict used by docs/API/UI.""" + + return { + "status": PRICE_BASIS_STATUS, + "price_basis": PRICE_BASIS, + "decision_grade_return_status": DECISION_GRADE_RETURN_STATUS, + "component_status": dict(PRICE_BASIS_COMPONENT_STATUS), + "evidence": PRICE_BASIS_EVIDENCE, + "adjustment_policy": ADJUSTMENT_POLICY, + "blocking_implications": list(PRICE_BASIS_BLOCKING_IMPLICATIONS), + "required_evidence": list(PRICE_BASIS_REQUIRED_EVIDENCE), + "allowed_uses": list(PRICE_BASIS_ALLOWED_USES), + "blocked_uses": list(PRICE_BASIS_BLOCKED_USES), + "user_guidance": [dict(row) for row in PRICE_BASIS_USER_GUIDANCE], + "quality_scan_scope": quality_scan_scope, + "quality_scan_table_count": quality_scan_table_count, + "quality_scan_total_table_count": quality_scan_total_table_count, + "quality_scan_complete": quality_scan_complete, + "split_like_table_count": split_like_table_count, + "split_like_window_sample_count": split_like_window_sample_count, + "per_table_window_limit": per_table_window_limit, + "representative_windows": representative_windows, + "scan_limitation": ( + "split_like windows are representative evidence capped per table; " + "they confirm unknown adjustment risk but do not prove exact corporate actions" + ), + } + + + +def _sample_rows(conn: sqlite3.Connection, table: str, *, limit: int = 5) -> list[dict[str, Any]]: + safe_limit = max(0, min(int(limit), 200)) + rows = conn.execute( + f"SELECT * FROM {_quote_ident(table)} ORDER BY date DESC LIMIT ?", + (safe_limit,), + ).fetchall() + return [dict(row) for row in rows] + + +def analyze_table_quality( + conn: sqlite3.Connection, + table: str, + *, + discontinuity_limit: int = 20, +) -> dict[str, Any]: + table = validate_daily_table_name(table) + qt = _quote_ident(table) + columns = set(_table_columns(conn, table)) + missing_core = [col for col in CORE_COLUMNS if col not in columns] + if missing_core: + return { + "table": table, + "missing_core_columns": missing_core, + "null_core_rows": None, + "nonpositive_ohlc_rows": None, + "ohlc_inconsistency_rows": None, + "split_like_discontinuity_count": None, + "split_like_windows": [], + "material_unknown_adjustment_windows": [], + "quality_status": "BLOCKED_SCHEMA", + } + + null_core = _scalar( + conn, + f"SELECT COUNT(*) FROM {qt} WHERE date IS NULL OR open IS NULL OR high IS NULL OR low IS NULL OR close IS NULL OR volume IS NULL", + ) + nonpositive_ohlc = _scalar( + conn, + f"SELECT COUNT(*) FROM {qt} WHERE open <= 0 OR high <= 0 OR low <= 0 OR close <= 0", + ) + ohlc_bad = _scalar( + conn, + f"SELECT COUNT(*) FROM {qt} WHERE high < low OR open < low OR open > high OR close < low OR close > high", + ) + + rows = conn.execute( + f"SELECT date, open, close, volume FROM {qt} WHERE close IS NOT NULL AND close > 0 ORDER BY date" + ).fetchall() + windows: list[dict[str, Any]] = [] + prev: sqlite3.Row | None = None + for row in rows: + if prev is not None: + prev_close = _safe_numeric(prev["close"]) + open_price = _safe_numeric(row["open"]) + close_price = _safe_numeric(row["close"]) + if prev_close and prev_close > 0: + open_ratio = (open_price / prev_close) if open_price and open_price > 0 else None + close_ratio = (close_price / prev_close) if close_price and close_price > 0 else None + ratios = [r for r in (open_ratio, close_ratio) if r is not None] + if any(r >= 4.0 or r <= 0.25 for r in ratios): + windows.append( + { + "previous_date": prev["date"], + "date": row["date"], + "previous_close": prev_close, + "open": open_price, + "close": close_price, + "open_to_previous_close_ratio": open_ratio, + "close_to_previous_close_ratio": close_ratio, + "reason": "split_like_price_jump_with_unknown_adjustment_basis", + } + ) + if len(windows) >= discontinuity_limit: + break + prev = row + + quality_status = "PASS" + blockers: list[str] = [] + if null_core: + blockers.append("NULL_CORE_OHLCV") + if nonpositive_ohlc: + blockers.append("NONPOSITIVE_OHLC") + if ohlc_bad: + blockers.append("OHLC_INCONSISTENCY") + if windows: + blockers.append("SPLIT_LIKE_DISCONTINUITY_UNKNOWN_ADJUSTMENT") + if blockers: + quality_status = "WATCH" + + return { + "table": table, + "missing_core_columns": [], + "null_core_rows": int(null_core or 0), + "nonpositive_ohlc_rows": int(nonpositive_ohlc or 0), + "ohlc_inconsistency_rows": int(ohlc_bad or 0), + "split_like_discontinuity_count": len(windows), + "split_like_windows": windows, + "material_unknown_adjustment_windows": windows, + "quality_status": quality_status, + "blockers": blockers, + } + + +def summarize_symbol( + symbol_or_table: str, + *, + db_path: Path | str = DEFAULT_DAILY_DB_PATH, + sample_limit: int = 20, +) -> dict[str, Any]: + ref = resolve_daily_table(symbol_or_table) + with connect_readonly(db_path) as conn: + tables = set(_list_tables(conn)) + if ref.table not in tables: + raise FileNotFoundError(ref.table) + qt = _quote_ident(ref.table) + columns = _table_columns(conn, ref.table) + row = conn.execute(f"SELECT COUNT(*) AS rows, MIN(date) AS first_date, MAX(date) AS last_date FROM {qt}").fetchone() + quality = analyze_table_quality(conn, ref.table) + symbol_windows = [ + {"table": ref.table, "code": ref.code, **window} + for window in quality.get("material_unknown_adjustment_windows", []) + ] + return { + "schema_version": 1, + "table": ref.table, + "code": ref.code, + "prefix": ref.prefix, + "row_count": int(row["rows"] or 0), + "first_date": row["first_date"], + "last_date": row["last_date"], + "columns": columns, + "schema_matches_expected": columns == EXPECTED_COLUMNS, + "price_basis": PRICE_BASIS, + "price_basis_status": PRICE_BASIS_STATUS, + "decision_grade_return_status": DECISION_GRADE_RETURN_STATUS, + "price_basis_evidence": PRICE_BASIS_EVIDENCE, + "price_basis_blocking_implications": list(PRICE_BASIS_BLOCKING_IMPLICATIONS), + "price_basis_required_evidence": list(PRICE_BASIS_REQUIRED_EVIDENCE), + "price_basis_allowed_uses": list(PRICE_BASIS_ALLOWED_USES), + "price_basis_blocked_uses": list(PRICE_BASIS_BLOCKED_USES), + "price_basis_user_guidance": [dict(row) for row in PRICE_BASIS_USER_GUIDANCE], + "adjustment_policy": ADJUSTMENT_POLICY, + "price_basis_audit": build_price_basis_audit( + quality_scan_scope="single_symbol", + quality_scan_table_count=1, + quality_scan_total_table_count=1, + quality_scan_complete=True, + representative_windows=symbol_windows[:10], + split_like_table_count=1 if symbol_windows else 0, + split_like_window_sample_count=len(symbol_windows), + per_table_window_limit=20, + ), + "quality": quality, + "sample_rows_desc": _sample_rows(conn, ref.table, limit=sample_limit), + } + + + +def _table_summary(conn: sqlite3.Connection, table: str, *, include_quality: bool) -> dict[str, Any]: + qt = _quote_ident(table) + row = conn.execute(f"SELECT COUNT(*) AS rows, MIN(date) AS first_date, MAX(date) AS last_date FROM {qt}").fetchone() + columns = _table_columns(conn, table) + first_date = row["first_date"] + last_date = row["last_date"] + summary = { + "table": table, + "code": table[1:], + "prefix": table[0], + "row_count": int(row["rows"] or 0), + "first_date": str(first_date) if first_date is not None else None, + "last_date": str(last_date) if last_date is not None else None, + "schema_signature": _schema_signature(columns), + "schema_matches_expected": columns == EXPECTED_COLUMNS, + } + if include_quality: + q = analyze_table_quality(conn, table, discontinuity_limit=5) + summary.update( + { + "quality_status": q["quality_status"], + "null_core_rows": q["null_core_rows"], + "nonpositive_ohlc_rows": q["nonpositive_ohlc_rows"], + "ohlc_inconsistency_rows": q["ohlc_inconsistency_rows"], + "split_like_discontinuity_count": q["split_like_discontinuity_count"], + "material_unknown_adjustment_windows": q["material_unknown_adjustment_windows"], + } + ) + return summary + + +def summarize_daily_db( + db_path: Path | str = DEFAULT_DAILY_DB_PATH, + *, + table_limit: int | None = None, + quality_table_limit: int = 250, +) -> dict[str, Any]: + """Return a bounded, read-only summary of the daily OHLCV DB. + + ``table_limit`` limits the number of table summaries returned to the caller, + but aggregate counts still cover all valid A/Q daily tables. + """ + + path = Path(db_path) + with connect_readonly(path) as conn: + all_tables = _list_tables(conn) + daily_tables = [name for name in all_tables if DAILY_TABLE_RE.match(name)] + invalid_tables = [name for name in all_tables if name not in daily_tables] + prefix_counts = Counter(name[0] for name in daily_tables) + schema_counts: Counter[str] = Counter() + total_rows = 0 + global_first: str | None = None + global_last: str | None = None + latest_counts: Counter[str] = Counter() + summaries: list[dict[str, Any]] = [] + quality_flags: list[dict[str, Any]] = [] + material_unknown_adjustment_windows: list[dict[str, Any]] = [] + split_like_table_count = 0 + split_like_window_sample_count = 0 + + safe_table_limit = len(daily_tables) if table_limit is None else max(0, min(int(table_limit), len(daily_tables))) + safe_quality_limit = max(0, min(int(quality_table_limit), len(daily_tables))) + + for index, table in enumerate(daily_tables): + include_quality = index < safe_quality_limit + table_summary = _table_summary(conn, table, include_quality=include_quality) + total_rows += int(table_summary["row_count"]) + first = str(table_summary["first_date"]) if table_summary["first_date"] is not None else None + last = str(table_summary["last_date"]) if table_summary["last_date"] is not None else None + if first is not None and (global_first is None or first < global_first): + global_first = first + if last is not None and (global_last is None or last > global_last): + global_last = last + if last is not None: + latest_counts[str(last)] += 1 + schema_counts[str(table_summary["schema_signature"])] += 1 + if index < safe_table_limit: + summaries.append(table_summary) + if include_quality: + table_windows = list(table_summary.get("material_unknown_adjustment_windows") or []) + if table_windows: + split_like_table_count += 1 + split_like_window_sample_count += len(table_windows) + for window in table_windows: + material_unknown_adjustment_windows.append( + { + "table": table, + "code": table[1:], + **window, + } + ) + + for key in ("null_core_rows", "nonpositive_ohlc_rows", "ohlc_inconsistency_rows", "split_like_discontinuity_count"): + value = table_summary.get(key) + if value: + quality_flags.append( + { + "table": table, + "code": table[1:], + "flag": key, + "value": value, + "status": "WATCH", + } + ) + + latest_date = global_last + tables_at_latest = latest_counts.get(latest_date or "", 0) + quality_scan_complete = safe_quality_limit == len(daily_tables) + price_basis_audit = build_price_basis_audit( + quality_scan_scope="all_tables" if quality_scan_complete else "sampled_preview", + quality_scan_table_count=safe_quality_limit, + quality_scan_total_table_count=len(daily_tables), + quality_scan_complete=quality_scan_complete, + representative_windows=material_unknown_adjustment_windows[:10], + split_like_table_count=split_like_table_count, + split_like_window_sample_count=split_like_window_sample_count, + per_table_window_limit=5, + ) + + return { + "schema_version": 1, + "generated_at": _utc_now(), + "db_path": str(path), + "db_fingerprint": _db_fingerprint(path), + "read_only": True, + "query_only": True, + "guardrail": "Research-only daily OHLCV DB inspection; no DB mutation, no live/broker/orders, no profit claim.", + "daily_table_regex": DAILY_TABLE_RE.pattern, + "table_count": len(daily_tables), + "non_daily_table_count": len(invalid_tables), + "prefix_counts": dict(prefix_counts), + "total_rows": total_rows, + "first_date": global_first, + "last_date": global_last, + "latest_date": latest_date, + "tables_at_latest_date": tables_at_latest, + "latest_coverage_fraction": (tables_at_latest / len(daily_tables)) if daily_tables else 0.0, + "expected_columns": EXPECTED_COLUMNS, + "schema_signatures": dict(schema_counts), + "price_basis": PRICE_BASIS, + "price_basis_evidence": PRICE_BASIS_EVIDENCE, + "adjustment_policy": ADJUSTMENT_POLICY, + "price_basis_status": PRICE_BASIS_STATUS, + "decision_grade_return_status": DECISION_GRADE_RETURN_STATUS, + "price_basis_blocking_implications": list(PRICE_BASIS_BLOCKING_IMPLICATIONS), + "price_basis_required_evidence": list(PRICE_BASIS_REQUIRED_EVIDENCE), + "price_basis_allowed_uses": list(PRICE_BASIS_ALLOWED_USES), + "price_basis_blocked_uses": list(PRICE_BASIS_BLOCKED_USES), + "price_basis_user_guidance": [dict(row) for row in PRICE_BASIS_USER_GUIDANCE], + "price_basis_audit": price_basis_audit, + "quality_scan_scope": "all_tables" if quality_scan_complete else "sampled_preview", + "quality_scan_table_count": safe_quality_limit, + "quality_scan_total_table_count": len(daily_tables), + "quality_scan_complete": quality_scan_complete, + "quality_sample_table_count": safe_quality_limit, + "table_summaries_returned": len(summaries), + "table_summaries": summaries, + "quality_flags": quality_flags[:500], + "material_unknown_adjustment_windows": material_unknown_adjustment_windows[:100], + "decision_grade_status": ( + "WATCH_PRICE_BASIS_UNKNOWN_CONFIRMED" + if quality_scan_complete + else "WATCH_PRICE_BASIS_UNKNOWN_CONFIRMED_QUALITY_SAMPLED" + ), + } + + +def write_db_summary_artifacts( + summary: dict[str, Any], + *, + artifact_root: Path | str | None = None, + run_id: str | None = None, +) -> dict[str, Any]: + """Write generated dashboard artifacts from an already computed summary.""" + + root = Path(artifact_root or DEFAULT_ARTIFACT_ROOT).resolve() + default_root = DEFAULT_ARTIFACT_ROOT.resolve() + try: + root.relative_to(default_root) + except ValueError: + if root != default_root: + raise ValueError("Daily OHLCV DB artifacts must stay under webui/rl_runs/daily_ohlcv_db_summary") + rid = run_id or f"summary_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}" + if not re.match(r"^[0-9A-Za-z_.-]+$", rid) or rid in {".", ".."} or ".." in rid.split("."): + raise ValueError("run_id contains unsafe characters") + out_dir = (root / rid).resolve() + try: + out_dir.relative_to(root) + except ValueError as exc: + raise ValueError("run_id escapes daily OHLCV artifact root") from exc + out_dir.mkdir(parents=True, exist_ok=True) + + summary_path = out_dir / "db_summary.json" + table_path = out_dir / "table_summaries.csv" + quality_path = out_dir / "quality_flags.csv" + price_basis_path = out_dir / "price_basis_audit.json" + price_basis_windows_path = out_dir / "price_basis_windows.csv" + summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8") + + table_rows = list(summary.get("table_summaries") or []) + if table_rows: + fields = sorted({key for row in table_rows for key in row.keys()}) + with table_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(table_rows) + else: + table_path.write_text("table\n", encoding="utf-8") + + quality_rows = list(summary.get("quality_flags") or []) + if quality_rows: + fields = sorted({key for row in quality_rows for key in row.keys()}) + with quality_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(quality_rows) + else: + quality_path.write_text("table,code,flag,value,status\n", encoding="utf-8") + price_basis_path.write_text( + json.dumps(summary.get("price_basis_audit") or {}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + price_basis_rows = list(summary.get("material_unknown_adjustment_windows") or []) + if price_basis_rows: + fields = sorted({key for row in price_basis_rows for key in row.keys()}) + with price_basis_windows_path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(price_basis_rows) + else: + price_basis_windows_path.write_text( + "table,code,previous_date,date,previous_close,open,close,open_to_previous_close_ratio,close_to_previous_close_ratio,reason\n", + encoding="utf-8", + ) + + + return { + "run_id": rid, + "artifact_dir": str(out_dir), + "db_summary_path": str(summary_path), + "table_summaries_path": str(table_path), + "quality_flags_path": str(quality_path), + "price_basis_audit_path": str(price_basis_path), + "price_basis_windows_path": str(price_basis_windows_path), + } + + +__all__ = [ + "ADJUSTMENT_POLICY", + "DAILY_TABLE_RE", + "DEFAULT_ARTIFACT_ROOT", + "DEFAULT_DAILY_DB_PATH", + "EXPECTED_COLUMNS", + "PRICE_BASIS", + "PRICE_BASIS_EVIDENCE", + "PRICE_BASIS_STATUS", + "DECISION_GRADE_RETURN_STATUS", + "analyze_table_quality", + "build_price_basis_audit", + "connect_readonly", + "list_daily_tables", + "resolve_daily_table", + "summarize_daily_db", + "summarize_symbol", + "validate_daily_table_name", + "write_db_summary_artifacts", +] diff --git a/stom_rl/daily_ohlcv_universe.py b/stom_rl/daily_ohlcv_universe.py new file mode 100644 index 000000000..64742dc86 --- /dev/null +++ b/stom_rl/daily_ohlcv_universe.py @@ -0,0 +1,690 @@ +"""Daily OHLCV universe classification and manifest helpers. + +This module creates a conservative research universe from local daily OHLCV table +names plus the read-only STOM ``stockinfo`` metadata. It intentionally excludes +products and uncertain symbols by default; inclusion is heuristic WATCH evidence, +not a tradable/live-ready claim. +""" + +from __future__ import annotations + +import csv +import hashlib +import json +import re +import sqlite3 +from collections import Counter +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .daily_ohlcv_db import ( + DEFAULT_DAILY_DB_PATH, + REPO_ROOT, + list_daily_tables, + validate_daily_table_name, +) + +DEFAULT_STOCKINFO_DB_PATH = REPO_ROOT / "_database" / "stock_tick_back.db" +DEFAULT_UNIVERSE_ROOT = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_universe" +DEFAULT_OFFICIAL_METADATA_PATH = REPO_ROOT / "_database" / "krx_listed_products.csv" +OFFICIAL_METADATA_REQUIRED_COLUMNS = ("code", "name", "market", "instrument_type") +OFFICIAL_COMMON_TYPES = {"common_equity", "common", "stock", "ordinary_share"} +OFFICIAL_EXCLUDED_TYPES = { + "preferred": "OFFICIAL_PREFERRED_SHARE_EXCLUDED", + "preferred_stock": "OFFICIAL_PREFERRED_SHARE_EXCLUDED", + "etf": "OFFICIAL_ETF_ETN_FUND_EXCLUDED", + "etn": "OFFICIAL_ETF_ETN_FUND_EXCLUDED", + "fund": "OFFICIAL_ETF_ETN_FUND_EXCLUDED", + "fund_or_etf": "OFFICIAL_ETF_ETN_FUND_EXCLUDED", + "spac": "OFFICIAL_SPAC_EXCLUDED", + "reit": "OFFICIAL_REIT_EXCLUDED", +} +UNIVERSE_REQUIRED_EVIDENCE = ( + "official_or_manual_krx_csv_with_code_name_market_instrument_type", + "six_character_string_codes_preserving_leading_zeros", + "kospi_kosdaq_common_equity_instrument_type_review", + "quarantine_artifact_for_unmatched_or_excluded_symbols", + "dated_manifest_with_metadata_sha_and_review_status", +) +UNIVERSE_ALLOWED_USES_WHEN_WATCH = ( + "research_universe_preview", + "exclusion_reason_review", + "quarantine_backlog_triage", + "dashboard_evidence_navigation", +) +UNIVERSE_ALLOWED_USES_WHEN_VERIFIED = ( + "research_universe_preview", + "dataset_candidate_filtering_after_d0_price_basis_check", + "baseline_research_input_after_split_and_cost_controls", + "dashboard_evidence_navigation", +) +UNIVERSE_BLOCKED_USES_WHEN_WATCH = ( + "model_build_or_candidate_promotion", + "paper_forward_or_live_readiness_claims", + "official_common_equity_certification_claims", +) +UNIVERSE_BLOCKED_USES_WHEN_VERIFIED = ( + "live_broker_order_readiness_claims", + "profitability_claims", +) +UNIVERSE_USER_GUIDANCE = ( + { + "section": "D1 summary", + "meaning": "WATCH_HEURISTIC_UNIVERSE means stockinfo/name-prefix rules are only a research preview.", + "action": "Use inclusion/exclusion counts and quarantine artifacts to review the universe, not to promote a model.", + }, + { + "section": "Official/manual CSV", + "meaning": "D1 can clear only when the KRX/manual CSV contract covers every daily table with leading-zero codes preserved.", + "action": "Provide code,name,market,instrument_type[,source] evidence outside the local DB and rerun the manifest.", + }, + { + "section": "Blocked promotion", + "meaning": "Missing or partial official/manual evidence blocks model_build_allowed regardless of favorable D3/D5 artifacts.", + "action": "Keep D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED visible until coverage is complete.", + }, +) + + +PRODUCT_PREFIXES = ( + "KODEX", + "TIGER", + "RISE", + "KBSTAR", + "ACE", + "SOL", + "HANARO", + "ARIRANG", + "KOSEF", + "TIMEFOLIO", + "TREX", + "FOCUS", + "마이다스", +) +PRODUCT_TOKENS = ( + " ETF", + " ETN", + "ETF", + "ETN", + "인버스", + "레버리지", + "선물", + "채권", + "국고채", + "회사채", + "커버드콜", + "액티브", + "나스닥", + "S&P", + "MSCI", + "TOP10", + "TOP5", +) +PREFERRED_RE = re.compile(r"(?:\d+우[BC]?|우[BC]?)$") + + +@dataclass(frozen=True) +class StockInfoRecord: + code: str + name: str + kosdaq: int | None +@dataclass(frozen=True) +class OfficialMetadataRecord: + code: str + name: str + market: str + instrument_type: str + source: str + + + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _metadata_sha(payload: dict[str, Any]) -> str: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def _connect_stockinfo_readonly(path: Path | str = DEFAULT_STOCKINFO_DB_PATH) -> sqlite3.Connection: + db_path = Path(path) + if not db_path.exists(): + raise FileNotFoundError(db_path) + uri = f"file:{db_path.resolve().as_posix()}?mode=ro" + conn = sqlite3.connect(uri, uri=True) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA query_only = ON") + return conn + + +def load_stockinfo(stockinfo_db_path: Path | str = DEFAULT_STOCKINFO_DB_PATH) -> dict[str, StockInfoRecord]: + with _connect_stockinfo_readonly(stockinfo_db_path) as conn: + rows = conn.execute('SELECT "index", "종목명", "코스닥" FROM stockinfo').fetchall() + records: dict[str, StockInfoRecord] = {} + for row in rows: + code = str(row["index"]).zfill(6) + name = "" if row["종목명"] is None else str(row["종목명"]) + kosdaq_raw = row["코스닥"] + try: + kosdaq = int(kosdaq_raw) if kosdaq_raw is not None else None + except (TypeError, ValueError): + kosdaq = None + records[code] = StockInfoRecord(code=code, name=name, kosdaq=kosdaq) + return records +def load_official_metadata_csv(path: Path | str) -> dict[str, OfficialMetadataRecord]: + """Load a KRX/manual listed-product CSV contract without mutating source DBs.""" + + metadata_path = Path(path) + if not metadata_path.exists(): + raise FileNotFoundError(metadata_path) + records: dict[str, OfficialMetadataRecord] = {} + with metadata_path.open("r", encoding="utf-8-sig", newline="") as handle: + reader = csv.DictReader(handle) + fieldnames = set(reader.fieldnames or []) + missing = [field for field in OFFICIAL_METADATA_REQUIRED_COLUMNS if field not in fieldnames] + if missing: + raise ValueError(f"official metadata missing required columns: {missing}") + for row in reader: + raw_code = str(row.get("code", "")).strip() + if not raw_code: + continue + if not re.fullmatch(r"[0-9A-Za-z]{6}", raw_code): + raise ValueError(f"official metadata code must be a 6-character string: {raw_code!r}") + code = raw_code.zfill(6) if raw_code.isdigit() else raw_code + market = str(row.get("market", "")).strip().upper() + instrument_type = str(row.get("instrument_type", "")).strip().lower() + name = str(row.get("name", "")).strip() + if not name: + raise ValueError(f"official metadata name is required for {code}") + if market not in {"KOSPI", "KOSDAQ", "KONEX", "UNKNOWN", "PRODUCT_Q"}: + raise ValueError(f"official metadata market is unsupported for {code}: {market!r}") + if not instrument_type: + raise ValueError(f"official metadata instrument_type is required for {code}") + if code in records: + raise ValueError(f"official metadata duplicate code: {code}") + records[code] = OfficialMetadataRecord( + code=code, + name=name, + market=market, + instrument_type=instrument_type, + source=str(row.get("source", "official_or_manual_csv")).strip() or "official_or_manual_csv", + ) + return records + + +def _universe_review_contract( + *, + official_records: dict[str, OfficialMetadataRecord] | None, + table_count: int, + official_matched_table_count: int, +) -> dict[str, Any]: + if official_records is None: + return { + "verdict": "WATCH_HEURISTIC_UNIVERSE", + "review_status": "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW", + "official_metadata_status": "MISSING", + "official_metadata_coverage_status": "MISSING", + "universe_certification_status": "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW", + "universe_allowed_uses": list(UNIVERSE_ALLOWED_USES_WHEN_WATCH), + "universe_blocked_uses": list(UNIVERSE_BLOCKED_USES_WHEN_WATCH), + } + if table_count > 0 and official_matched_table_count == table_count: + return { + "verdict": "OFFICIAL_OR_MANUAL_REVIEWED", + "review_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "official_metadata_status": "OFFICIAL_VERIFIED", + "official_metadata_coverage_status": "COMPLETE", + "universe_certification_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "universe_allowed_uses": list(UNIVERSE_ALLOWED_USES_WHEN_VERIFIED), + "universe_blocked_uses": list(UNIVERSE_BLOCKED_USES_WHEN_VERIFIED), + } + return { + "verdict": "WATCH_HEURISTIC_UNIVERSE", + "review_status": "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW", + "official_metadata_status": "LOADED", + "official_metadata_coverage_status": "PARTIAL", + "universe_certification_status": "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW", + "universe_allowed_uses": list(UNIVERSE_ALLOWED_USES_WHEN_WATCH), + "universe_blocked_uses": list(UNIVERSE_BLOCKED_USES_WHEN_WATCH), + } + + +def _official_metadata_summary(path: Path, records: dict[str, OfficialMetadataRecord] | None) -> dict[str, Any]: + if records is None: + return { + "status": "MISSING", + "path": str(path), + "available": False, + "used": False, + "required_columns": list(OFFICIAL_METADATA_REQUIRED_COLUMNS), + "review_status": "WATCH_OFFICIAL_METADATA_REQUIRED", + "ingestion_contract": "CSV columns: code,name,market,instrument_type[,source]; codes remain strings with leading zeros.", + } + return { + "status": "LOADED", + "path": str(path), + "available": True, + "used": True, + "record_count": len(records), + "required_columns": list(OFFICIAL_METADATA_REQUIRED_COLUMNS), + "review_status": "OFFICIAL_OR_MANUAL_METADATA_PRESENT", + "metadata_sha": _metadata_sha( + { + "records": [ + { + "code": row.code, + "name": row.name, + "market": row.market, + "instrument_type": row.instrument_type, + "source": row.source, + } + for row in records.values() + ] + } + ), + } + + +def _classify_with_official_metadata( + table: str, + record: OfficialMetadataRecord | None, +) -> dict[str, Any] | None: + code = table[1:] + prefix = table[0] + if record is None: + return None + normalized_type = record.instrument_type.strip().lower() + if prefix == "Q": + include = False + instrument_type = normalized_type or "q_product" + exclusion_reason = "Q_PRODUCT_TABLE" + market = "PRODUCT_Q" + elif normalized_type in OFFICIAL_COMMON_TYPES and record.market in {"KOSPI", "KOSDAQ"}: + include = True + instrument_type = "common_equity" + exclusion_reason = None + market = record.market + else: + include = False + instrument_type = normalized_type or "unknown_official_type" + exclusion_reason = OFFICIAL_EXCLUDED_TYPES.get(normalized_type, "OFFICIAL_NON_COMMON_OR_UNSUPPORTED_MARKET_EXCLUDED") + market = record.market if record.market in {"KOSPI", "KOSDAQ", "KONEX", "UNKNOWN"} else "UNKNOWN" + payload = { + "table": table, + "code": code, + "name": record.name, + "market": market, + "instrument_type": instrument_type, + "include": include, + "exclusion_reason": exclusion_reason, + "classification_source": "official_metadata_csv", + "classification_confidence": 1.0, + "official_metadata_status": "matched", + "official_metadata_source": record.source, + "review_status": "official_metadata_reviewed" if include else "official_metadata_excluded", + } + payload["metadata_sha"] = _metadata_sha(payload) + return payload + + + + +def _detect_instrument_type(name: str, code: str, prefix: str) -> tuple[str, str | None, float, str]: + normalized = name.strip() + upper = normalized.upper() + if prefix == "Q": + return "q_product", "Q_PRODUCT_TABLE", 0.99, "table_prefix" + if not code.isdigit(): + return "unknown_alphanumeric", "ALPHANUMERIC_CODE_UNREVIEWED", 0.8, "table_code" + if not normalized: + return "unknown", "METADATA_NAME_MISSING", 0.0, "stockinfo_missing_name" + if any(upper.startswith(token) for token in PRODUCT_PREFIXES): + return "fund_or_etf", "ETF_ETN_FUND_NAME_PREFIX", 0.97, "stockinfo_name_prefix" + if any(token in upper for token in PRODUCT_TOKENS): + return "fund_or_etf", "ETF_ETN_FUND_NAME_TOKEN", 0.93, "stockinfo_name_token" + if "스팩" in normalized or "SPAC" in upper: + return "spac", "SPAC_EXCLUDED", 0.96, "stockinfo_name_token" + if ("리츠" in normalized and not normalized.startswith("메리츠")) or upper.endswith("REIT"): + return "reit", "REIT_EXCLUDED", 0.9, "stockinfo_name_token" + if PREFERRED_RE.search(normalized): + return "preferred", "PREFERRED_SHARE_EXCLUDED", 0.92, "stockinfo_name_suffix" + return "common_equity", None, 0.85, "stockinfo_name_market_heuristic" + + +def classify_daily_table( + table: str, + stockinfo: dict[str, StockInfoRecord], + official_metadata: dict[str, OfficialMetadataRecord] | None = None, +) -> dict[str, Any]: + table = validate_daily_table_name(table) + code = table[1:] + prefix = table[0] + official_payload = _classify_with_official_metadata(table, official_metadata.get(code) if official_metadata is not None else None) + if official_payload is not None: + return official_payload + if prefix == "Q": + record = stockinfo.get(code) + payload = { + "table": table, + "code": code, + "name": record.name if record is not None else None, + "market": "PRODUCT_Q", + "stockinfo_kosdaq": record.kosdaq if record is not None else None, + "instrument_type": "q_product", + "include": False, + "exclusion_reason": "Q_PRODUCT_TABLE", + "classification_source": "table_prefix", + "classification_confidence": 0.99, + "official_metadata_status": "not_used", + "review_status": "excluded_by_default", + } + payload["metadata_sha"] = _metadata_sha(payload) + return payload + if not code.isdigit(): + payload = { + "table": table, + "code": code, + "name": None, + "market": "UNKNOWN", + "instrument_type": "unknown_alphanumeric", + "include": False, + "exclusion_reason": "ALPHANUMERIC_CODE_UNREVIEWED", + "classification_source": "table_code", + "classification_confidence": 0.8, + "official_metadata_status": "not_used", + "review_status": "excluded_by_default", + } + payload["metadata_sha"] = _metadata_sha(payload) + return payload + record = stockinfo.get(code) + if record is None: + payload = { + "table": table, + "code": code, + "name": None, + "market": "UNKNOWN", + "instrument_type": "unknown_unmatched", + "include": False, + "exclusion_reason": "METADATA_UNMATCHED", + "classification_source": "daily_table_without_stockinfo_match", + "classification_confidence": 0.0, + "official_metadata_status": "not_used", + "review_status": "quarantined_unmatched", + } + payload["metadata_sha"] = _metadata_sha(payload) + return payload + if record.kosdaq not in {0, 1}: + payload = { + "table": table, + "code": code, + "name": record.name, + "market": "UNKNOWN", + "stockinfo_kosdaq": record.kosdaq, + "instrument_type": "unknown_market", + "include": False, + "exclusion_reason": "UNKNOWN_MARKET_METADATA", + "classification_source": "stockinfo_market_missing_or_invalid", + "classification_confidence": 0.0, + "official_metadata_status": "not_used", + "review_status": "quarantined_unknown_market", + } + payload["metadata_sha"] = _metadata_sha(payload) + return payload + + instrument_type, exclusion_reason, confidence, source = _detect_instrument_type(record.name, code, prefix) + include = instrument_type == "common_equity" + market = "KOSDAQ" if record.kosdaq == 1 else "KOSPI" + review_status = "heuristic_watch" if include else "excluded_by_default" + if include: + exclusion_reason = None + payload = { + "table": table, + "code": code, + "name": record.name, + "market": market, + "stockinfo_kosdaq": record.kosdaq, + "instrument_type": instrument_type, + "include": include, + "exclusion_reason": exclusion_reason, + "classification_source": source, + "classification_confidence": confidence, + "review_status": review_status, + "official_metadata_status": "not_available", + } + payload["metadata_sha"] = _metadata_sha(payload) + return payload + + +def build_universe_manifest( + daily_db_path: Path | str = DEFAULT_DAILY_DB_PATH, + stockinfo_db_path: Path | str = DEFAULT_STOCKINFO_DB_PATH, + *, + official_metadata_path: Path | str | None = DEFAULT_OFFICIAL_METADATA_PATH, + table_limit: int | None = None, +) -> dict[str, Any]: + tables = list_daily_tables(daily_db_path) + if table_limit is not None: + safe_limit = max(0, min(int(table_limit), len(tables))) + tables = tables[:safe_limit] + stockinfo = load_stockinfo(stockinfo_db_path) + official_path = Path(official_metadata_path) if official_metadata_path is not None else DEFAULT_OFFICIAL_METADATA_PATH + official_records: dict[str, OfficialMetadataRecord] | None = None + if official_metadata_path is not None and official_path.exists(): + official_records = load_official_metadata_csv(official_path) + symbols = [classify_daily_table(table, stockinfo, official_records) for table in tables] + include_count = sum(1 for row in symbols if row["include"]) + exclusions = [row for row in symbols if not row["include"]] + by_type = Counter(row["instrument_type"] for row in symbols) + by_market = Counter(row["market"] for row in symbols) + by_reason = Counter(row["exclusion_reason"] or "INCLUDED" for row in symbols) + stockinfo_matched_table_count = sum(1 for table in tables if table[1:] in stockinfo) + stockinfo_unmatched_table_count = len(tables) - stockinfo_matched_table_count + official_summary = _official_metadata_summary(official_path, official_records) + official_matched_table_count = sum(1 for row in symbols if row.get("official_metadata_status") == "matched") + official_unmatched_table_count = len(tables) - official_matched_table_count if official_records is not None else len(tables) + official_unmatched_quarantine_count = sum( + 1 + for row in symbols + if official_records is not None + and row.get("official_metadata_status") != "matched" + and not row["include"] + ) + universe_review = _universe_review_contract( + official_records=official_records, + table_count=len(tables), + official_matched_table_count=official_matched_table_count, + ) + official_summary["status"] = universe_review["official_metadata_status"] + official_summary["coverage_status"] = universe_review["official_metadata_coverage_status"] + official_summary["review_status"] = ( + "OFFICIAL_OR_MANUAL_REVIEWED" + if universe_review["official_metadata_coverage_status"] == "COMPLETE" + else official_summary.get("review_status", "WATCH_OFFICIAL_METADATA_REQUIRED") + ) + official_summary["certification_status"] = universe_review["universe_certification_status"] + uncertain_quarantine_reasons = { + "ALPHANUMERIC_CODE_UNREVIEWED", + "METADATA_UNMATCHED", + "UNKNOWN_MARKET_METADATA", + } + quarantine_artifact_count = sum( + 1 + for row in symbols + if not row.get("include") + and ( + row.get("exclusion_reason") in uncertain_quarantine_reasons + or str(row.get("review_status", "")).startswith("quarantined") + ) + ) + manifest = { + "schema_version": 1, + "generated_at": _utc_now(), + "daily_db_path": str(Path(daily_db_path)), + "stockinfo_db_path": str(Path(stockinfo_db_path)), + "guardrail": "Research-only universe classification; no live/broker/orders, no profit claim. Inclusion is heuristic WATCH until official/audited metadata review.", + "verdict": universe_review["verdict"], + "official_metadata": official_summary, + "official_metadata_status": universe_review["official_metadata_status"], + "official_metadata_coverage_status": universe_review["official_metadata_coverage_status"], + "official_metadata_path": str(official_path), + "official_metadata_required_columns": list(OFFICIAL_METADATA_REQUIRED_COLUMNS), + "official_metadata_matched_table_count": official_matched_table_count, + "official_metadata_unmatched_table_count": official_unmatched_table_count, + "official_metadata_unmatched_quarantine_count": official_unmatched_quarantine_count, + "review_status": universe_review["review_status"], + "universe_review_status": universe_review["review_status"], + "universe_certification_status": universe_review["universe_certification_status"], + "universe_required_evidence": list(UNIVERSE_REQUIRED_EVIDENCE), + "universe_allowed_uses": universe_review["universe_allowed_uses"], + "universe_blocked_uses": universe_review["universe_blocked_uses"], + "universe_user_guidance": [dict(row) for row in UNIVERSE_USER_GUIDANCE], + "table_count": len(tables), + "stockinfo_count": len(stockinfo), + "stockinfo_matched_table_count": stockinfo_matched_table_count, + "stockinfo_unmatched_table_count": stockinfo_unmatched_table_count, + "unmatched_quarantine_count": stockinfo_unmatched_table_count, + "quarantine_artifact_count": quarantine_artifact_count, + "include_count": include_count, + "exclude_count": len(exclusions), + "unmatched_count": stockinfo_unmatched_table_count, + "metadata_unmatched_count": by_reason.get("METADATA_UNMATCHED", 0), + "q_product_count": by_reason.get("Q_PRODUCT_TABLE", 0), + "counts_by_type": dict(by_type), + "counts_by_market": dict(by_market), + "counts_by_exclusion_reason": dict(by_reason), + "required_fields": [ + "classification_source", + "classification_confidence", + "exclusion_reason", + "metadata_sha", + "review_status", + "official_metadata_status", + "official_metadata_coverage_status", + "universe_certification_status", + ], + "symbols": symbols, + "exclusions": exclusions, + } + manifest["manifest_sha"] = _metadata_sha( + { + "schema_version": manifest["schema_version"], + "symbols": [row["metadata_sha"] for row in symbols], + "verdict": manifest["verdict"], + } + ) + return manifest + + +def write_universe_artifacts( + manifest: dict[str, Any], + *, + artifact_root: Path | str | None = None, + run_id: str | None = None, +) -> dict[str, Any]: + root = Path(artifact_root or DEFAULT_UNIVERSE_ROOT).resolve() + default_root = DEFAULT_UNIVERSE_ROOT.resolve() + try: + root.relative_to(default_root) + except ValueError: + if root != default_root: + raise ValueError("Daily OHLCV universe artifacts must stay under webui/rl_runs/daily_ohlcv_universe") + rid = run_id or f"universe_{datetime.now(timezone.utc).strftime('%Y%m%d')}" + if not re.match(r"^[0-9A-Za-z_.-]+$", rid) or rid in {".", ".."} or ".." in rid.split("."): + raise ValueError("run_id contains unsafe characters") + out_dir = (root / rid).resolve() + try: + out_dir.relative_to(root) + except ValueError as exc: + raise ValueError("run_id escapes daily OHLCV universe artifact root") from exc + out_dir.mkdir(parents=True, exist_ok=True) + + universe_path = out_dir / "universe.json" + symbols_path = out_dir / "symbols.csv" + exclusions_path = out_dir / "exclusions.csv" + official_audit_path = out_dir / "official_metadata_audit.json" + quarantine_path = out_dir / "quarantine.csv" + universe_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + + _write_rows_csv(symbols_path, manifest.get("symbols") or []) + _write_rows_csv(exclusions_path, manifest.get("exclusions") or []) + uncertain_quarantine_reasons = { + "ALPHANUMERIC_CODE_UNREVIEWED", + "METADATA_UNMATCHED", + "UNKNOWN_MARKET_METADATA", + } + _write_rows_csv(quarantine_path, [ + row + for row in manifest.get("symbols") or [] + if not row.get("include") and ( + row.get("exclusion_reason") in uncertain_quarantine_reasons + or str(row.get("review_status", "")).startswith("quarantined") + ) + ]) + official_audit_path.write_text( + json.dumps( + { + "official_metadata": manifest.get("official_metadata"), + "official_metadata_status": manifest.get("official_metadata_status"), + "official_metadata_matched_table_count": manifest.get("official_metadata_matched_table_count"), + "official_metadata_unmatched_table_count": manifest.get("official_metadata_unmatched_table_count"), + "official_metadata_unmatched_quarantine_count": manifest.get("official_metadata_unmatched_quarantine_count"), + "official_metadata_coverage_status": manifest.get("official_metadata_coverage_status"), + "universe_certification_status": manifest.get("universe_certification_status"), + "universe_required_evidence": manifest.get("universe_required_evidence"), + "universe_allowed_uses": manifest.get("universe_allowed_uses"), + "universe_blocked_uses": manifest.get("universe_blocked_uses"), + "quarantine_artifact_count": manifest.get("quarantine_artifact_count"), + "verdict": manifest.get("verdict"), + "review_status": manifest.get("review_status"), + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + return { + "run_id": rid, + "artifact_dir": str(out_dir), + "universe_path": str(universe_path), + "symbols_path": str(symbols_path), + "exclusions_path": str(exclusions_path), + "official_metadata_audit_path": str(official_audit_path), + "quarantine_path": str(quarantine_path), + "manifest_sha": manifest.get("manifest_sha"), + } + + +def _write_rows_csv(path: Path, rows: list[dict[str, Any]]) -> None: + if rows: + fields = sorted({key for row in rows for key in row.keys()}) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + else: + path.write_text("table,code,name,market,instrument_type,include,exclusion_reason\n", encoding="utf-8") + + +__all__ = [ + "DEFAULT_STOCKINFO_DB_PATH", + "DEFAULT_UNIVERSE_ROOT", + "DEFAULT_OFFICIAL_METADATA_PATH", + "OFFICIAL_METADATA_REQUIRED_COLUMNS", + "UNIVERSE_REQUIRED_EVIDENCE", + "UNIVERSE_ALLOWED_USES_WHEN_WATCH", + "UNIVERSE_ALLOWED_USES_WHEN_VERIFIED", + "UNIVERSE_BLOCKED_USES_WHEN_WATCH", + "UNIVERSE_BLOCKED_USES_WHEN_VERIFIED", + "UNIVERSE_USER_GUIDANCE", + "OfficialMetadataRecord", + "StockInfoRecord", + "build_universe_manifest", + "classify_daily_table", + "load_stockinfo", + "load_official_metadata_csv", + "write_universe_artifacts", +] diff --git a/stom_rl/daily_portfolio_env.py b/stom_rl/daily_portfolio_env.py new file mode 100644 index 000000000..8f7cea58d --- /dev/null +++ b/stom_rl/daily_portfolio_env.py @@ -0,0 +1,928 @@ +"""Constrained daily portfolio environment for research-only D4 experiments.""" + +from __future__ import annotations + +import csv +import json +import math +import re +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +ACTION_NAMES = { + 0: "hold", + 1: "buy", + 2: "add", + 3: "sell", + 4: "reduce", +} +ROUND_TRIP_COST_RATE = 23 / 10_000.0 +ROUND_TRIP_COST_BP = 23 +FILL_ASSUMPTION = "close_to_next_close_research_label; no live/broker/order fill is inferred from daily OHLCV" +DEFAULT_ENV_INSPECTION_ROOT = Path("webui") / "rl_runs" / "daily_ohlcv_portfolio_env" +ENV_INSPECTION_SCHEMA_VERSION = 1 +OBSERVATION_MANIFEST_SCHEMA_VERSION = 2 +OBSERVATION_MODE_V1 = "v1" +OBSERVATION_MODE_ACTION_INDUCTION_V2 = "action_induction_v2" +VALID_OBSERVATION_MODES = {OBSERVATION_MODE_V1, OBSERVATION_MODE_ACTION_INDUCTION_V2} +OBSERVATION_STATE_FIELDS = ["position_count", "top_score_bucket"] +ACTION_INDUCTION_V2_STATE_FIELDS = [ + "position_count", + "top_score_bucket", + "score_margin_bucket", + "candidate_count_bucket", + "recent_score_volatility_bucket", + "d3_confidence_bucket", +] +REQUIRED_OBSERVATION_MANIFEST_SECTIONS = [ + "feature_timing", + "holdings_identity", + "cash_exposure", + "candidate_rank_score_features", + "horizon_alignment", + "action_mask_semantics", + "leakage_checks", + "frozen_d3_comparison", +] +FROZEN_D3_COMPARISON_REQUIREMENTS = [ + "no_trade_cash", + "shuffle_control", + "equal_weight_topk_momentum", + "vol_adjusted_momentum", + "supervised_linear_ranker", + "supervised_direction_classifier", +] +FUTURE_LABEL_COLUMNS = ["future_return", "future_return_1d"] +REQUIRED_LEAKAGE_CHECKS = [ + "future_return_1d_excluded_from_state", + "future_return_1d_excluded_from_action_mask", + "future_label_availability_not_candidate_filter", + "reward_label_post_action_only", + "leading_zero_code_identity", +] +SAFE_RUN_RE = re.compile(r"^[0-9A-Za-z_.-]+$") + + +def _safe_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + result = float(value) + if math.isnan(result) or math.isinf(result): + return None + return result + except (TypeError, ValueError): + return None +def _bucket_by_threshold(value: float | None, thresholds: tuple[float, ...]) -> int: + if value is None: + return 0 + clean = abs(float(value)) + for index, threshold in enumerate(thresholds, start=1): + if clean < threshold: + return index + return len(thresholds) + 1 + + +def _score_sign_bucket(score: float | None) -> int: + if score is None: + return 0 + return 1 if score > 0 else -1 if score < 0 else 0 + + +def _validate_observation_mode(observation_mode: str) -> str: + mode = str(observation_mode or OBSERVATION_MODE_V1) + if mode not in VALID_OBSERVATION_MODES: + raise ValueError(f"Unsupported observation_mode: {mode}") + return mode + + +def _observation_fields_for_mode(observation_mode: str) -> list[str]: + mode = _validate_observation_mode(observation_mode) + if mode == OBSERVATION_MODE_ACTION_INDUCTION_V2: + return list(ACTION_INDUCTION_V2_STATE_FIELDS) + return list(OBSERVATION_STATE_FIELDS) + + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _validate_run_id(run_id: str) -> str: + rid = str(run_id or "").strip() + if not SAFE_RUN_RE.match(rid) or rid in {".", ".."} or "/" in rid or "\\" in rid: + raise ValueError("run_id contains unsafe characters") + return rid + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _write_csv(path: Path, rows: list[dict[str, Any]], fallback_fields: list[str]) -> None: + if rows: + field_set = {key for row in rows for key in row.keys()} + fields = [field for field in fallback_fields if field in field_set] + fields.extend(sorted(field_set - set(fields))) + else: + fields = fallback_fields + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def _action_mask_payload(mask: list[bool]) -> dict[str, bool]: + return {ACTION_NAMES[index]: bool(value) for index, value in enumerate(mask)} +def _action_mask_reason(valid: bool, valid_reason: str, invalid_reason: str) -> str: + return valid_reason if valid else invalid_reason + + +@dataclass(frozen=True) +class DailyCandidate: + date: str + code: str + split: str + score: float + future_return: float | None + reward_label_available: bool + + +def candidates_by_date(rows: list[dict[str, Any]], *, score_column: str, candidate_limit: int = 20) -> dict[str, list[DailyCandidate]]: + grouped: dict[str, list[DailyCandidate]] = {} + for row in rows: + score = _safe_float(row.get(score_column)) + future_return = _safe_float(row.get("future_return_1d")) + if score is None: + continue + candidate = DailyCandidate( + date=str(row.get("date")), + code=str(row.get("code") or "").zfill(6), + split=str(row.get("split")), + score=score, + future_return=future_return, + reward_label_available=future_return is not None, + ) + grouped.setdefault(candidate.date, []).append(candidate) + limit = max(1, int(candidate_limit)) + return { + date: sorted(items, key=lambda item: item.score, reverse=True)[:limit] + for date, items in sorted(grouped.items()) + } + + +class DailyPortfolioEnv: + """A minimal constrained portfolio environment over daily candidate slots.""" + + action_names = ACTION_NAMES + + def __init__( + self, + candidates: dict[str, list[DailyCandidate]], + *, + max_positions: int = 5, + cost_rate: float = ROUND_TRIP_COST_RATE, + invalid_action_penalty: float = 0.001, + exposure_penalty: float = 0.00005, + concentration_penalty: float = 0.00005, + drawdown_penalty: float = 0.01, + churn_penalty: float = 0.00002, + fill_assumption: str = FILL_ASSUMPTION, + observation_mode: str = OBSERVATION_MODE_V1, + ) -> None: + self.candidates = candidates + self.dates = sorted(candidates) + self.max_positions = max(1, int(max_positions)) + self.cost_rate = float(cost_rate) + self.invalid_action_penalty = float(invalid_action_penalty) + self.exposure_penalty = float(exposure_penalty) + self.concentration_penalty = float(concentration_penalty) + self.drawdown_penalty = float(drawdown_penalty) + self.churn_penalty = float(churn_penalty) + self.fill_assumption = fill_assumption + self.observation_mode = _validate_observation_mode(observation_mode) + self.observation_state_fields = _observation_fields_for_mode(self.observation_mode) + self.reset() + + def reset(self) -> tuple[int, ...]: + self.index = 0 + self.positions: list[str] = [] + self.equity = 1.0 + self.peak_equity = 1.0 + self.current_drawdown = 0.0 + self.invalid_actions = 0 + self.steps = 0 + return self.state() + + def _recent_score_volatility_bucket(self, *, lookback: int = 5) -> int: + if self.index <= 0: + return 0 + prior_dates = self.dates[max(0, self.index - int(lookback)) : self.index] + top_scores = [ + self.candidates[date][0].score + for date in prior_dates + if self.candidates.get(date) + ] + if len(top_scores) < 2: + return 0 + mean_score = sum(top_scores) / len(top_scores) + variance = sum((score - mean_score) ** 2 for score in top_scores) / len(top_scores) + return _bucket_by_threshold(math.sqrt(variance), (0.001, 0.005, 0.02)) + + def state_details(self) -> dict[str, int | str]: + candidates = self._current_candidates() + top_score = candidates[0].score if candidates else None + second_score = candidates[1].score if len(candidates) > 1 else None + score_margin = (top_score - second_score) if top_score is not None and second_score is not None else top_score + details: dict[str, int | str] = { + "observation_mode": self.observation_mode, + "position_count": len(self.positions), + "top_score_bucket": _score_sign_bucket(top_score), + } + if self.observation_mode == OBSERVATION_MODE_ACTION_INDUCTION_V2: + details.update( + { + "score_margin_bucket": _bucket_by_threshold(score_margin, (0.001, 0.005, 0.02)), + "candidate_count_bucket": 0 + if not candidates + else 1 + if len(candidates) == 1 + else 2 + if len(candidates) <= 4 + else 3, + "recent_score_volatility_bucket": self._recent_score_volatility_bucket(), + "d3_confidence_bucket": _bucket_by_threshold(top_score, (0.001, 0.005, 0.02)), + } + ) + return details + + def state(self) -> tuple[int, ...]: + details = self.state_details() + return tuple(int(details[field]) for field in self.observation_state_fields) + + def done(self) -> bool: + return self.index >= len(self.dates) + + def _current_candidates(self) -> list[DailyCandidate]: + if self.done(): + return [] + return self.candidates.get(self.dates[self.index], []) + + def action_mask_details(self) -> dict[str, dict[str, Any]]: + candidates = self._current_candidates() + has_new_candidate = any(candidate.code not in self.positions for candidate in candidates) + has_position = bool(self.positions) + max_reached = len(self.positions) >= self.max_positions + details = { + "hold": { + "valid": True, + "reason": "always_valid_no_trade_or_hold", + }, + "buy": { + "valid": not has_position and has_new_candidate, + "reason": _action_mask_reason( + not has_position and has_new_candidate, + "flat_portfolio_has_new_candidate", + "blocked_existing_position" if has_position else "blocked_no_new_candidate", + ), + }, + "add": { + "valid": has_position and not max_reached and has_new_candidate, + "reason": _action_mask_reason( + has_position and not max_reached and has_new_candidate, + "has_position_capacity_and_new_candidate", + "blocked_no_position" if not has_position else "blocked_max_positions" if max_reached else "blocked_no_new_candidate", + ), + }, + "sell": { + "valid": has_position, + "reason": _action_mask_reason(has_position, "has_position_to_sell", "blocked_no_position"), + }, + "reduce": { + "valid": len(self.positions) > 1, + "reason": _action_mask_reason(len(self.positions) > 1, "multiple_positions_to_reduce", "blocked_requires_multiple_positions"), + }, + } + return details + + def action_mask(self) -> list[bool]: + details = self.action_mask_details() + return [bool(details[ACTION_NAMES[index]]["valid"]) for index in sorted(ACTION_NAMES)] + + + def choose_first_new_candidate(self) -> str | None: + for candidate in self._current_candidates(): + if candidate.code not in self.positions: + return candidate.code + return None + + def step(self, action: int) -> tuple[tuple[int, ...], float, bool, dict[str, Any]]: + if self.done(): + raise StopIteration("Environment is done") + requested_action = int(action) + action = requested_action + details = self.action_mask_details() + mask = [bool(details[ACTION_NAMES[index]]["valid"]) for index in sorted(ACTION_NAMES)] + requested_action_name = self.action_names.get(requested_action, "unknown") + action_name = requested_action_name + if requested_action not in self.action_names: + invalid_reason = "unknown_action" + else: + invalid_reason = None if mask[requested_action] else str(details[requested_action_name]["reason"]) + invalid = invalid_reason is not None + previous_positions = list(self.positions) + if invalid: + self.invalid_actions += 1 + action = 0 + action_name = "hold" + elif action in {1, 2}: + code = self.choose_first_new_candidate() + if code is not None and len(self.positions) < self.max_positions: + self.positions.append(code) + elif action == 3: + self.positions.clear() + elif action == 4 and self.positions: + self.positions.pop() + + returns_by_code = {candidate.code: candidate.future_return for candidate in self._current_candidates()} + missing_reward_label_codes = [code for code in self.positions if returns_by_code.get(code) is None] + held_returns = [float(returns_by_code.get(code) or 0.0) for code in self.positions] + gross_return = sum(held_returns) / len(held_returns) if held_returns else 0.0 + changed = len(set(previous_positions) ^ set(self.positions)) + turnover = min(1.0, changed / self.max_positions) + cost = turnover * self.cost_rate + net_return_after_cost = gross_return - cost + exposure = len(self.positions) / self.max_positions + concentration = 1.0 / len(self.positions) if self.positions else 0.0 + exposure_cost = exposure * self.exposure_penalty + concentration_cost = max(0.0, concentration - 0.5) * self.concentration_penalty + invalid_cost = self.invalid_action_penalty if invalid else 0.0 + churn_cost = turnover * self.churn_penalty + no_trade_hold_reward = 0.0 + reward_before_drawdown = net_return_after_cost - exposure_cost - concentration_cost - invalid_cost - churn_cost + no_trade_hold_reward + projected_equity = self.equity * (1.0 + reward_before_drawdown) + projected_peak = max(self.peak_equity, projected_equity) + projected_drawdown = projected_equity / projected_peak - 1.0 if projected_peak else 0.0 + drawdown_cost = abs(min(0.0, projected_drawdown)) * self.drawdown_penalty + reward = reward_before_drawdown - drawdown_cost + self.equity *= 1.0 + reward + self.peak_equity = max(self.peak_equity, self.equity) + self.current_drawdown = self.equity / self.peak_equity - 1.0 if self.peak_equity else 0.0 + date = self.dates[self.index] + self.index += 1 + self.steps += 1 + no_trade_action = requested_action == 0 and not previous_positions and not self.positions + info = { + "date": date, + "requested_action": requested_action_name, + "action": action_name, + "executed_action": action_name, + "invalid_action": invalid, + "invalid_action_reason": invalid_reason, + "no_trade_action": no_trade_action, + "positions": list(self.positions), + "gross_return": gross_return, + "cost": cost, + "net_return_after_cost": net_return_after_cost, + "turnover": turnover, + "exposure": exposure, + "concentration": concentration, + "exposure_penalty": exposure_cost, + "concentration_penalty": concentration_cost, + "invalid_action_penalty": invalid_cost, + "churn_penalty": churn_cost, + "no_trade_hold_reward": no_trade_hold_reward, + "drawdown_penalty": drawdown_cost, + "current_drawdown": self.current_drawdown, + "reward_before_drawdown_penalty": reward_before_drawdown, + "reward": reward, + "equity": self.equity, + "action_mask": _action_mask_payload(mask), + "action_mask_reasons": {name: str(value["reason"]) for name, value in details.items()}, + "fill_assumption": self.fill_assumption, + "missing_reward_label_codes": list(missing_reward_label_codes), + "missing_reward_label_count": len(missing_reward_label_codes), + "reward_components": { + "daily_nav_return": gross_return, + "turnover_cost": cost, + "net_return_after_cost": net_return_after_cost, + "exposure_penalty": exposure_cost, + "concentration_penalty": concentration_cost, + "invalid_action_penalty": invalid_cost, + "churn_penalty": churn_cost, + "drawdown_penalty": drawdown_cost, + "no_trade_hold_reward": no_trade_hold_reward, + }, + } + return self.state(), reward, self.done(), info + + +def build_observation_manifest( + *, + max_positions: int = 5, + score_column: str = "score_supervised_linear_ranker", + candidate_limit: int | None = None, + observation_mode: str = OBSERVATION_MODE_V1, + action_prior_mode: str = "none", + action_prior_strength: float = 0.0, + action_filter_mode: str = "none", +) -> dict[str, Any]: + """Describe the D4 state contract separately from reward/action telemetry.""" + + max_pos = max(1, int(max_positions)) + mode = _validate_observation_mode(observation_mode) + observation_fields = [ + { + "name": "position_count", + "dtype": "int", + "source": "current positions before action", + "timing": "t/current/pre_action", + "leakage_status": "causal", + "min": 0, + "max": max_pos, + }, + { + "name": "top_score_bucket", + "dtype": "int", + "source": f"{score_column} from the current date candidate panel", + "timing": "t/current/pre_action", + "leakage_status": "causal", + "values": [-1, 0, 1], + }, + ] + if mode == OBSERVATION_MODE_ACTION_INDUCTION_V2: + observation_fields.extend( + [ + { + "name": "score_margin_bucket", + "dtype": "int", + "source": f"top-1 minus top-2 {score_column} within the current date candidate panel", + "timing": "t/current/pre_action", + "leakage_status": "causal", + "values": [0, 1, 2, 3, 4], + }, + { + "name": "candidate_count_bucket", + "dtype": "int", + "source": "number of current date candidates after score sorting and candidate_limit truncation", + "timing": "t/current/pre_action", + "leakage_status": "causal", + "values": [0, 1, 2, 3], + }, + { + "name": "recent_score_volatility_bucket", + "dtype": "int", + "source": "past-only rolling volatility of prior top D3 scores; proxy used because raw OHLCV volatility is not present in prediction artifacts", + "timing": "t-1/lookback/pre_action", + "leakage_status": "causal_past_only_no_current_reward_label", + "values": [0, 1, 2, 3, 4], + }, + { + "name": "d3_confidence_bucket", + "dtype": "int", + "source": f"absolute current top {score_column} magnitude bucket", + "timing": "t/current/pre_action", + "leakage_status": "causal", + "values": [0, 1, 2, 3, 4], + }, + ] + ) + state_field_names = [str(field["name"]) for field in observation_fields] + return { + "schema_version": OBSERVATION_MANIFEST_SCHEMA_VERSION, + "status": "PASS_RESEARCH_ONLY_STATE_CONTRACT", + "gate": "D4_OBSERVATION_STATE_MANIFEST", + "guardrail": "Observation/state manifest only; no profit guarantee, no live/broker/orders, no deployable model claim.", + "model_build_allowed": False, + "go_summary_allowed": False, + "reward_action_telemetry_sufficient_for_d4": False, + "observation_mode": mode, + "observation_shape": [len(observation_fields)], + "observation_fields": observation_fields, + "feature_timing": { + "candidate_scores": "D3 score columns are read from the current date candidate panel before the action.", + "candidate_rank": "Candidates are sorted by current score descending within the same date before candidate_limit truncation.", + "score_margin": "score_margin_bucket is derived from current same-date scores only, before reward labels are consumed.", + "candidate_count": "candidate_count_bucket is derived from current same-date candidate availability after truncation.", + "recent_score_volatility": "recent_score_volatility_bucket uses prior top-score history only; it does not read current or future reward labels.", + "d3_confidence": "d3_confidence_bucket is a current-score magnitude bucket, not an outcome label.", + "holdings": "The current positions list exists before the action for masks/accounting; the compact state exposes position_count.", + "cash_exposure": "cash_fraction and exposure_fraction are derived from current position_count/max_positions before action.", + "future_return_1d": "Excluded from current observation and action mask; consumed only after the action for reward accounting or post-policy diagnostics.", + }, + "holdings_identity": { + "code_format": "six_digit_string", + "leading_zero_policy": "zfill(6), never int coercion", + "tracked_before_action": True, + "identity_use": "action masks, positions artifact, and portfolio accounting", + "state_vector_identity_policy": "compact state keeps count only; positions artifact preserves held codes.", + }, + "cash_exposure": { + "max_positions": max_pos, + "state_variables": ["position_count"], + "derived_before_action": ["cash_fraction", "exposure_fraction"], + "exposure_formula": "position_count / max_positions", + "cash_formula": "1 - exposure_fraction", + "post_action_telemetry": ["exposure", "turnover", "concentration"], + }, + "candidate_rank_score_features": { + "score_column": score_column, + "candidate_limit": candidate_limit, + "rank_order": "score descending within each date", + "top_score_bucket": "sign(top current candidate score)", + "score_margin_bucket": "bucket(top1_score - top2_score) for action_induction_v2; empty when no second candidate", + "candidate_count_bucket": "0 none, 1 singleton, 2 two-to-four, 3 five-or-more candidates", + "recent_score_volatility_bucket": "past-only top-score volatility proxy; no current/future outcome labels", + "d3_confidence_bucket": "bucket(abs(top current candidate score))", + "score_values_not_target_labels": True, + "excluded_label_columns": list(FUTURE_LABEL_COLUMNS), + }, + "action_induction_v2": { + "enabled": mode == OBSERVATION_MODE_ACTION_INDUCTION_V2, + "variants": [ + "state_margin_bucket_v1", + "candidate_count_bucket_v1", + "recent_volatility_bucket_v1_proxy_no_label", + "d3_confidence_bucket_v1", + "action_prior_exploration_v1" if action_prior_mode != "none" else "action_prior_exploration_disabled", + ], + "state_fields": state_field_names, + "action_prior_mode": action_prior_mode, + "action_prior_strength": float(action_prior_strength), + "action_filter_mode": action_filter_mode, + "trade_quality_filter_v1": action_filter_mode not in {"none", "disabled", "prior_disabled_control_v1"}, + "diagnostic_only": True, + "promotion_status": "NO_GO_RESEARCH_ONLY_UNTIL_D5_PASS", + }, + "trade_quality_filter": { + "mode": action_filter_mode, + "enabled": action_filter_mode not in {"none", "disabled", "prior_disabled_control_v1"}, + "entry_actions_filtered": ["buy", "add"], + "decision_inputs": ["d3_confidence_bucket", "score_margin_bucket", "recent_score_volatility_bucket"], + "future_label_policy": "future_return_1d is forbidden for filter selection and allowed only after action for reward/diagnostics.", + "thresholds": { + "confidence_min_bucket": 3, + "margin_min_bucket": 3, + "risk_volatility_max_bucket": 3, + }, + "telemetry_artifact": "abstention_reasons.csv", + "diagnostic_only": True, + "promotion_status": "NO_GO_RESEARCH_ONLY_UNTIL_D5_PASS", + }, + "horizon_alignment": { + "rebalance_clock": "daily date step", + "reward_label": "future_return_1d", + "reward_horizon_days": 1, + "fill_assumption": FILL_ASSUMPTION, + "policy": "close-to-next-close research label only; no broker/order fill is inferred.", + }, + "action_mask_semantics": { + "hold": "always valid before done; represents no-trade when flat and hold/carry when invested", + "buy": "valid only when no position exists and a new candidate is available", + "add": "valid when a position exists, max_positions is not reached, and a new candidate is available", + "sell": "valid when at least one position exists", + "reduce": "valid when more than one position exists", + "reason_fields": "action_mask_reasons records the exact valid/blocked reason per action", + }, + "leakage_checks": [ + { + "check": "future_return_1d_excluded_from_state", + "status": "PASS", + "evidence": f"state() returns declared causal fields only: {', '.join(state_field_names)}.", + }, + { + "check": "future_return_1d_excluded_from_action_mask", + "status": "PASS", + "evidence": "action_mask() reads current candidates and positions, not reward labels.", + }, + { + "check": "future_label_availability_not_candidate_filter", + "status": "PASS", + "evidence": "candidates_by_date skips missing scores only; missing future_return_1d stays eligible and is zero-filled only during post-action reward accounting.", + }, + { + "check": "reward_label_post_action_only", + "status": "PASS", + "evidence": "step() reads candidate.future_return after the action mutates positions.", + }, + { + "check": "leading_zero_code_identity", + "status": "PASS", + "evidence": "candidates_by_date stores str(code).zfill(6) and position artifacts re-zfill codes.", + }, + ], + "frozen_d3_comparison": { + "required": True, + "score_column": score_column, + "baseline_source": "D3 prediction baseline artifacts", + "required_baselines": list(FROZEN_D3_COMPARISON_REQUIREMENTS), + "promotion_rule": "D4 cannot be promoted from reward/action telemetry alone; compare to frozen D3 baselines and D5 OOS gates.", + }, + } + + +def validate_observation_manifest(manifest: dict[str, Any]) -> dict[str, Any]: + """Return a strict gate report for the D4 observation/state manifest.""" + + issues: list[str] = [] + missing_sections = [section for section in REQUIRED_OBSERVATION_MANIFEST_SECTIONS if not manifest.get(section)] + if missing_sections: + issues.append(f"missing_sections:{','.join(missing_sections)}") + + field_names = [ + str(field.get("name")) + for field in manifest.get("observation_fields", []) + if isinstance(field, dict) + ] + expected_fields = _observation_fields_for_mode(str(manifest.get("observation_mode") or OBSERVATION_MODE_V1)) + missing_fields = [field for field in expected_fields if field not in field_names] + if missing_fields: + issues.append(f"missing_observation_fields:{','.join(missing_fields)}") + + leaked_fields = [ + field + for field in field_names + if any(label in field for label in FUTURE_LABEL_COLUMNS) + ] + if leaked_fields: + issues.append(f"future_label_in_observation:{','.join(leaked_fields)}") + + if manifest.get("reward_action_telemetry_sufficient_for_d4") is not False: + issues.append("reward_action_telemetry_must_not_satisfy_d4") + + d3 = manifest.get("frozen_d3_comparison", {}) + baselines = set(d3.get("required_baselines", [])) if isinstance(d3, dict) else set() + missing_baselines = [baseline for baseline in FROZEN_D3_COMPARISON_REQUIREMENTS if baseline not in baselines] + if missing_baselines: + issues.append(f"missing_frozen_d3_baselines:{','.join(missing_baselines)}") + + leakage_entries = [ + check + for check in manifest.get("leakage_checks", []) + if isinstance(check, dict) + ] + leakage_names = [str(check.get("check")) for check in leakage_entries] + duplicate_leakage_checks = sorted({check for check in leakage_names if leakage_names.count(check) > 1}) + missing_leakage_checks = [check for check in REQUIRED_LEAKAGE_CHECKS if check not in leakage_names] + failing_leakage_checks = [ + str(check.get("check")) + for check in leakage_entries + if str(check.get("check")) in REQUIRED_LEAKAGE_CHECKS and str(check.get("status")) != "PASS" + ] + if duplicate_leakage_checks: + issues.append(f"duplicate_leakage_checks:{','.join(duplicate_leakage_checks)}") + if missing_leakage_checks: + issues.append(f"missing_leakage_checks:{','.join(missing_leakage_checks)}") + if failing_leakage_checks: + issues.append(f"failing_leakage_checks:{','.join(failing_leakage_checks)}") + + return { + "schema_version": OBSERVATION_MANIFEST_SCHEMA_VERSION, + "status": "PASS" if not issues else "FAIL", + "issues": issues, + "required_sections": list(REQUIRED_OBSERVATION_MANIFEST_SECTIONS), + "observation_fields": field_names, + "missing_sections": missing_sections, + "missing_observation_fields": missing_fields, + "future_label_fields": leaked_fields, + "missing_frozen_d3_baselines": missing_baselines, + "missing_leakage_checks": missing_leakage_checks, + "failing_leakage_checks": failing_leakage_checks, + "duplicate_leakage_checks": duplicate_leakage_checks, + "reward_action_telemetry_sufficient_for_d4": manifest.get("reward_action_telemetry_sufficient_for_d4"), + } + +def environment_contract( + *, + max_positions: int = 5, + score_column: str = "score_supervised_linear_ranker", + candidate_limit: int | None = None, + observation_mode: str = OBSERVATION_MODE_V1, + action_prior_mode: str = "none", + action_prior_strength: float = 0.0, +) -> dict[str, Any]: + observation_manifest = build_observation_manifest( + max_positions=max_positions, + score_column=score_column, + candidate_limit=candidate_limit, + observation_mode=observation_mode, + action_prior_mode=action_prior_mode, + action_prior_strength=action_prior_strength, + ) + observation_validation = validate_observation_manifest(observation_manifest) + state_fields = _observation_fields_for_mode(observation_mode) + return { + "schema_version": ENV_INSPECTION_SCHEMA_VERSION, + "status": "RESEARCH_ONLY", + "guardrail": "Daily OHLCV portfolio RL environment evidence only; no profit guarantee, no live/broker/orders.", + "fill_assumption": FILL_ASSUMPTION, + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + "state": { + "shape": [len(state_fields)], + "fields": state_fields, + "lookahead_policy": "state uses current candidate scores, current holdings, and optional past-only score history; future_return labels are consumed only after action for reward accounting", + }, + "action_space": ACTION_NAMES, + "action_mask": { + "hold": "always valid before done; represents no-trade when flat and hold/carry when invested", + "buy": "valid only when no position exists and a new candidate is available", + "add": "valid when a position exists, max_positions is not reached, and a new candidate is available", + "sell": "valid when at least one position exists", + "reduce": "valid when more than one position exists", + "reason_fields": "action_mask_reasons records the exact valid/blocked reason per action", + }, + "reward_formula": "net_return_after_cost - exposure_penalty - concentration_penalty - invalid_action_penalty - churn_penalty - drawdown_penalty + no_trade_hold_reward", + "reward_components": [ + "daily_nav_return", + "turnover_cost", + "net_return_after_cost", + "exposure_penalty", + "concentration_penalty", + "invalid_action_penalty", + "churn_penalty", + "drawdown_penalty", + "no_trade_hold_reward", + ], + "max_positions": max(1, int(max_positions)), + "model_build_allowed": False, + "observation_manifest": observation_manifest, + "observation_manifest_validation": observation_validation, + } + + +def build_env_inspection( + candidates: dict[str, list[DailyCandidate]], + *, + max_positions: int = 5, + score_column: str = "score_supervised_linear_ranker", + candidate_limit: int | None = None, + scripted_actions: list[int] | None = None, + observation_mode: str = OBSERVATION_MODE_V1, + action_prior_mode: str = "none", + action_prior_strength: float = 0.0, +) -> dict[str, Any]: + env = DailyPortfolioEnv(candidates, max_positions=max_positions, observation_mode=observation_mode) + actions = scripted_actions or [1, 2, 0, 4, 3] + manifest = { + **environment_contract(max_positions=max_positions, score_column=score_column, candidate_limit=candidate_limit, observation_mode=observation_mode, action_prior_mode=action_prior_mode, action_prior_strength=action_prior_strength), + "generated_at": _utc_now(), + "date_count": len(env.dates), + "candidate_count": sum(len(items) for items in candidates.values()), + "candidate_dates": list(env.dates), + "candidate_limit": candidate_limit, + } + reward_rows: list[dict[str, Any]] = [] + action_mask_rows: list[dict[str, Any]] = [] + position_rows: list[dict[str, Any]] = [] + state_rows: list[dict[str, Any]] = [] + step = 0 + while not env.done(): + mask = env.action_mask() + state = env.state() + state_details = env.state_details() + current_candidates = env._current_candidates() + top_candidate = current_candidates[0] if current_candidates else None + position_count = int(state[0]) + exposure_fraction = position_count / env.max_positions + cash_fraction = max(0.0, 1.0 - exposure_fraction) + state_rows.append( + { + "step": step + 1, + "date": env.dates[env.index], + "observation_position_count": position_count, + "observation_top_score_bucket": int(state_details["top_score_bucket"]), + "observation_score_margin_bucket": state_details.get("score_margin_bucket", ""), + "observation_candidate_count_bucket": state_details.get("candidate_count_bucket", ""), + "observation_recent_score_volatility_bucket": state_details.get("recent_score_volatility_bucket", ""), + "observation_d3_confidence_bucket": state_details.get("d3_confidence_bucket", ""), + "observation_mode": observation_mode, + "cash_fraction": cash_fraction, + "exposure_fraction": exposure_fraction, + "max_positions": env.max_positions, + "held_codes": "|".join(str(code).zfill(6) for code in env.positions), + "candidate_count": len(current_candidates), + "top_candidate_code": str(top_candidate.code).zfill(6) if top_candidate else "", + "top_candidate_rank": 1 if top_candidate else "", + "top_candidate_score": top_candidate.score if top_candidate else "", + "top_candidate_reward_label_available": bool(top_candidate.reward_label_available) if top_candidate else False, + "score_column": score_column, + "future_label_exposed": False, + } + ) + action = actions[step % len(actions)] + action_mask_rows.append( + { + "step": step + 1, + "date": env.dates[env.index], + "requested_action": ACTION_NAMES.get(action, "unknown"), + **{f"mask_{name}": valid for name, valid in _action_mask_payload(mask).items()}, + **{f"mask_reason_{name}": str(value["reason"]) for name, value in env.action_mask_details().items()}, + } + ) + _state, _reward, _done, info = env.step(action) + reward_rows.append( + { + "step": step + 1, + "date": info["date"], + "requested_action": info["requested_action"], + "executed_action": info["executed_action"], + "action": info["action"], + "invalid_action": info["invalid_action"], + "invalid_action_reason": info["invalid_action_reason"], + "no_trade_action": info["no_trade_action"], + "gross_return": info["gross_return"], + "turnover_cost": info["cost"], + "net_return_after_cost": info["net_return_after_cost"], + "turnover": info["turnover"], + "exposure": info["exposure"], + "concentration": info["concentration"], + "exposure_penalty": info["exposure_penalty"], + "concentration_penalty": info["concentration_penalty"], + "invalid_action_penalty": info["invalid_action_penalty"], + "churn_penalty": info["churn_penalty"], + "no_trade_hold_reward": info["no_trade_hold_reward"], + "drawdown_penalty": info["drawdown_penalty"], + "current_drawdown": info["current_drawdown"], + "reward": info["reward"], + "equity": info["equity"], + "missing_reward_label_count": info["missing_reward_label_count"], + } + ) + for rank, code in enumerate(info["positions"], start=1): + position_rows.append({"step": step + 1, "date": info["date"], "rank": rank, "code": str(code).zfill(6), "action": info["action"]}) + step += 1 + manifest["summary"] = { + "steps": step, + "final_equity": env.equity, + "invalid_actions": env.invalid_actions, + "current_drawdown": env.current_drawdown, + } + return { + "env_manifest": manifest, + "observation_manifest": manifest["observation_manifest"], + "observation_manifest_validation": manifest["observation_manifest_validation"], + "reward_breakdown": reward_rows, + "action_masks": action_mask_rows, + "positions": position_rows, + "state_observations": state_rows, + } + + +def write_env_inspection_artifacts( + inspection: dict[str, Any], + *, + run_id: str | None = None, + artifact_root: Path | str | None = None, + overwrite: bool = False, +) -> dict[str, Any]: + root = Path(artifact_root or DEFAULT_ENV_INSPECTION_ROOT).resolve() + default_root = DEFAULT_ENV_INSPECTION_ROOT.resolve() + try: + root.relative_to(default_root) + except ValueError: + if root != default_root: + raise ValueError("Daily OHLCV env inspection artifacts must stay under webui/rl_runs/daily_ohlcv_portfolio_env") + rid = _validate_run_id(run_id or f"env_inspection_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}") + out_dir = (root / rid).resolve() + out_dir.relative_to(root) + if out_dir.exists() and any(out_dir.iterdir()) and not overwrite: + raise FileExistsError(f"Env inspection artifact run_id already exists: {rid}") + out_dir.mkdir(parents=True, exist_ok=True) + paths = { + "env_manifest": out_dir / "env_manifest.json", + "observation_manifest": out_dir / "observation_manifest.json", + "reward_breakdown": out_dir / "reward_breakdown.csv", + "action_masks": out_dir / "action_masks.csv", + "positions": out_dir / "positions.csv", + "state_observations": out_dir / "state_observations.csv", + } + manifest = {**inspection["env_manifest"], "run_id": rid, "artifact_dir": str(out_dir), "artifacts": {key: str(path) for key, path in paths.items()}} + _write_json(paths["env_manifest"], manifest) + _write_json(paths["observation_manifest"], {**inspection["observation_manifest"], "run_id": rid}) + _write_csv(paths["reward_breakdown"], inspection["reward_breakdown"], ["step", "date", "requested_action", "executed_action", "action", "invalid_action", "invalid_action_reason", "no_trade_action", "gross_return", "turnover_cost", "net_return_after_cost", "missing_reward_label_count", "reward", "equity"]) + _write_csv(paths["action_masks"], inspection["action_masks"], ["step", "date", "requested_action", "mask_hold", "mask_buy", "mask_add", "mask_sell", "mask_reduce", "mask_reason_hold", "mask_reason_buy", "mask_reason_add", "mask_reason_sell", "mask_reason_reduce"]) + _write_csv(paths["positions"], inspection["positions"], ["step", "date", "rank", "code", "action"]) + _write_csv(paths["state_observations"], inspection["state_observations"], ["step", "date", "observation_position_count", "observation_top_score_bucket", "observation_score_margin_bucket", "observation_candidate_count_bucket", "observation_recent_score_volatility_bucket", "observation_d3_confidence_bucket", "observation_mode", "cash_fraction", "exposure_fraction", "held_codes", "candidate_count", "top_candidate_code", "top_candidate_rank", "top_candidate_score", "top_candidate_reward_label_available", "future_label_exposed"]) + return {"run_id": rid, "artifact_dir": str(out_dir), **{f"{key}_path": str(path) for key, path in paths.items()}} + +__all__ = [ + "ACTION_NAMES", + "DEFAULT_ENV_INSPECTION_ROOT", + "ENV_INSPECTION_SCHEMA_VERSION", + "OBSERVATION_MANIFEST_SCHEMA_VERSION", + "OBSERVATION_MODE_ACTION_INDUCTION_V2", + "OBSERVATION_MODE_V1", + "FILL_ASSUMPTION", + "ROUND_TRIP_COST_BP", + "ROUND_TRIP_COST_RATE", + "DailyCandidate", + "DailyPortfolioEnv", + "build_env_inspection", + "build_observation_manifest", + "candidates_by_date", + "environment_contract", + "write_env_inspection_artifacts", + "validate_observation_manifest", +] diff --git a/stom_rl/daily_prediction.py b/stom_rl/daily_prediction.py new file mode 100644 index 000000000..2a5e45dbe --- /dev/null +++ b/stom_rl/daily_prediction.py @@ -0,0 +1,705 @@ +"""Daily OHLCV prediction baselines and transparent supervised rankers. + +D3 is a research evidence step. It consumes D2 dataset artifacts, evaluates +explicit baselines before any RL work, and emits WATCH/NO-GO-ready artifacts. +It does not place orders, tune on OOS data, or claim profitability. +""" + +from __future__ import annotations + +import csv +import json +import hashlib +import math +import re +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +from .daily_ohlcv_dataset import DEFAULT_DATASET_ROOT, DEFAULT_FEATURE_COLUMNS, DEFAULT_LABEL_COLUMNS +from .daily_ohlcv_db import PRICE_BASIS, PRICE_BASIS_EVIDENCE, REPO_ROOT +from .daily_ranker import fit_direction_classifier, fit_linear_ranker, score_direction_probability, score_row + +DEFAULT_PREDICTION_ROOT = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_prediction" +PREDICTION_SCHEMA_VERSION = 1 +ROUND_TRIP_COST_BP = 23 +ROUND_TRIP_COST_RATE = ROUND_TRIP_COST_BP / 10_000.0 +SAFE_RUN_RE = re.compile(r"^[0-9A-Za-z_.-]+$") +BASELINE_STRATEGIES = [ + "no_trade_cash", + "shuffle_control", + "equal_weight_topk_momentum", + "vol_adjusted_momentum", + "mean_reversion", + "market_proxy", + "supervised_linear_ranker", + "supervised_direction_classifier", +] +CONTROL_STRATEGIES = {"no_trade_cash", "shuffle_control"} +RULE_BASELINE_STRATEGIES = { + "equal_weight_topk_momentum", + "vol_adjusted_momentum", + "mean_reversion", + "market_proxy", +} +SUPERVISED_STRATEGIES = {"supervised_linear_ranker", "supervised_direction_classifier"} +PRICE_BASIS_VERIFIED_STATUSES = {"VERIFIED", "RAW_VERIFIED", "ADJUSTED_VERIFIED", "PRICE_BASIS_VERIFIED"} +PRICE_BASIS_VERIFIED_VALUES = {"raw", "adjusted", "split_adjusted", "total_return_adjusted"} +D3_REQUIRED_EVIDENCE = [ + "frozen_dataset_manifest_sha", + "train_only_supervised_fit", + "deterministic_shuffle_control", + "no_trade_cash_control", + "rule_topk_baselines", + "supervised_ranker_classifier_baselines", + "23bp_cost_net_metrics", + "val_test_evaluation_splits", + "mdd_turnover_hit_rate_deltas", +] +D3_ALLOWED_USES_WHEN_WATCH = [ + "baseline_comparison_for_research", + "feature_and_ranker_diagnostics", + "d4_rl_design_reference", +] +D3_BLOCKED_USES_WHEN_WATCH = [ + "model_build_or_candidate_promotion", + "go_summary_or_profit_claim", + "paper_forward_or_live_readiness_claims", +] +D3_USER_GUIDANCE = [ + { + "section": "D3 summary", + "meaning": "D3 compares no-trade, deterministic shuffle, rule Top-K, market proxy, and supervised baselines after 23bp cost.", + "action": "Use it to decide whether an RL experiment has a strong enough frozen baseline to beat.", + }, + { + "section": "Promotion lock", + "meaning": "D3 WATCH evidence is not a model-build or GO summary approval.", + "action": "Keep model_build_allowed=false until D0/D1 evidence and D5 walk-forward gates pass.", + }, + { + "section": "Controls", + "meaning": "Shuffle/no-trade/rule baselines and MDD/turnover/hit-rate deltas are required context.", + "action": "Treat any model that fails these controls as research-only or NO-GO.", + }, +] + + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _safe_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + result = float(value) + if math.isnan(result) or math.isinf(result): + return None + return result + except (TypeError, ValueError): + return None + + +def _safe_bool(value: Any) -> bool: + return value in {True, 1, "1", "true", "True", "TRUE"} + + +def _validate_run_id(run_id: str) -> str: + rid = str(run_id or "").strip() + if not SAFE_RUN_RE.match(rid) or rid in {".", ".."} or ".." in rid.split("."): + raise ValueError("run_id contains unsafe characters") + return rid + + +def _latest_run_dir(root: Path, required_file: str) -> Path: + candidates = sorted(root.glob(f"*/{required_file}"), key=lambda p: p.stat().st_mtime, reverse=True) + if not candidates: + raise FileNotFoundError(f"No {required_file} found under {root}") + return candidates[0].parent + + +def _resolve_dataset_run(dataset_run_dir: Path | str | None = None) -> Path: + if dataset_run_dir is not None: + run_dir = Path(dataset_run_dir).resolve() + root = DEFAULT_DATASET_ROOT.resolve() + run_dir.relative_to(root) + if not (run_dir / "dataset_manifest.json").exists(): + raise FileNotFoundError(run_dir / "dataset_manifest.json") + return run_dir + return _latest_run_dir(DEFAULT_DATASET_ROOT, "dataset_manifest.json") + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _read_csv(path: Path) -> list[dict[str, Any]]: + with path.open("r", encoding="utf-8", newline="") as handle: + return [dict(row) for row in csv.DictReader(handle)] + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _write_csv(path: Path, rows: list[dict[str, Any]], fallback_fields: list[str]) -> None: + fields = sorted({key for row in rows for key in row.keys()}) if rows else fallback_fields + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def _file_sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _string_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item) for item in value if item not in (None, "")] + if value in (None, ""): + return [] + return [str(value)] + + +def _unique_strings(*groups: Any) -> list[str]: + merged: list[str] = [] + for group in groups: + for item in _string_list(group): + if item not in merged: + merged.append(item) + return merged + + +def _d3_gate_blockers(dataset_manifest: dict[str, Any]) -> list[str]: + blockers = _string_list(dataset_manifest.get("upstream_gate_blockers")) + price_basis = str(dataset_manifest.get("price_basis") or PRICE_BASIS).lower() + price_status = str(dataset_manifest.get("price_basis_status") or "").upper() + decision_status = str(dataset_manifest.get("decision_grade_return_status") or "") + if ( + "D0_PRICE_BASIS_NOT_VERIFIED" not in blockers + and (price_basis not in PRICE_BASIS_VERIFIED_VALUES or price_status not in PRICE_BASIS_VERIFIED_STATUSES or decision_status.startswith("BLOCKED")) + ): + blockers.append("D0_PRICE_BASIS_NOT_VERIFIED") + universe_verdict = str(dataset_manifest.get("universe_verdict") or "") + universe_review = str(dataset_manifest.get("universe_review_status") or "") + official_status = str(dataset_manifest.get("official_metadata_status") or "") + coverage_status = str(dataset_manifest.get("official_metadata_coverage_status") or "") + certification_status = str(dataset_manifest.get("universe_certification_status") or "") + if ( + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED" not in blockers + and ( + universe_verdict != "OFFICIAL_OR_MANUAL_REVIEWED" + or universe_review != "OFFICIAL_OR_MANUAL_REVIEWED" + or official_status != "OFFICIAL_VERIFIED" + or coverage_status != "COMPLETE" + or certification_status != "OFFICIAL_OR_MANUAL_REVIEWED" + ) + ): + blockers.append("D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED") + blockers.append("D5_WALK_FORWARD_NOT_PASS") + return _unique_strings(blockers) + + +def load_dataset_rows(dataset_run_dir: Path | str | None = None) -> tuple[dict[str, Any], list[dict[str, Any]]]: + run_dir = _resolve_dataset_run(dataset_run_dir) + manifest = _read_json(run_dir / "dataset_manifest.json") + features = _read_csv(run_dir / "feature_panel.csv") + labels = _read_csv(run_dir / "label_panel.csv") + splits = _read_csv(run_dir / "split_assignments.csv") + labels_by_key = {(row["date"], row["table"]): row for row in labels} + splits_by_key = {(row["date"], row["table"]): row for row in splits} + rows: list[dict[str, Any]] = [] + for feature in features: + key = (feature["date"], feature["table"]) + label = labels_by_key.get(key, {}) + split = splits_by_key.get(key, {}) + merged = {**feature, **label, **split} + merged["code"] = str(merged.get("code") or feature.get("code") or "").zfill(6) + merged["eligible_for_training"] = _safe_bool(merged.get("eligible_for_training")) + rows.append(merged) + return manifest, rows + + +def _eligible_rows(rows: Iterable[dict[str, Any]], *, include_train: bool = True) -> list[dict[str, Any]]: + allowed_splits = {"train", "val", "test"} if include_train else {"val", "test"} + return [ + row + for row in rows + if row.get("split") in allowed_splits + and row.get("eligible_for_training") is True + and _safe_float(row.get("future_return_1d")) is not None + ] + + +def _score_predictions(rows: list[dict[str, Any]], feature_columns: list[str]) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any]]: + train_rows = [row for row in rows if str(row.get("split")) == "train"] + ranker = fit_linear_ranker(train_rows, feature_columns=feature_columns, target_column="future_return_1d") + classifier = fit_direction_classifier(train_rows, feature_columns=feature_columns, target_column="future_direction_1d") + predictions: list[dict[str, Any]] = [] + for row in rows: + ret_5d = _safe_float(row.get("return_5d")) + vol = _safe_float(row.get("volatility_5d")) + vol_adj = None if ret_5d is None else ret_5d / max(abs(vol or 0.0), 1e-9) + supervised_score = score_row(ranker, row) + direction_probability = score_direction_probability(classifier, row) + predictions.append( + { + "date": row.get("date"), + "table": row.get("table"), + "code": str(row.get("code") or "").zfill(6), + "split": row.get("split"), + "future_return_1d": _safe_float(row.get("future_return_1d")), + "future_direction_1d": _safe_float(row.get("future_direction_1d")), + "score_equal_weight_topk_momentum": ret_5d, + "score_vol_adjusted_momentum": vol_adj, + "score_mean_reversion": None if ret_5d is None else -ret_5d, + "score_market_proxy": 0.0, + "score_supervised_linear_ranker": supervised_score, + "score_supervised_direction_classifier": direction_probability, + } + ) + return predictions, ranker.to_dict(), classifier.to_dict() + + +def _group_by_date(rows: Iterable[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]: + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + grouped[str(row.get("date"))].append(row) + return dict(sorted(grouped.items())) + + +def _strategy_family(strategy: str) -> str: + if strategy in CONTROL_STRATEGIES: + return "control" + if strategy in SUPERVISED_STRATEGIES: + return "supervised" + return "rule_baseline" + + +def _select_for_strategy(strategy: str, rows: list[dict[str, Any]], top_k: int) -> list[dict[str, Any]]: + if strategy == "no_trade_cash": + return [] + if strategy == "market_proxy": + return list(rows) + if strategy == "shuffle_control": + return sorted( + rows, + key=lambda row: hashlib.sha256(f"{row.get('date')}:{row.get('code')}".encode("utf-8")).hexdigest(), + )[:top_k] + score_column = { + "equal_weight_topk_momentum": "score_equal_weight_topk_momentum", + "vol_adjusted_momentum": "score_vol_adjusted_momentum", + "mean_reversion": "score_mean_reversion", + "supervised_linear_ranker": "score_supervised_linear_ranker", + "supervised_direction_classifier": "score_supervised_direction_classifier", + }[strategy] + scored = [row for row in rows if _safe_float(row.get(score_column)) is not None] + return sorted(scored, key=lambda row: float(row[score_column]), reverse=True)[:top_k] + + +def _max_drawdown(equity: list[float]) -> float: + peak = 1.0 + max_dd = 0.0 + for value in equity: + peak = max(peak, value) + if peak: + max_dd = min(max_dd, value / peak - 1.0) + return max_dd + + +def evaluate_strategy( + predictions: list[dict[str, Any]], + *, + strategy: str, + top_k: int = 20, + cost_rate: float = ROUND_TRIP_COST_RATE, + splits: Iterable[str] = ("val", "test"), +) -> tuple[list[dict[str, Any]], dict[str, Any], list[dict[str, Any]], list[dict[str, Any]]]: + eval_rows = [row for row in predictions if row.get("split") in set(splits)] + grouped = _group_by_date(eval_rows) + daily_rows: list[dict[str, Any]] = [] + position_rows: list[dict[str, Any]] = [] + turnover_rows: list[dict[str, Any]] = [] + previous_codes: set[str] = set() + equity = 1.0 + equity_curve: list[float] = [] + for date, rows in grouped.items(): + selected = _select_for_strategy(strategy, rows, top_k=top_k) + selected_codes = {str(row["code"]) for row in selected} + if strategy == "no_trade_cash": + turnover = 0.0 + gross = 0.0 + elif strategy == "market_proxy": + # Conservative proxy accounting: this is a daily equal-weight market + # proxy, not a buy-and-hold index. Charge one full round-trip cost + # per rebalance day so a broad proxy cannot become the "best" row + # simply because turnover was under-counted. + turnover = 1.0 if selected else 0.0 + returns = [_safe_float(row.get("future_return_1d")) for row in selected] + clean = [value for value in returns if value is not None] + gross = sum(clean) / len(clean) if clean else 0.0 + else: + denom = max(1, top_k) + overlap = len(selected_codes & previous_codes) + turnover = 1.0 if not previous_codes else max(0.0, min(1.0, 1.0 - overlap / denom)) + returns = [_safe_float(row.get("future_return_1d")) for row in selected] + clean = [value for value in returns if value is not None] + gross = sum(clean) / len(clean) if clean else 0.0 + net = gross - turnover * cost_rate + equity *= 1.0 + net + equity_curve.append(equity) + daily_rows.append( + { + "strategy": strategy, + "date": date, + "split": "+".join(sorted(set(str(row.get("split")) for row in rows))), + "selected_count": len(selected), + "gross_return": gross, + "net_return": net, + "turnover": turnover, + "equity": equity, + } + ) + turnover_rows.append({"strategy": strategy, "date": date, "turnover": turnover, "selected_count": len(selected)}) + for rank, row in enumerate(selected, start=1): + position_rows.append( + { + "strategy": strategy, + "date": date, + "rank": rank, + "code": row.get("code"), + "split": row.get("split"), + "future_return_1d": row.get("future_return_1d"), + } + ) + previous_codes = selected_codes + net_returns = [float(row["net_return"]) for row in daily_rows] + gross_returns = [float(row["gross_return"]) for row in daily_rows] + metrics = { + "strategy": strategy, + "strategy_family": _strategy_family(strategy), + "is_control": strategy in CONTROL_STRATEGIES, + "is_shuffle_control": strategy == "shuffle_control", + "is_supervised": strategy in SUPERVISED_STRATEGIES, + "splits": list(splits), + "top_k": top_k, + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + "trade_days": len(daily_rows), + "positions": len(position_rows), + "mean_daily_gross_return": sum(gross_returns) / len(gross_returns) if gross_returns else 0.0, + "mean_daily_net_return": sum(net_returns) / len(net_returns) if net_returns else 0.0, + "total_net_return": equity - 1.0, + "hit_rate": sum(1 for value in net_returns if value > 0) / len(net_returns) if net_returns else 0.0, + "max_drawdown": _max_drawdown(equity_curve), + "mean_turnover": sum(row["turnover"] for row in turnover_rows) / len(turnover_rows) if turnover_rows else 0.0, + } + return position_rows, metrics, daily_rows, turnover_rows + + +def _best_metric(rows: list[dict[str, Any]]) -> dict[str, Any] | None: + return max(rows, key=lambda row: row["total_net_return"]) if rows else None + + +def _metric_delta(left: dict[str, Any] | None, right: dict[str, Any] | None) -> float | None: + if left is None or right is None: + return None + return float(left["total_net_return"]) - float(right["total_net_return"]) + + +def _baseline_metrics( + predictions: list[dict[str, Any]], + *, + top_k: int, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], dict[str, Any]]: + positions: list[dict[str, Any]] = [] + metrics: list[dict[str, Any]] = [] + drawdown_rows: list[dict[str, Any]] = [] + turnover_rows: list[dict[str, Any]] = [] + for strategy in BASELINE_STRATEGIES: + p, metric, daily, turnover = evaluate_strategy(predictions, strategy=strategy, top_k=top_k) + positions.extend(p) + metrics.append(metric) + drawdown_rows.extend({"strategy": row["strategy"], "date": row["date"], "equity": row["equity"], "net_return": row["net_return"]} for row in daily) + turnover_rows.extend(turnover) + + cash = next((row for row in metrics if row["strategy"] == "no_trade_cash"), None) + shuffle = next((row for row in metrics if row["strategy"] == "shuffle_control"), None) + control = [row for row in metrics if row.get("strategy_family") == "control"] + rule_baselines = [row for row in metrics if row.get("strategy_family") == "rule_baseline"] + supervised = [row for row in metrics if row.get("strategy_family") == "supervised"] + best_control = _best_metric(control) + best_rule_baseline = _best_metric(rule_baselines) + best_supervised = _best_metric(supervised) + best_overall = _best_metric(metrics) + + for metric in metrics: + metric["delta_vs_cash_total_net_return"] = _metric_delta(metric, cash) + metric["delta_vs_shuffle_control_total_net_return"] = _metric_delta(metric, shuffle) + metric["delta_vs_best_rule_baseline_total_net_return"] = _metric_delta(metric, best_rule_baseline) + + baseline_delta_summary = { + "status": "WATCH", + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + "top_k": top_k, + "evaluation_splits": ["val", "test"], + "control_strategies": [strategy for strategy in BASELINE_STRATEGIES if strategy in CONTROL_STRATEGIES], + "rule_baseline_strategies": [strategy for strategy in BASELINE_STRATEGIES if strategy in RULE_BASELINE_STRATEGIES], + "supervised_strategies": [strategy for strategy in BASELINE_STRATEGIES if strategy in SUPERVISED_STRATEGIES], + "best_overall_strategy": best_overall.get("strategy") if best_overall else None, + "best_control_strategy": best_control.get("strategy") if best_control else None, + "best_rule_baseline_strategy": best_rule_baseline.get("strategy") if best_rule_baseline else None, + "best_supervised_strategy": best_supervised.get("strategy") if best_supervised else None, + "shuffle_control_strategy": shuffle.get("strategy") if shuffle else None, + "best_supervised_delta_vs_best_rule_baseline": _metric_delta(best_supervised, best_rule_baseline), + "best_supervised_delta_vs_shuffle_control": _metric_delta(best_supervised, shuffle), + "best_rule_delta_vs_shuffle_control": _metric_delta(best_rule_baseline, shuffle), + "best_overall_delta_vs_shuffle_control": _metric_delta(best_overall, shuffle), + "model_build_allowed": False, + } + return positions, metrics, drawdown_rows, turnover_rows, baseline_delta_summary + + +def _calibration_rows(predictions: list[dict[str, Any]], *, bins: int = 5) -> list[dict[str, Any]]: + rows = [row for row in predictions if row.get("split") in {"val", "test"} and _safe_float(row.get("score_supervised_direction_classifier")) is not None] + if not rows: + return [] + sorted_rows = sorted(rows, key=lambda row: float(row["score_supervised_direction_classifier"])) + out: list[dict[str, Any]] = [] + for bin_index in range(bins): + start = int(len(sorted_rows) * bin_index / bins) + end = int(len(sorted_rows) * (bin_index + 1) / bins) + chunk = sorted_rows[start:end] + if not chunk: + continue + probs = [float(row["score_supervised_direction_classifier"]) for row in chunk] + labels = [_safe_float(row.get("future_direction_1d")) or 0.0 for row in chunk] + out.append( + { + "bin": bin_index + 1, + "count": len(chunk), + "mean_probability": sum(probs) / len(probs), + "realized_positive_rate": sum(labels) / len(labels), + } + ) + return out + + +def run_daily_prediction( + *, + dataset_run_dir: Path | str | None = None, + top_k: int = 20, +) -> dict[str, Any]: + dataset_manifest, rows = load_dataset_rows(dataset_run_dir) + feature_columns = [str(col) for col in dataset_manifest.get("feature_columns") or DEFAULT_FEATURE_COLUMNS] + label_columns = [str(col) for col in dataset_manifest.get("label_columns") or DEFAULT_LABEL_COLUMNS] + if any(col in label_columns or col.startswith(("future_", "label_", "target_")) for col in feature_columns): + raise ValueError("Feature columns contain future/label leakage") + eligible = _eligible_rows(rows, include_train=True) + top_k_value = max(1, int(top_k)) + predictions, ranker_model, classifier_model = _score_predictions(eligible, feature_columns) + positions, metrics, drawdown_rows, turnover_rows, baseline_delta_summary = _baseline_metrics(predictions, top_k=top_k_value) + calibration = _calibration_rows(predictions) + best_strategy = max(metrics, key=lambda row: row["total_net_return"]) if metrics else None + dataset_run_path = _resolve_dataset_run(dataset_run_dir) + d3_gate_blockers = _unique_strings(_d3_gate_blockers(dataset_manifest), ["D3_BASELINE_WATCH_RESEARCH_ONLY"]) + baseline_delta_summary.update( + { + "readiness_status": "D3_WATCH_RESEARCH_ONLY", + "go_summary_allowed": False, + "model_build_allowed": False, + "d3_gate_blockers": d3_gate_blockers, + "required_evidence": list(D3_REQUIRED_EVIDENCE), + "allowed_uses": list(D3_ALLOWED_USES_WHEN_WATCH), + "blocked_uses": list(D3_BLOCKED_USES_WHEN_WATCH), + "deterministic_shuffle_method": "sha256(date:code)_ascending", + } + ) + verdict_reasons = _unique_strings( + [ + "RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM", + "PRICE_BASIS_UNKNOWN", + "UNIVERSE_WATCH_HEURISTIC", + ], + d3_gate_blockers, + ) + verdict = { + "schema_version": PREDICTION_SCHEMA_VERSION, + "status": "WATCH", + "readiness_status": "D3_WATCH_RESEARCH_ONLY", + "go_summary_allowed": False, + "model_build_allowed": False, + "reasons": verdict_reasons, + "d3_gate_blockers": d3_gate_blockers, + "best_strategy_by_total_net_return": best_strategy.get("strategy") if best_strategy else None, + "requires_before_rl_go": ["D5 walk-forward", "shuffle controls", "cost sensitivity", "official/manual universe review", "price-basis verification"], + "baseline_delta_summary": baseline_delta_summary, + "blocked_uses": list(D3_BLOCKED_USES_WHEN_WATCH), + } + manifest = { + "schema_version": PREDICTION_SCHEMA_VERSION, + "generated_at": _utc_now(), + "guardrail": "Research-only D3 prediction/baseline evidence; no profit guarantee, no live/broker/orders, no trained-RL readiness claim.", + "status": "WATCH", + "readiness_status": "D3_WATCH_RESEARCH_ONLY", + "model_build_allowed": False, + "go_summary_allowed": False, + "dataset_run_dir": str(dataset_run_path), + "dataset_run_id": dataset_run_path.name, + "dataset_manifest_sha": dataset_manifest.get("manifest_sha"), + "dataset_artifact_scope": dataset_manifest.get("artifact_scope"), + "dataset_model_readiness": dataset_manifest.get("model_readiness"), + "dataset_decision_grade_status": dataset_manifest.get("decision_grade_status"), + "dataset_upstream_gate_blockers": _string_list(dataset_manifest.get("upstream_gate_blockers")), + "dataset_row_counts": dataset_manifest.get("row_counts") or {}, + "dataset_split_summary": dataset_manifest.get("split_summary") or {}, + "price_basis": dataset_manifest.get("price_basis") or PRICE_BASIS, + "price_basis_evidence": dataset_manifest.get("price_basis_evidence") or PRICE_BASIS_EVIDENCE, + "price_basis_status": dataset_manifest.get("price_basis_status"), + "decision_grade_return_status": dataset_manifest.get("decision_grade_return_status"), + "universe_verdict": dataset_manifest.get("universe_verdict"), + "universe_review_status": dataset_manifest.get("universe_review_status"), + "official_metadata_status": dataset_manifest.get("official_metadata_status"), + "official_metadata_coverage_status": dataset_manifest.get("official_metadata_coverage_status"), + "universe_certification_status": dataset_manifest.get("universe_certification_status"), + "feature_columns": feature_columns, + "label_columns": label_columns, + "top_k": top_k_value, + "cost_assumption_round_trip_bp": ROUND_TRIP_COST_BP, + "no_oos_retuning": True, + "fit_split": "train", + "evaluation_splits": ["val", "test"], + "baseline_freeze_contract": { + "frozen_dataset_manifest_sha": dataset_manifest.get("manifest_sha"), + "dataset_run_id": dataset_run_path.name, + "feature_columns": feature_columns, + "label_columns": label_columns, + "fit_split": "train", + "evaluation_splits": ["val", "test"], + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + "top_k": top_k_value, + "deterministic_shuffle_method": "sha256(date:code)_ascending", + "strategies": list(BASELINE_STRATEGIES), + "no_oos_retuning": True, + }, + "d3_gate_blockers": d3_gate_blockers, + "d3_required_evidence": list(D3_REQUIRED_EVIDENCE), + "d3_allowed_uses": list(D3_ALLOWED_USES_WHEN_WATCH), + "d3_blocked_uses": list(D3_BLOCKED_USES_WHEN_WATCH), + "d3_user_guidance": [dict(row) for row in D3_USER_GUIDANCE], + "row_counts": { + "eligible_rows": len(eligible), + "prediction_rows": len(predictions), + "position_rows": len(positions), + }, + "models": { + "supervised_linear_ranker": ranker_model, + "supervised_direction_classifier": classifier_model, + }, + "baseline_strategies": BASELINE_STRATEGIES, + "baseline_delta_summary": baseline_delta_summary, + "verdict": verdict, + } + return { + "manifest": manifest, + "predictions": predictions, + "topk_positions": positions, + "baseline_metrics": metrics, + "model_metrics": [row for row in metrics if row["strategy"].startswith("supervised_")], + "calibration": calibration, + "turnover": turnover_rows, + "drawdown": drawdown_rows, + "verdict": verdict, + "baseline_delta_summary": baseline_delta_summary, + } + + +def write_prediction_artifacts( + result: dict[str, Any], + *, + run_id: str | None = None, + artifact_root: Path | str | None = None, + overwrite: bool = False, +) -> dict[str, Any]: + root = Path(artifact_root or DEFAULT_PREDICTION_ROOT).resolve() + default_root = DEFAULT_PREDICTION_ROOT.resolve() + try: + root.relative_to(default_root) + except ValueError: + if root != default_root: + raise ValueError("Daily OHLCV prediction artifacts must stay under webui/rl_runs/daily_ohlcv_prediction") + rid = _validate_run_id(run_id or f"prediction_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}") + out_dir = (root / rid).resolve() + out_dir.relative_to(root) + if out_dir.exists() and any(out_dir.iterdir()) and not overwrite: + raise FileExistsError(f"Prediction artifact run_id already exists: {rid}") + out_dir.mkdir(parents=True, exist_ok=True) + paths = { + "prediction_manifest": out_dir / "prediction_manifest.json", + "baseline_metrics": out_dir / "baseline_metrics.json", + "baseline_delta_summary": out_dir / "baseline_delta_summary.json", + "model_metrics": out_dir / "model_metrics.json", + "topk_positions": out_dir / "topk_positions.csv", + "calibration": out_dir / "calibration.csv", + "turnover": out_dir / "turnover.csv", + "drawdown": out_dir / "drawdown.csv", + "predictions": out_dir / "predictions.csv", + "verdict": out_dir / "verdict.json", + } + manifest = {**result["manifest"], "run_id": rid, "artifact_dir": str(out_dir), "artifacts": {key: str(path) for key, path in paths.items()}} + _write_json(paths["baseline_metrics"], {"metrics": result["baseline_metrics"]}) + _write_json(paths["baseline_delta_summary"], result["baseline_delta_summary"]) + _write_json(paths["model_metrics"], {"models": manifest["models"], "metrics": result["model_metrics"]}) + _write_json(paths["verdict"], result["verdict"]) + _write_csv(paths["topk_positions"], result["topk_positions"], ["strategy", "date", "rank", "code", "split", "future_return_1d"]) + _write_csv(paths["calibration"], result["calibration"], ["bin", "count", "mean_probability", "realized_positive_rate"]) + _write_csv(paths["turnover"], result["turnover"], ["strategy", "date", "turnover", "selected_count"]) + _write_csv(paths["drawdown"], result["drawdown"], ["strategy", "date", "equity", "net_return"]) + _write_csv(paths["predictions"], result["predictions"], ["date", "table", "code", "split", "future_return_1d"]) + artifact_hashes = { + key: _file_sha256(path) + for key, path in paths.items() + if key != "prediction_manifest" + } + manifest["artifact_hashes"] = artifact_hashes + _write_json(paths["prediction_manifest"], manifest) + manifest_sha = _file_sha256(paths["prediction_manifest"]) + return { + "run_id": rid, + "artifact_dir": str(out_dir), + "prediction_manifest_sha256": manifest_sha, + "artifact_hashes": {**artifact_hashes, "prediction_manifest": manifest_sha}, + **{f"{key}_path": str(path) for key, path in paths.items()}, + } + + +def run_and_write_daily_prediction( + *, + run_id: str | None = None, + artifact_root: Path | str | None = None, + overwrite: bool = False, + **kwargs: Any, +) -> dict[str, Any]: + result = run_daily_prediction(**kwargs) + written = write_prediction_artifacts(result, run_id=run_id, artifact_root=artifact_root, overwrite=overwrite) + return {"result": result, "written": written} + + +__all__ = [ + "BASELINE_STRATEGIES", + "DEFAULT_PREDICTION_ROOT", + "PREDICTION_SCHEMA_VERSION", + "ROUND_TRIP_COST_BP", + "D3_ALLOWED_USES_WHEN_WATCH", + "D3_BLOCKED_USES_WHEN_WATCH", + "D3_REQUIRED_EVIDENCE", + "D3_USER_GUIDANCE", + "evaluate_strategy", + "load_dataset_rows", + "run_and_write_daily_prediction", + "run_daily_prediction", + "write_prediction_artifacts", +] diff --git a/stom_rl/daily_ranker.py b/stom_rl/daily_ranker.py new file mode 100644 index 000000000..43d3849d4 --- /dev/null +++ b/stom_rl/daily_ranker.py @@ -0,0 +1,172 @@ +"""Small deterministic rankers for daily OHLCV research artifacts. + +These helpers intentionally avoid black-box training stacks. They fit only on the +D2 train split, expose their coefficients, and are used as supervised baselines +rather than profit/live readiness evidence. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Any, Iterable + + +def _safe_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + result = float(value) + if math.isnan(result) or math.isinf(result): + return None + return result + except (TypeError, ValueError): + return None + + +@dataclass(frozen=True) +class LinearRankerModel: + feature_columns: tuple[str, ...] + target_column: str + means: dict[str, float] + stds: dict[str, float] + weights: dict[str, float] + intercept: float + train_row_count: int + model_kind: str + + def to_dict(self) -> dict[str, Any]: + return { + "model_kind": self.model_kind, + "target_column": self.target_column, + "feature_columns": list(self.feature_columns), + "means": self.means, + "stds": self.stds, + "weights": self.weights, + "intercept": self.intercept, + "train_row_count": self.train_row_count, + "training_policy": "fit_train_split_only_no_oos_retuning", + } + + +def _mean(values: Iterable[float]) -> float: + clean = list(values) + return sum(clean) / len(clean) if clean else 0.0 + + +def _std(values: Iterable[float]) -> float: + clean = list(values) + if not clean: + return 1.0 + avg = _mean(clean) + var = sum((value - avg) ** 2 for value in clean) / len(clean) + return math.sqrt(var) or 1.0 + + +def _eligible_train_rows(rows: Iterable[dict[str, Any]], target_column: str) -> list[dict[str, Any]]: + return [ + row + for row in rows + if str(row.get("split")) == "train" + and row.get("eligible_for_training") in {True, "True", "true", "1", 1} + and _safe_float(row.get(target_column)) is not None + ] + + +def fit_linear_ranker( + rows: Iterable[dict[str, Any]], + *, + feature_columns: Iterable[str], + target_column: str = "future_return_1d", + model_kind: str = "supervised_linear_ranker", +) -> LinearRankerModel: + """Fit transparent correlation weights on train rows only.""" + + features = tuple(str(col) for col in feature_columns) + train_rows = _eligible_train_rows(rows, target_column) + if not train_rows: + raise ValueError("No eligible train rows available for ranker fit") + means: dict[str, float] = {} + stds: dict[str, float] = {} + for column in features: + values = [_safe_float(row.get(column)) for row in train_rows] + clean = [value for value in values if value is not None] + means[column] = _mean(clean) + stds[column] = _std(clean) + targets = [_safe_float(row.get(target_column)) for row in train_rows] + y_values = [target if target is not None else 0.0 for target in targets] + y_mean = _mean(y_values) + weights: dict[str, float] = {} + for column in features: + xs: list[float] = [] + ys: list[float] = [] + for row, target in zip(train_rows, y_values): + x = _safe_float(row.get(column)) + if x is None: + continue + xs.append((x - means[column]) / stds[column]) + ys.append(target - y_mean) + weights[column] = _mean([x * y for x, y in zip(xs, ys)]) if xs else 0.0 + return LinearRankerModel( + feature_columns=features, + target_column=target_column, + means=means, + stds=stds, + weights=weights, + intercept=y_mean, + train_row_count=len(train_rows), + model_kind=model_kind, + ) + + +def score_row(model: LinearRankerModel, row: dict[str, Any]) -> float: + score = model.intercept + for column in model.feature_columns: + value = _safe_float(row.get(column)) + if value is None: + value = model.means[column] + score += model.weights[column] * ((value - model.means[column]) / model.stds[column]) + return score + + +def score_rows(model: LinearRankerModel, rows: Iterable[dict[str, Any]], *, output_column: str = "supervised_score") -> list[dict[str, Any]]: + scored: list[dict[str, Any]] = [] + for row in rows: + out = dict(row) + out[output_column] = score_row(model, row) + scored.append(out) + return scored + + +def sigmoid(value: float) -> float: + clipped = max(-40.0, min(40.0, value)) + return 1.0 / (1.0 + math.exp(-clipped)) + + +def fit_direction_classifier( + rows: Iterable[dict[str, Any]], + *, + feature_columns: Iterable[str], + target_column: str = "future_direction_1d", +) -> LinearRankerModel: + return fit_linear_ranker( + rows, + feature_columns=feature_columns, + target_column=target_column, + model_kind="supervised_direction_classifier", + ) + + +def score_direction_probability(model: LinearRankerModel, row: dict[str, Any]) -> float: + return sigmoid(score_row(model, row)) + + +__all__ = [ + "LinearRankerModel", + "fit_direction_classifier", + "fit_linear_ranker", + "score_direction_probability", + "score_row", + "score_rows", + "sigmoid", +] diff --git a/stom_rl/daily_registry.py b/stom_rl/daily_registry.py new file mode 100644 index 000000000..6d3e6d872 --- /dev/null +++ b/stom_rl/daily_registry.py @@ -0,0 +1,675 @@ +"""Research-only daily RL registry and paper-forward ledger artifacts.""" + +from __future__ import annotations + +import csv +import hashlib +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .daily_ohlcv_db import REPO_ROOT +from .daily_rl_train import DEFAULT_PORTFOLIO_ROOT, ROUND_TRIP_COST_BP +from .daily_walk_forward import DEFAULT_WALK_FORWARD_ROOT + +DEFAULT_DAILY_REGISTRY_ROOT = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_registry" +REGISTRY_SCHEMA_VERSION = 1 +SAFE_RUN_RE = re.compile(r"^[0-9A-Za-z_.-]+$") +RESEARCH_GUARDRAIL = ( + "Research-only daily RL registry and paper-forward ledger; no live/broker/orders, " + "no profit guarantee, no deployable readiness." +) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _validate_run_id(run_id: str) -> str: + rid = str(run_id or "").strip() + if not SAFE_RUN_RE.match(rid) or rid in {".", ".."} or "/" in rid or "\\" in rid: + raise ValueError("run_id contains unsafe characters") + return rid + + +def _safe_resolve_dir(path: Path | str) -> Path: + return Path(path).resolve() + + +def _latest_run_dir(root: Path, required_file: str) -> Path: + root = root.resolve() + if not root.exists(): + raise FileNotFoundError(f"No artifact root: {root}") + candidates = [path.parent for path in root.glob(f"*/{required_file}") if path.is_file()] + if not candidates: + raise FileNotFoundError(f"No {required_file} under {root}") + return max(candidates, key=lambda p: (p / required_file).stat().st_mtime) + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _optional_json(path: Path) -> dict[str, Any]: + return _read_json(path) if path.exists() else {} + + +def _read_csv_rows(path: Path) -> list[dict[str, str]]: + if not path.exists(): + return [] + with path.open("r", encoding="utf-8", newline="") as handle: + return [dict(row) for row in csv.DictReader(handle)] + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _write_csv(path: Path, rows: list[dict[str, Any]], fieldnames: list[str]) -> None: + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({key: row.get(key, "") for key in fieldnames}) + + +def _write_jsonl(path: Path, rows: list[dict[str, Any]]) -> None: + path.write_text("".join(json.dumps(row, ensure_ascii=False, sort_keys=True) + "\n" for row in rows), encoding="utf-8") + + +def _sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _sha256_payload(payload: Any) -> str: + return _sha256_bytes(json.dumps(payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")) + + +def _sha256_file(path: Path) -> str | None: + if not path.exists() or not path.is_file(): + return None + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _source_hashes() -> dict[str, str]: + source_paths = [ + REPO_ROOT / "stom_rl" / "daily_rl_train.py", + REPO_ROOT / "stom_rl" / "daily_walk_forward.py", + REPO_ROOT / "stom_rl" / "daily_registry.py", + REPO_ROOT / "webui" / "daily_ohlcv_dashboard.py", + REPO_ROOT / "webui" / "app.py", + REPO_ROOT / "webui" / "v2_src" / "src" / "lib" / "dailyOhlcvApi.ts", + REPO_ROOT / "webui" / "v2_src" / "src" / "tabs" / "DailyOhlcvTab.svelte", + REPO_ROOT / "webui" / "v2_src" / "src" / "tabs" / "dailyOhlcv" / "DailyProgressTimeline.svelte", + REPO_ROOT / "webui" / "v2_src" / "src" / "tabs" / "dailyOhlcv" / "DailyVisualLabCard.svelte", + ] + hashes: dict[str, str] = {} + for path in source_paths: + digest = _sha256_file(path) + if digest is None: + raise FileNotFoundError(f"Required registry source hash file is missing: {path}") + hashes[str(path.relative_to(REPO_ROOT)).replace("\\", "/")] = digest + return hashes + + +def _bool_false(value: Any) -> bool: + return value is False or str(value).lower() in {"false", "0", "no"} + + +def _strict_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + result = float(value) + if result != result or result in {float("inf"), float("-inf")}: + return None + return result + except (TypeError, ValueError): + return None + + +PRICE_BASIS_VERIFIED_STATUSES = { + "VERIFIED", + "RAW_VERIFIED", + "ADJUSTED_VERIFIED", + "PRICE_BASIS_VERIFIED", +} +PRICE_BASIS_VERIFIED_VALUES = {"raw", "adjusted", "split_adjusted", "total_return_adjusted"} +UNIVERSE_VERIFIED_VERDICT = "OFFICIAL_OR_MANUAL_REVIEWED" +OFFICIAL_METADATA_VERIFIED_STATUS = "OFFICIAL_VERIFIED" +OFFICIAL_METADATA_COMPLETE_COVERAGE = "COMPLETE" + + +def _stage_surface(*, portfolio_manifest: dict[str, Any], gate_verdict: dict[str, Any]) -> dict[str, Any]: + surface = {**gate_verdict, **portfolio_manifest} + if "verdict" not in surface and surface.get("universe_verdict") is not None: + surface["verdict"] = surface.get("universe_verdict") + return surface + + +def _price_basis_verified(surface: dict[str, Any]) -> bool: + price_basis = str(surface.get("price_basis") or "").lower() + status = str(surface.get("price_basis_status") or surface.get("price_basis_review_status") or "").upper() + decision_status = str(surface.get("decision_grade_return_status") or "") + return ( + price_basis in PRICE_BASIS_VERIFIED_VALUES + and status in PRICE_BASIS_VERIFIED_STATUSES + and not decision_status.startswith("BLOCKED") + ) + + +def _universe_official_or_manual_verified(surface: dict[str, Any]) -> bool: + verdict = str(surface.get("verdict") or "") + review_status = surface.get("universe_review_status") + official_status = str(surface.get("official_metadata_status") or "") + coverage_status = str(surface.get("official_metadata_coverage_status") or "") + certification_status = str(surface.get("universe_certification_status") or "") + return ( + verdict == UNIVERSE_VERIFIED_VERDICT + and (review_status is None or str(review_status) == UNIVERSE_VERIFIED_VERDICT) + and official_status == OFFICIAL_METADATA_VERIFIED_STATUS + and coverage_status == OFFICIAL_METADATA_COMPLETE_COVERAGE + and certification_status == UNIVERSE_VERIFIED_VERDICT + ) + + + +def _registry_effective_blockers( + *, + portfolio_verdict: dict[str, Any], + gate_verdict: dict[str, Any], + portfolio_manifest: dict[str, Any], + baseline_comparison: dict[str, Any], + baseline_comparison_missing: bool, + policy_nav_missing: bool, + policy_nav_rows: list[dict[str, str]], + policy_nav_numeric_invalid: bool, +) -> list[str]: + blockers: list[str] = [] + stage_surface = _stage_surface(portfolio_manifest=portfolio_manifest, gate_verdict=gate_verdict) + if not _price_basis_verified(stage_surface): + blockers.append("D0_PRICE_BASIS_NOT_VERIFIED") + if not _universe_official_or_manual_verified(stage_surface): + blockers.append("D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED") + baseline_delta = _strict_float(baseline_comparison.get("delta_vs_best_d3_total_net_return")) + gate_reasons = {str(reason) for reason in gate_verdict.get("reasons") or []} + if baseline_comparison_missing or baseline_delta is None: + blockers.append("D3_BASELINE_EVIDENCE_MISSING") + elif baseline_delta < 0: + blockers.append("D3_BASELINE_NOT_PROMOTABLE") + elif "RL_POLICY_UNDERPERFORMS_D3_BASELINE" in gate_reasons: + blockers.append("D3_BASELINE_NOT_PROMOTABLE") + if portfolio_verdict.get("implementation_unlocked") is not True: + blockers.append("D4_IMPLEMENTATION_NOT_UNLOCKED") + if gate_verdict.get("status") != "PASS" or gate_verdict.get("model_build_allowed") is not True: + blockers.append("D5_WALK_FORWARD_NOT_PASS") + if policy_nav_missing or not policy_nav_rows: + blockers.append("D9_POLICY_NAV_EVIDENCE_MISSING") + elif policy_nav_numeric_invalid: + blockers.append("D9_POLICY_NAV_NUMERIC_EVIDENCE_INVALID") + return sorted(dict.fromkeys(blockers)) + +def _registry_reasons( + *, + portfolio_verdict: dict[str, Any], + gate_verdict: dict[str, Any], + portfolio_manifest: dict[str, Any], +) -> list[str]: + stage_surface = _stage_surface(portfolio_manifest=portfolio_manifest, gate_verdict=gate_verdict) + reasons: list[str] = [] + reasons.extend(str(reason) for reason in gate_verdict.get("reasons") or []) + if _bool_false(gate_verdict.get("model_build_allowed")): + reasons.append("MODEL_BUILD_LOCKED_BY_D5_GATE") + if _bool_false(portfolio_verdict.get("implementation_unlocked")): + reasons.append("D4_IMPLEMENTATION_LOCKED_RESEARCH_ONLY") + if not _price_basis_verified(stage_surface): + if str(stage_surface.get("price_basis") or "").lower() == "unknown": + reasons.append("PRICE_BASIS_UNKNOWN") + else: + reasons.append("PRICE_BASIS_NOT_VERIFIED") + if not _universe_official_or_manual_verified(stage_surface): + if str(stage_surface.get("universe_review_status") or "").startswith("WATCH"): + reasons.append("UNIVERSE_WATCH_HEURISTIC") + else: + reasons.append("UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED") + reasons.append("NO_LIVE_BROKER_ORDER_SURFACE") + return sorted(dict.fromkeys(reasons)) + + +def _build_realized_returns(policy_nav_rows: list[dict[str, str]]) -> list[dict[str, Any]]: + realized: list[dict[str, Any]] = [] + previous_nav: float | None = None + for row in policy_nav_rows: + nav = _strict_float(row.get("policy_nav")) + reward = _strict_float(row.get("policy_reward")) + current_drawdown = _strict_float(row.get("policy_current_drawdown")) + numeric_errors: list[str] = [] + if nav is None: + numeric_errors.append("INVALID_POLICY_NAV") + if reward is None and row.get("policy_reward") not in (None, ""): + numeric_errors.append("INVALID_POLICY_REWARD") + if current_drawdown is None and row.get("policy_current_drawdown") not in (None, ""): + numeric_errors.append("INVALID_POLICY_CURRENT_DRAWDOWN") + realized_return = None if nav is None or previous_nav in (None, 0.0) else (nav / previous_nav) - 1.0 + if nav is not None: + previous_nav = nav + realized.append( + { + "date": row.get("date"), + "split": row.get("split"), + "paper_nav": nav, + "realized_return": realized_return, + "policy_reward": reward, + "current_drawdown": current_drawdown, + "evidence_status": "BLOCKED_NUMERIC_EVIDENCE" if numeric_errors else "COMPLETE_NUMERIC_EVIDENCE", + "numeric_error": ";".join(numeric_errors), + "source": "policy_nav_research_artifact_not_live_trade", + } + ) + return realized + + +def _build_drawdown_rows(policy_nav_rows: list[dict[str, str]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + peak: float | None = None + for row in policy_nav_rows: + nav = _strict_float(row.get("policy_nav")) + reported = _strict_float(row.get("policy_current_drawdown")) + numeric_errors: list[str] = [] + if nav is None: + numeric_errors.append("INVALID_POLICY_NAV") + computed = None + else: + peak = nav if peak is None else max(peak, nav) + computed = 0.0 if not peak else (nav / peak) - 1.0 + if reported is None and row.get("policy_current_drawdown") not in (None, ""): + numeric_errors.append("INVALID_POLICY_CURRENT_DRAWDOWN") + paper_forward_drawdown = reported if reported is not None else computed + rows.append( + { + "date": row.get("date"), + "split": row.get("split"), + "paper_nav": nav, + "paper_forward_drawdown": paper_forward_drawdown, + "computed_drawdown": computed, + "evidence_status": "BLOCKED_NUMERIC_EVIDENCE" if numeric_errors else "COMPLETE_NUMERIC_EVIDENCE", + "numeric_error": ";".join(numeric_errors), + "source": "research_policy_nav_not_live_account", + } + ) + return rows + + +def _policy_nav_numeric_invalid(policy_nav_rows: list[dict[str, str]]) -> bool: + for row in policy_nav_rows: + if _strict_float(row.get("policy_nav")) is None: + return True + if row.get("policy_reward") not in (None, "") and _strict_float(row.get("policy_reward")) is None: + return True + if row.get("policy_current_drawdown") not in (None, "") and _strict_float(row.get("policy_current_drawdown")) is None: + return True + return False + +def _blocked_realized_return_row(reason: str) -> dict[str, Any]: + return { + "date": "", + "split": "", + "paper_nav": None, + "realized_return": None, + "policy_reward": None, + "current_drawdown": None, + "evidence_status": "BLOCKED_MISSING_POLICY_NAV", + "numeric_error": reason, + "source": "missing_policy_nav_research_artifact_not_live_trade", + } + + +def _blocked_drawdown_row(reason: str) -> dict[str, Any]: + return { + "date": "", + "split": "", + "paper_nav": None, + "paper_forward_drawdown": None, + "computed_drawdown": None, + "evidence_status": "BLOCKED_MISSING_POLICY_NAV", + "numeric_error": reason, + "source": "missing_policy_nav_research_artifact_not_live_account", + } + + +def _build_paper_selection_rows( + *, + gate_verdict: dict[str, Any], + portfolio_manifest: dict[str, Any], + reasons: list[str], + model_build_allowed: bool, +) -> list[dict[str, Any]]: + selected_strategy = gate_verdict.get("selected_strategy") or portfolio_manifest.get("score_column") or "tabular_q_constrained_daily_portfolio_rl" + if model_build_allowed: + return [ + { + "date": gate_verdict.get("generated_at") or "", + "code": "", + "rank": "", + "paper_weight": 0, + "paper_only_selected": True, + "selection_status": "PAPER_ONLY_PLANNING_ALLOWED_NOT_LIVE", + "strategy": selected_strategy, + "reason": "MODEL_BUILD_GATE_TRUE_BUT_LIVE_BROKER_ORDERS_STILL_DISABLED", + } + ] + selection_status = "BLOCKED_BY_D5_NO_GO" if gate_verdict.get("status") != "PASS" else "BLOCKED_BY_EFFECTIVE_RESEARCH_GATE" + return [ + { + "date": gate_verdict.get("generated_at") or "", + "code": "", + "rank": "", + "paper_weight": 0, + "paper_only_selected": False, + "selection_status": selection_status, + "strategy": selected_strategy, + "reason": ";".join(reasons), + } + ] + + +def _build_drift_rows( + *, + portfolio_manifest: dict[str, Any], + gate_verdict: dict[str, Any], + config_hash: str, + data_hash: str, + code_hash: str, + effective_blockers: list[str], +) -> list[dict[str, Any]]: + stage_surface = _stage_surface(portfolio_manifest=portfolio_manifest, gate_verdict=gate_verdict) + price_basis = stage_surface.get("price_basis") or "unknown" + universe_status = stage_surface.get("universe_review_status") or stage_surface.get("verdict") or "unknown" + price_basis_verified = _price_basis_verified(stage_surface) + universe_verified = _universe_official_or_manual_verified(stage_surface) + return [ + { + "metric": "price_basis", + "value": price_basis, + "reference": "verified adjusted/raw/split/dividend basis required", + "status": "PASS" if price_basis_verified else "BLOCKED", + "action": "keep model_build_allowed=false until price basis is verified", + }, + { + "metric": "universe_review_status", + "value": universe_status, + "reference": "official KRX/manual metadata validation required", + "status": "PASS" if universe_verified else "WATCH", + "action": "keep heuristic universe warning visible until official/manual review evidence is complete", + }, + { + "metric": "d5_gate_status", + "value": gate_verdict.get("status") or "unknown", + "reference": "NO-GO blocks model build", + "status": "BLOCKED" if gate_verdict.get("status") != "PASS" else "PASS", + "action": "block promotion when D5 is not PASS", + }, + { + "metric": "model_build_allowed", + "value": not effective_blockers, + "reference": "strict D0/D1/D3/D4/D5 effective gates", + "status": "BLOCKED" if effective_blockers else "PASS", + "action": "no trained model build or deployable readiness claim", + }, + { + "metric": "effective_model_gate", + "value": "PASS" if not effective_blockers else ";".join(effective_blockers), + "reference": "cross-stage D0/D1/D3/D4/D5 lock reasons", + "status": "PASS" if not effective_blockers else "BLOCKED", + "action": "paper-forward remains blocked until every effective blocker is cleared", + }, + {"metric": "config_hash", "value": config_hash, "reference": "registry reproducibility", "status": "TRACKED", "action": "compare before paper-forward continuation"}, + {"metric": "data_hash", "value": data_hash, "reference": "source artifact reproducibility", "status": "TRACKED", "action": "compare before paper-forward continuation"}, + {"metric": "code_hash", "value": code_hash, "reference": "source implementation reproducibility", "status": "TRACKED", "action": "compare before paper-forward continuation"}, + ] + + +def build_daily_registry( + *, + portfolio_run_dir: Path | str | None = None, + walk_forward_run_dir: Path | str | None = None, +) -> dict[str, Any]: + """Build a research-only registry payload from the latest daily RL evidence runs.""" + + portfolio_dir = _safe_resolve_dir(portfolio_run_dir) if portfolio_run_dir else _latest_run_dir(DEFAULT_PORTFOLIO_ROOT, "rl_manifest.json") + walk_dir = _safe_resolve_dir(walk_forward_run_dir) if walk_forward_run_dir else _latest_run_dir(DEFAULT_WALK_FORWARD_ROOT, "walk_forward_manifest.json") + + portfolio_manifest = _read_json(portfolio_dir / "rl_manifest.json") + portfolio_verdict = _optional_json(portfolio_dir / "verdict.json") or portfolio_manifest.get("verdict", {}) + baseline_comparison_path = portfolio_dir / "baseline_comparison.json" + baseline_comparison = _optional_json(baseline_comparison_path) + policy_evaluation = _optional_json(portfolio_dir / "policy_evaluation_manifest.json") + walk_manifest = _read_json(walk_dir / "walk_forward_manifest.json") + gate_verdict = _optional_json(walk_dir / "gate_verdict.json") or walk_manifest.get("verdict", {}) + + file_hashes = { + "portfolio_rl_manifest": _sha256_file(portfolio_dir / "rl_manifest.json"), + "portfolio_verdict": _sha256_file(portfolio_dir / "verdict.json"), + "portfolio_baseline_comparison": _sha256_file(portfolio_dir / "baseline_comparison.json"), + "portfolio_policy_nav": _sha256_file(portfolio_dir / "policy_nav.csv"), + "walk_forward_manifest": _sha256_file(walk_dir / "walk_forward_manifest.json"), + "walk_forward_gate_verdict": _sha256_file(walk_dir / "gate_verdict.json"), + "walk_forward_fold_metrics": _sha256_file(walk_dir / "fold_metrics.csv"), + } + policy_nav_path = portfolio_dir / "policy_nav.csv" + policy_nav_missing = not policy_nav_path.exists() + policy_nav_rows = _read_csv_rows(policy_nav_path) + policy_nav_numeric_invalid = _policy_nav_numeric_invalid(policy_nav_rows) if policy_nav_rows else False + source_hashes = _source_hashes() + config_hash = _sha256_payload( + { + "portfolio_manifest": portfolio_manifest, + "portfolio_verdict": portfolio_verdict, + "baseline_comparison": baseline_comparison, + "policy_evaluation": policy_evaluation, + "walk_forward_manifest": walk_manifest, + "gate_verdict": gate_verdict, + } + ) + data_hash = _sha256_payload( + { + "prediction_manifest_sha": portfolio_manifest.get("prediction_manifest_sha"), + "portfolio_dir": portfolio_dir.name, + "walk_forward_dir": walk_dir.name, + "file_hashes": file_hashes, + } + ) + code_hash = _sha256_payload(source_hashes) + effective_blockers = _registry_effective_blockers( + portfolio_verdict=portfolio_verdict, + gate_verdict=gate_verdict, + portfolio_manifest=portfolio_manifest, + baseline_comparison=baseline_comparison, + baseline_comparison_missing=not baseline_comparison_path.exists(), + policy_nav_missing=policy_nav_missing, + policy_nav_rows=policy_nav_rows, + policy_nav_numeric_invalid=policy_nav_numeric_invalid, + ) + reasons = _registry_reasons(portfolio_verdict=portfolio_verdict, gate_verdict=gate_verdict, portfolio_manifest=portfolio_manifest) + reasons = sorted(dict.fromkeys([*reasons, *effective_blockers])) + model_build_allowed = not effective_blockers + promotion_status = "PAPER_ONLY_REVIEW_REQUIRED" if model_build_allowed else "BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER" + stage_surface = _stage_surface(portfolio_manifest=portfolio_manifest, gate_verdict=gate_verdict) + candidate = { + "candidate_id": portfolio_manifest.get("run_id") or portfolio_dir.name, + "strategy": baseline_comparison.get("policy_strategy") or "tabular_q_constrained_daily_portfolio_rl", + "portfolio_run_id": portfolio_manifest.get("run_id") or portfolio_dir.name, + "walk_forward_run_id": walk_manifest.get("run_id") or walk_dir.name, + "promotion_status": promotion_status, + "model_build_allowed": model_build_allowed, + "paper_forward_allowed": model_build_allowed, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "config_hash": config_hash, + "data_hash": data_hash, + "code_hash": code_hash, + "file_hashes": file_hashes, + "source_hashes": source_hashes, + "price_basis": stage_surface.get("price_basis") or "unknown", + "price_basis_status": stage_surface.get("price_basis_status") or stage_surface.get("price_basis_review_status"), + "decision_grade_return_status": stage_surface.get("decision_grade_return_status"), + "verdict": stage_surface.get("verdict") or stage_surface.get("universe_review_status"), + "universe_review_status": stage_surface.get("universe_review_status"), + "official_metadata_status": stage_surface.get("official_metadata_status"), + "official_metadata_coverage_status": stage_surface.get("official_metadata_coverage_status"), + "universe_certification_status": stage_surface.get("universe_certification_status"), + "d4_status": portfolio_verdict.get("status") or portfolio_manifest.get("status"), + "d5_status": gate_verdict.get("status"), + "cost_round_trip_bp": gate_verdict.get("cost_round_trip_bp") or baseline_comparison.get("cost_round_trip_bp") or ROUND_TRIP_COST_BP, + "baseline_delta_vs_best_d3": baseline_comparison.get("delta_vs_best_d3_total_net_return"), + "reasons": reasons, + "effective_gate_blockers": effective_blockers, + } + if policy_nav_missing: + realized_returns = [_blocked_realized_return_row("POLICY_NAV_CSV_MISSING")] + drawdown_rows = [_blocked_drawdown_row("POLICY_NAV_CSV_MISSING")] + elif not policy_nav_rows: + realized_returns = [_blocked_realized_return_row("POLICY_NAV_CSV_EMPTY")] + drawdown_rows = [_blocked_drawdown_row("POLICY_NAV_CSV_EMPTY")] + else: + realized_returns = _build_realized_returns(policy_nav_rows) + drawdown_rows = _build_drawdown_rows(policy_nav_rows) + paper_selected = _build_paper_selection_rows(gate_verdict=gate_verdict, portfolio_manifest=portfolio_manifest, reasons=reasons, model_build_allowed=model_build_allowed) + drift = _build_drift_rows( + portfolio_manifest=portfolio_manifest, + gate_verdict=gate_verdict, + config_hash=config_hash, + data_hash=data_hash, + code_hash=code_hash, + effective_blockers=effective_blockers, + ) + generated_at = _utc_now() + decision_log = [ + { + "timestamp": generated_at, + "event": "registry_created", + "candidate_id": candidate["candidate_id"], + "status": "RESEARCH_ONLY", + "detail": "Registry assembled from generated D4/D5 evidence artifacts only.", + }, + { + "timestamp": generated_at, + "event": "promotion_status_set", + "candidate_id": candidate["candidate_id"], + "status": promotion_status, + "reasons": reasons, + }, + { + "timestamp": generated_at, + "event": "live_broker_order_blocked", + "candidate_id": candidate["candidate_id"], + "status": "BLOCKED", + "detail": "Dashboard/API registry surface is read-only and cannot place orders.", + }, + ] + manifest = { + "schema_version": REGISTRY_SCHEMA_VERSION, + "generated_at": generated_at, + "status": "RESEARCH_ONLY_BLOCKED" if not model_build_allowed else "PAPER_ONLY_REVIEW_REQUIRED", + "guardrail": RESEARCH_GUARDRAIL, + "source_portfolio_run_dir": str(portfolio_dir), + "source_walk_forward_run_dir": str(walk_dir), + "portfolio_run_id": candidate["portfolio_run_id"], + "walk_forward_run_id": candidate["walk_forward_run_id"], + "promotion_status": promotion_status, + "model_build_allowed": model_build_allowed, + "paper_forward_allowed": model_build_allowed, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "cost_assumption_round_trip_bp": candidate["cost_round_trip_bp"], + "config_hash": config_hash, + "data_hash": data_hash, + "code_hash": code_hash, + "effective_gate_blockers": effective_blockers, + "source_hashes": source_hashes, + "row_counts": { + "candidate_registry_rows": 1, + "paper_selected_rows": len(paper_selected), + "realized_return_rows": len(realized_returns), + "drift_rows": len(drift), + "drawdown_rows": len(drawdown_rows), + "decision_log_rows": len(decision_log), + }, + } + return { + "manifest": manifest, + "candidate_registry": { + "schema_version": REGISTRY_SCHEMA_VERSION, + "generated_at": generated_at, + "status": manifest["status"], + "guardrail": RESEARCH_GUARDRAIL, + "candidates": [candidate], + }, + "paper_selected": paper_selected, + "realized_returns": realized_returns, + "drift": drift, + "drawdown": drawdown_rows, + "decision_log": decision_log, + } + + +def write_registry_artifacts( + result: dict[str, Any], + *, + run_id: str | None = None, + artifact_root: Path | str | None = None, + overwrite: bool = False, +) -> dict[str, Any]: + root = Path(artifact_root or DEFAULT_DAILY_REGISTRY_ROOT).resolve() + default_root = DEFAULT_DAILY_REGISTRY_ROOT.resolve() + try: + root.relative_to(default_root) + except ValueError: + if root != default_root: + raise ValueError("Daily OHLCV registry artifacts must stay under webui/rl_runs/daily_ohlcv_registry") + rid = _validate_run_id(run_id or f"registry_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}") + out_dir = (root / rid).resolve() + out_dir.relative_to(root) + if out_dir.exists() and any(out_dir.iterdir()) and not overwrite: + raise FileExistsError(f"Daily registry artifact run_id already exists: {rid}") + out_dir.mkdir(parents=True, exist_ok=True) + paths = { + "registry_manifest": out_dir / "registry_manifest.json", + "candidate_registry": out_dir / "candidate_registry.json", + "paper_selected": out_dir / "paper_selected.csv", + "realized_returns": out_dir / "realized_returns.csv", + "drift": out_dir / "drift.csv", + "drawdown": out_dir / "drawdown.csv", + "decision_log": out_dir / "decision_log.jsonl", + } + manifest = {**result["manifest"], "run_id": rid, "artifact_dir": str(out_dir), "artifacts": {key: str(path) for key, path in paths.items()}} + _write_json(paths["registry_manifest"], manifest) + _write_json(paths["candidate_registry"], result["candidate_registry"]) + _write_csv(paths["paper_selected"], result["paper_selected"], ["date", "code", "rank", "paper_weight", "paper_only_selected", "selection_status", "strategy", "reason"]) + _write_csv(paths["realized_returns"], result["realized_returns"], ["date", "split", "paper_nav", "realized_return", "policy_reward", "current_drawdown", "evidence_status", "numeric_error", "source"]) + _write_csv(paths["drift"], result["drift"], ["metric", "value", "reference", "status", "action"]) + _write_csv(paths["drawdown"], result["drawdown"], ["date", "split", "paper_nav", "paper_forward_drawdown", "computed_drawdown", "evidence_status", "numeric_error", "source"]) + _write_jsonl(paths["decision_log"], result["decision_log"]) + return {"run_id": rid, "artifact_dir": str(out_dir), **{f"{key}_path": str(path) for key, path in paths.items()}} + + +def run_and_write_daily_registry(*, run_id: str | None = None, overwrite: bool = False, **kwargs: Any) -> dict[str, Any]: + result = build_daily_registry(**kwargs) + receipt = write_registry_artifacts(result, run_id=run_id, overwrite=overwrite) + return {"result": result, "receipt": receipt} + + +__all__ = [ + "DEFAULT_DAILY_REGISTRY_ROOT", + "REGISTRY_SCHEMA_VERSION", + "RESEARCH_GUARDRAIL", + "build_daily_registry", + "run_and_write_daily_registry", + "write_registry_artifacts", +] diff --git a/stom_rl/daily_rl_train.py b/stom_rl/daily_rl_train.py new file mode 100644 index 000000000..2050fa58b --- /dev/null +++ b/stom_rl/daily_rl_train.py @@ -0,0 +1,1749 @@ +"""Research-only constrained daily portfolio RL runner. + +D4 uses a tiny tabular Q policy over the constrained daily portfolio environment. +The output is falsification/evidence for later walk-forward gates, not a live, +broker, order, profit, or deployable-RL claim. +""" + +from __future__ import annotations + +import csv +import hashlib +import json +import random +import re +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +from .daily_ohlcv_db import PRICE_BASIS, PRICE_BASIS_EVIDENCE, REPO_ROOT +from .daily_portfolio_env import ( + ACTION_NAMES, + OBSERVATION_MODE_V1, + DailyPortfolioEnv, + build_observation_manifest, + candidates_by_date, + validate_observation_manifest, +) +from .daily_prediction import DEFAULT_PREDICTION_ROOT, ROUND_TRIP_COST_BP + +DEFAULT_PORTFOLIO_ROOT = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_portfolio" +PORTFOLIO_SCHEMA_VERSION = 1 +SAFE_RUN_RE = re.compile(r"^[0-9A-Za-z_.-]+$") +SCORE_COLUMN = "score_supervised_linear_ranker" +FROZEN_BASELINE_STRATEGIES = [ + "no_trade_cash", + "shuffle_control", + "equal_weight_topk_momentum", + "vol_adjusted_momentum", + "supervised_linear_ranker", + "supervised_direction_classifier", +] +RESEARCH_GUARDRAIL = ( + "Research-only D4 constrained daily portfolio RL evidence; no profit guarantee, " + "no live/broker/orders, no deployable model readiness claim." +) +ENTRY_ACTION_IDS = {1, 2} +ACTION_FILTER_MODE_NONE = "none" +ACTION_FILTER_MODE_DISABLED = "disabled" +ACTION_FILTER_MODE_PRIOR_DISABLED_CONTROL = "prior_disabled_control_v1" +TRADE_QUALITY_FILTER_THRESHOLDS = { + "confidence_min_bucket": 3, + "margin_min_bucket": 3, + "risk_volatility_max_bucket": 3, +} +TRADE_QUALITY_FILTER_MODES = { + ACTION_FILTER_MODE_NONE, + ACTION_FILTER_MODE_DISABLED, + ACTION_FILTER_MODE_PRIOR_DISABLED_CONTROL, + "confidence_abstain_v1", + "margin_abstain_v1", + "confidence_margin_joint_v1", + "risk_regime_abstain_v1", +} + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _validate_run_id(run_id: str) -> str: + rid = str(run_id or "").strip() + if not SAFE_RUN_RE.match(rid) or rid in {".", ".."} or "/" in rid or "\\" in rid: + raise ValueError("run_id contains unsafe characters") + return rid + + +def _latest_run_dir(root: Path, required_file: str) -> Path: + candidates = sorted(root.glob(f"*/{required_file}"), key=lambda p: p.stat().st_mtime, reverse=True) + if not candidates: + raise FileNotFoundError(f"No {required_file} under {root}") + return candidates[0].parent + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() +def _source_hashes() -> dict[str, str]: + source_paths = { + "stom_rl/daily_rl_train.py": Path(__file__).resolve(), + "stom_rl/daily_portfolio_env.py": REPO_ROOT / "stom_rl" / "daily_portfolio_env.py", + "stom_rl/daily_prediction.py": REPO_ROOT / "stom_rl" / "daily_prediction.py", + } + return {name: _file_sha256(path) for name, path in source_paths.items()} + + + + +def _read_csv(path: Path) -> list[dict[str, Any]]: + with path.open("r", encoding="utf-8", newline="") as handle: + return [dict(row) for row in csv.DictReader(handle)] + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _write_csv(path: Path, rows: list[dict[str, Any]], fallback_fields: list[str]) -> None: + if rows: + field_set = {key for row in rows for key in row.keys()} + fields = [field for field in fallback_fields if field in field_set] + fields.extend(sorted(field_set - set(fields))) + else: + fields = fallback_fields + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def _resolve_prediction_run(prediction_run_dir: Path | str | None = None) -> Path: + if prediction_run_dir is not None: + run_dir = Path(prediction_run_dir).resolve() + root = DEFAULT_PREDICTION_ROOT.resolve() + run_dir.relative_to(root) + if not (run_dir / "prediction_manifest.json").exists(): + raise FileNotFoundError(run_dir / "prediction_manifest.json") + return run_dir + return _latest_run_dir(DEFAULT_PREDICTION_ROOT, "prediction_manifest.json") + + +def _max_drawdown(equity_values: Iterable[float]) -> float: + peak = 1.0 + max_dd = 0.0 + for value in equity_values: + peak = max(peak, value) + if peak: + max_dd = min(max_dd, value / peak - 1.0) + return max_dd + + +def _mean(values: Iterable[float]) -> float: + clean = [float(value) for value in values] + return sum(clean) / len(clean) if clean else 0.0 +def _optional_float(value: Any) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + + +def _policy_action( + q_table: dict[tuple[int, ...], list[float]], + state: tuple[int, ...], + mask: list[bool], + *, + epsilon: float, + rng: random.Random, + action_prior_values: list[float] | None = None, +) -> int: + valid_actions = [idx for idx, valid in enumerate(mask) if valid] + if not valid_actions: + return 0 + if rng.random() < epsilon: + return rng.choice(valid_actions) + values = q_table[state] + priors = action_prior_values or [0.0] * len(ACTION_NAMES) + return max(valid_actions, key=lambda action: values[action] + priors[action]) + + +def build_action_prior_values(*, mode: str = "none", strength: float = 0.0) -> list[float]: + """Return diagnostic action priors used only for policy selection, not rewards.""" + + clean_mode = str(mode or "none") + clean_strength = max(0.0, float(strength or 0.0)) + priors = [0.0] * len(ACTION_NAMES) + if clean_mode in {"none", "disabled"} or clean_strength == 0.0: + return priors + if clean_mode == "entry_bias_v1": + priors[1] = clean_strength + priors[2] = clean_strength + return priors + raise ValueError(f"Unsupported action_prior_mode: {clean_mode}") + + +def _clean_action_filter_mode(mode: str | None) -> str: + clean_mode = str(mode or ACTION_FILTER_MODE_NONE) + if clean_mode not in TRADE_QUALITY_FILTER_MODES: + raise ValueError(f"Unsupported action_filter_mode: {clean_mode}") + return clean_mode + + +def _action_filter_enabled(mode: str) -> bool: + return mode not in { + ACTION_FILTER_MODE_NONE, + ACTION_FILTER_MODE_DISABLED, + ACTION_FILTER_MODE_PRIOR_DISABLED_CONTROL, + } + + +def _required_filter_state_fields(mode: str) -> list[str]: + if mode == "confidence_abstain_v1": + return ["d3_confidence_bucket"] + if mode == "margin_abstain_v1": + return ["score_margin_bucket"] + if mode == "confidence_margin_joint_v1": + return ["d3_confidence_bucket", "score_margin_bucket"] + if mode == "risk_regime_abstain_v1": + return ["recent_score_volatility_bucket"] + return [] + + +def _require_filter_state_fields(mode: str, state_details: dict[str, Any]) -> None: + missing = [ + field + for field in _required_filter_state_fields(mode) + if field not in state_details or state_details.get(field) in (None, "") + ] + if missing: + raise ValueError( + "action_filter_mode requires action_induction_v2 state fields: " + + ",".join(missing) + ) + + +def _state_bucket(state_details: dict[str, Any], key: str) -> int: + try: + return int(state_details.get(key) or 0) + except (TypeError, ValueError): + return 0 + + +def build_action_filter_decision( + *, + mode: str = ACTION_FILTER_MODE_NONE, + state_details: dict[str, Any], + mask: list[bool], +) -> dict[str, Any]: + """Return causal D4 trade-quality filter decisions for buy/add actions. + + The filter reads only state_details fields already available before action + selection. It never reads future_return_1d or reward diagnostics. + """ + + clean_mode = _clean_action_filter_mode(mode) + filtered_mask = list(mask) + reasons_by_action = {name: "not_entry_action" for name in ACTION_NAMES.values()} + enabled = _action_filter_enabled(clean_mode) + if enabled: + _require_filter_state_fields(clean_mode, state_details) + d3_confidence_bucket = _state_bucket(state_details, "d3_confidence_bucket") + score_margin_bucket = _state_bucket(state_details, "score_margin_bucket") + recent_score_volatility_bucket = _state_bucket(state_details, "recent_score_volatility_bucket") + passed = True + primary_reason = "filter_disabled" + + if clean_mode == "confidence_abstain_v1": + passed = d3_confidence_bucket >= TRADE_QUALITY_FILTER_THRESHOLDS["confidence_min_bucket"] + primary_reason = ( + "pass_confidence_bucket_gte_threshold" + if passed + else "blocked_confidence_bucket_below_threshold" + ) + elif clean_mode == "margin_abstain_v1": + passed = score_margin_bucket >= TRADE_QUALITY_FILTER_THRESHOLDS["margin_min_bucket"] + primary_reason = ( + "pass_margin_bucket_gte_threshold" + if passed + else "blocked_margin_bucket_below_threshold" + ) + elif clean_mode == "confidence_margin_joint_v1": + confidence_pass = d3_confidence_bucket >= TRADE_QUALITY_FILTER_THRESHOLDS["confidence_min_bucket"] + margin_pass = score_margin_bucket >= TRADE_QUALITY_FILTER_THRESHOLDS["margin_min_bucket"] + passed = confidence_pass and margin_pass + primary_reason = ( + "pass_confidence_and_margin_thresholds" + if passed + else "blocked_confidence_or_margin_below_threshold" + ) + elif clean_mode == "risk_regime_abstain_v1": + passed = recent_score_volatility_bucket <= TRADE_QUALITY_FILTER_THRESHOLDS["risk_volatility_max_bucket"] + primary_reason = ( + "pass_recent_score_volatility_lte_threshold" + if passed + else "blocked_recent_score_volatility_above_threshold" + ) + elif clean_mode == ACTION_FILTER_MODE_PRIOR_DISABLED_CONTROL: + primary_reason = "prior_disabled_control_no_filter" + elif clean_mode == ACTION_FILTER_MODE_DISABLED: + primary_reason = "filter_disabled" + elif clean_mode == ACTION_FILTER_MODE_NONE: + primary_reason = "filter_disabled" + + for action_id in ENTRY_ACTION_IDS: + action_name = ACTION_NAMES[action_id] + if action_id >= len(mask) or not mask[action_id]: + reasons_by_action[action_name] = "base_action_mask_blocked" + continue + if enabled and not passed: + filtered_mask[action_id] = False + reasons_by_action[action_name] = primary_reason + else: + reasons_by_action[action_name] = primary_reason + + blocked_entry_actions = [ + ACTION_NAMES[action_id] + for action_id in ENTRY_ACTION_IDS + if action_id < len(mask) and bool(mask[action_id]) and not bool(filtered_mask[action_id]) + ] + return { + "mode": clean_mode, + "enabled": enabled, + "thresholds": dict(TRADE_QUALITY_FILTER_THRESHOLDS), + "filtered_mask": filtered_mask, + "reasons_by_action": reasons_by_action, + "primary_reason": primary_reason, + "entry_filter_passed": passed, + "blocked_entry_actions": blocked_entry_actions, + "future_label_exposed": False, + "state_buckets": { + "d3_confidence_bucket": d3_confidence_bucket, + "score_margin_bucket": score_margin_bucket, + "recent_score_volatility_bucket": recent_score_volatility_bucket, + }, + } + + +def _serialize_q_table(q_table: dict[tuple[int, ...], list[float]]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for state, values in sorted(q_table.items()): + rows.append( + { + "state_key": "|".join(str(part) for part in state), + "state_dim": len(state), + "position_count": state[0] if len(state) >= 1 else "", + "score_bucket": state[1] if len(state) >= 2 else "", + **{f"state_{index}": value for index, value in enumerate(state)}, + **{f"q_{ACTION_NAMES[index]}": value for index, value in enumerate(values)}, + } + ) + return rows + + +def build_learning_curve(episode_rows: list[dict[str, Any]], *, window: int = 3) -> list[dict[str, Any]]: + curve: list[dict[str, Any]] = [] + best_reward: float | None = None + rewards: list[float] = [] + for row in episode_rows: + reward = float(row.get("total_reward") or 0.0) + rewards.append(reward) + best_reward = reward if best_reward is None else max(best_reward, reward) + rolling = rewards[-max(1, int(window)) :] + steps = int(row.get("steps") or 0) + invalid_actions = int(row.get("invalid_actions") or 0) + curve.append( + { + "episode": int(row.get("episode") or len(curve) + 1), + "total_reward": reward, + "rolling_mean_reward": _mean(rolling), + "best_total_reward": best_reward, + "steps": steps, + "final_equity": float(row.get("final_equity") or 0.0), + "invalid_actions": invalid_actions, + "invalid_action_rate": invalid_actions / steps if steps else 0.0, + } + ) + return curve + + +def build_action_distribution(action_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + counts: dict[tuple[str, str, str, bool, str, bool], int] = defaultdict(int) + totals_by_split: dict[str, int] = defaultdict(int) + for row in action_rows: + split = str(row.get("split") or "unknown") + requested_action = str(row.get("requested_action") or row.get("action") or "unknown") + executed_action = str(row.get("executed_action") or row.get("action") or "unknown") + invalid = bool(row.get("invalid_action")) + invalid_reason = str(row.get("invalid_action_reason") or "") + no_trade_action = bool(row.get("no_trade_action")) + counts[(split, requested_action, executed_action, invalid, invalid_reason, no_trade_action)] += 1 + totals_by_split[split] += 1 + return [ + { + "split": split, + "action": executed_action, + "requested_action": requested_action, + "executed_action": executed_action, + "invalid_action": invalid, + "invalid_action_reason": invalid_reason, + "no_trade_action": no_trade_action, + "count": count, + "action_rate": count / totals_by_split[split] if totals_by_split[split] else 0.0, + } + for (split, requested_action, executed_action, invalid, invalid_reason, no_trade_action), count in sorted(counts.items()) + ] + + +def build_turnover_rows(reward_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + "split": row.get("split"), + "date": row.get("date"), + "action": row.get("action"), + "requested_action": row.get("requested_action"), + "executed_action": row.get("executed_action"), + "invalid_action_reason": row.get("invalid_action_reason"), + "no_trade_action": row.get("no_trade_action"), + "turnover": row.get("turnover"), + "turnover_cost": row.get("cost"), + "net_return_after_cost": row.get("net_return_after_cost"), + "churn_penalty": row.get("churn_penalty"), + "reward": row.get("reward"), + "equity": row.get("equity"), + } + for row in reward_rows + ] + + +def build_drawdown_rows(reward_rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + return [ + { + "split": row.get("split"), + "date": row.get("date"), + "action": row.get("action"), + "requested_action": row.get("requested_action"), + "executed_action": row.get("executed_action"), + "invalid_action_reason": row.get("invalid_action_reason"), + "no_trade_action": row.get("no_trade_action"), + "equity": row.get("equity"), + "current_drawdown": row.get("current_drawdown"), + "drawdown_penalty": row.get("drawdown_penalty"), + "reward_before_drawdown_penalty": row.get("reward_before_drawdown_penalty"), + "reward": row.get("reward"), + } + for row in reward_rows + ] + + +def build_reward_component_summary(reward_rows: list[dict[str, Any]]) -> dict[str, Any]: + component_keys = [ + "gross_return", + "cost", + "net_return_after_cost", + "exposure_penalty", + "concentration_penalty", + "invalid_action_penalty", + "churn_penalty", + "drawdown_penalty", + "no_trade_hold_reward", + "reward", + ] + by_split: dict[str, dict[str, float]] = {} + for row in reward_rows: + split = str(row.get("split") or "unknown") + bucket = by_split.setdefault( + split, + {key: 0.0 for key in component_keys} | {"rows": 0.0, "no_trade_actions": 0.0, "invalid_actions": 0.0}, + ) + bucket["rows"] += 1.0 + bucket["no_trade_actions"] += 1.0 if bool(row.get("no_trade_action")) else 0.0 + bucket["invalid_actions"] += 1.0 if bool(row.get("invalid_action")) else 0.0 + for key in component_keys: + bucket[key] += float(row.get(key) or 0.0) + return { + "component_keys": component_keys, + "by_split": [ + {"split": split, **values} + for split, values in sorted(by_split.items()) + ], + "guardrail": "Reward components are accounting/diagnostic telemetry only; they are not a profit, live, broker, order, or model-build claim.", + } + + +def build_reward_action_ablations( + reward_rows: list[dict[str, Any]], + action_rows: list[dict[str, Any]], +) -> list[dict[str, Any]]: + rows_by_split: dict[str, list[dict[str, Any]]] = defaultdict(list) + actions_by_split: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in reward_rows: + rows_by_split[str(row.get("split") or "unknown")].append(row) + for row in action_rows: + actions_by_split[str(row.get("split") or "unknown")].append(row) + + reward_scenarios = [ + ("recorded_reward", None, "Recorded constrained D4 reward after 23bp cost and penalties."), + ("without_turnover_cost", "cost", "Counterfactual removes the explicit 23bp turnover cost component."), + ("without_drawdown_penalty", "drawdown_penalty", "Counterfactual removes drawdown penalty only."), + ("without_concentration_penalty", "concentration_penalty", "Counterfactual removes concentration penalty only."), + ("without_churn_penalty", "churn_penalty", "Counterfactual removes turnover/churn penalty only."), + ("without_invalid_action_penalty", "invalid_action_penalty", "Counterfactual removes invalid-action penalty only."), + ] + + ablations: list[dict[str, Any]] = [] + for split in sorted(set(rows_by_split) | set(actions_by_split)): + split_rows = rows_by_split.get(split, []) + split_actions = actions_by_split.get(split, []) + recorded_rewards = [float(row.get("reward") or 0.0) for row in split_rows] + recorded_total = sum(recorded_rewards) + action_count = len(split_actions) + invalid_count = sum(1 for row in split_actions if bool(row.get("invalid_action"))) + no_trade_count = sum(1 for row in split_actions if bool(row.get("no_trade_action"))) + for name, add_back_key, description in reward_scenarios: + adjusted_rewards = [ + float(row.get("reward") or 0.0) + (float(row.get(add_back_key) or 0.0) if add_back_key else 0.0) + for row in split_rows + ] + total = sum(adjusted_rewards) + ablations.append( + { + "split": split, + "ablation_family": "reward_component", + "ablation": name, + "description": description, + "rows": len(adjusted_rewards), + "total_reward": total, + "mean_reward": total / len(adjusted_rewards) if adjusted_rewards else 0.0, + "delta_vs_recorded_reward": total - recorded_total, + "action_rows": action_count, + "invalid_action_count": invalid_count, + "invalid_action_rate": invalid_count / action_count if action_count else 0.0, + "no_trade_action_count": no_trade_count, + "no_trade_action_rate": no_trade_count / action_count if action_count else 0.0, + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + } + ) + + no_trade_rewards = [float(row.get("reward") or 0.0) for row in split_rows if bool(row.get("no_trade_action"))] + no_trade_total = sum(no_trade_rewards) + ablations.append( + { + "split": split, + "ablation_family": "action_subset", + "ablation": "observed_no_trade_hold_actions", + "description": "Observed hold-with-empty-book rows; diagnostic no-trade behavior, not a tuned control.", + "rows": len(no_trade_rewards), + "total_reward": no_trade_total, + "mean_reward": no_trade_total / len(no_trade_rewards) if no_trade_rewards else 0.0, + "delta_vs_recorded_reward": no_trade_total - recorded_total, + "action_rows": action_count, + "invalid_action_count": invalid_count, + "invalid_action_rate": invalid_count / action_count if action_count else 0.0, + "no_trade_action_count": no_trade_count, + "no_trade_action_rate": no_trade_count / action_count if action_count else 0.0, + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + } + ) + return ablations +def build_no_trade_opportunity_summary(opportunity_rows: list[dict[str, Any]]) -> dict[str, Any]: + """Summarize post-policy no-trade opportunity diagnostics without changing rewards.""" + + rows_by_split: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in opportunity_rows: + rows_by_split[str(row.get("split") or "unknown")].append(row) + + by_split: list[dict[str, Any]] = [] + for split, split_rows in sorted(rows_by_split.items()): + no_trade_rows = [row for row in split_rows if bool(row.get("no_trade_action"))] + labeled_no_trade_rows = [ + row + for row in no_trade_rows + if _optional_float(row.get("top_candidate_net_after_entry_cost")) is not None + ] + missed_rows = [ + row + for row in labeled_no_trade_rows + if (_optional_float(row.get("top_candidate_net_after_entry_cost")) or 0.0) > 0.0 + ] + avoided_rows = [ + row + for row in labeled_no_trade_rows + if (_optional_float(row.get("top_candidate_net_after_entry_cost")) or 0.0) < 0.0 + ] + flat_rows = [ + row + for row in labeled_no_trade_rows + if (_optional_float(row.get("top_candidate_net_after_entry_cost")) or 0.0) == 0.0 + ] + missed_net = [_optional_float(row.get("top_candidate_net_after_entry_cost")) or 0.0 for row in missed_rows] + avoided_net = [_optional_float(row.get("top_candidate_net_after_entry_cost")) or 0.0 for row in avoided_rows] + top_scores = [ + _optional_float(row.get("top_candidate_score")) + for row in no_trade_rows + if _optional_float(row.get("top_candidate_score")) is not None + ] + by_split.append( + { + "split": split, + "rows": len(split_rows), + "no_trade_rows": len(no_trade_rows), + "no_trade_rate": len(no_trade_rows) / len(split_rows) if split_rows else 0.0, + "labeled_no_trade_rows": len(labeled_no_trade_rows), + "missed_positive_no_trade_count": len(missed_rows), + "risk_avoided_no_trade_count": len(avoided_rows), + "flat_no_trade_count": len(flat_rows), + "total_missed_positive_net_after_entry_cost": sum(missed_net), + "mean_missed_positive_net_after_entry_cost": _mean(missed_net), + "total_risk_avoided_net_after_entry_cost": sum(avoided_net), + "mean_risk_avoided_net_after_entry_cost": _mean(avoided_net), + "mean_top_candidate_score_on_no_trade": _mean(score for score in top_scores if score is not None), + } + ) + + return { + "schema_version": PORTFOLIO_SCHEMA_VERSION, + "status": "POST_POLICY_DIAGNOSTIC_ONLY", + "guardrail": "No-trade opportunity diagnostics use future labels only after the policy decision for failure analysis; they are not training state, profit evidence, live readiness, broker readiness, or retuning permission.", + "training_state_uses_future_label": False, + "diagnostic_uses_future_label_after_policy": True, + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + "entry_cost_model": "top_candidate_future_return_1d minus one-position entry turnover share of 23bp round-trip cost; diagnostic only.", + "by_split": by_split, + } + + +def train_tabular_q_policy( + train_candidates: dict[str, list[Any]], + *, + episodes: int = 8, + seed: int = 7, + max_positions: int = 5, + alpha: float = 0.2, + gamma: float = 0.85, + epsilon: float = 0.15, + observation_mode: str = OBSERVATION_MODE_V1, + action_prior_mode: str = "none", + action_prior_strength: float = 0.0, + action_filter_mode: str = ACTION_FILTER_MODE_NONE, +) -> tuple[dict[tuple[int, ...], list[float]], list[dict[str, Any]]]: + rng = random.Random(seed) + action_prior_values = build_action_prior_values(mode=action_prior_mode, strength=action_prior_strength) + q_table: dict[tuple[int, ...], list[float]] = defaultdict(lambda: [0.0] * len(ACTION_NAMES)) + episode_rows: list[dict[str, Any]] = [] + for episode in range(1, int(episodes) + 1): + env = DailyPortfolioEnv(train_candidates, max_positions=max_positions, observation_mode=observation_mode) + state = env.reset() + total_reward = 0.0 + steps = 0 + while not env.done(): + mask = env.action_mask() + filter_decision = build_action_filter_decision( + mode=action_filter_mode, + state_details=env.state_details(), + mask=mask, + ) + filtered_mask = list(filter_decision["filtered_mask"]) + action = _policy_action(q_table, state, filtered_mask, epsilon=epsilon, rng=rng, action_prior_values=action_prior_values) + next_state, reward, done, _info = env.step(action) + best_next = max(q_table[next_state]) if not done else 0.0 + q_table[state][action] += alpha * (reward + gamma * best_next - q_table[state][action]) + total_reward += reward + steps += 1 + state = next_state + episode_rows.append( + { + "episode": episode, + "total_reward": total_reward, + "steps": steps, + "final_equity": env.equity, + "invalid_actions": env.invalid_actions, + "mean_reward": total_reward / steps if steps else 0.0, + "invalid_action_rate": env.invalid_actions / steps if steps else 0.0, + "observation_mode": observation_mode, + "action_prior_mode": action_prior_mode, + "action_prior_strength": float(action_prior_strength), + "action_filter_mode": action_filter_mode, + } + ) + return dict(q_table), episode_rows + + +def evaluate_policy( + candidates: dict[str, list[Any]], + q_table: dict[tuple[int, ...], list[float]], + *, + split_label: str, + max_positions: int = 5, + observation_mode: str = OBSERVATION_MODE_V1, + action_prior_mode: str = "none", + action_prior_strength: float = 0.0, + action_filter_mode: str = ACTION_FILTER_MODE_NONE, +) -> dict[str, Any]: + env = DailyPortfolioEnv(candidates, max_positions=max_positions, observation_mode=observation_mode) + action_prior_values = build_action_prior_values(mode=action_prior_mode, strength=action_prior_strength) + state = env.reset() + positions_rows: list[dict[str, Any]] = [] + invalid_rows: list[dict[str, Any]] = [] + reward_rows: list[dict[str, Any]] = [] + state_rows: list[dict[str, Any]] = [] + opportunity_rows: list[dict[str, Any]] = [] + abstention_rows: list[dict[str, Any]] = [] + equity_values: list[float] = [] + total_reward = 0.0 + + while not env.done(): + mask = env.action_mask() + state_details = env.state_details() + filter_decision = build_action_filter_decision( + mode=action_filter_mode, + state_details=state_details, + mask=mask, + ) + filtered_mask = list(filter_decision["filtered_mask"]) + current_candidates = env._current_candidates() + top_candidate = current_candidates[0] if current_candidates else None + top_candidate_future_return = _optional_float(top_candidate.future_return) if top_candidate else None + top_candidate_entry_cost = (1.0 / env.max_positions) * env.cost_rate if top_candidate else 0.0 + top_candidate_net_after_entry_cost = ( + top_candidate_future_return - top_candidate_entry_cost + if top_candidate_future_return is not None + else None + ) + position_count = int(state[0]) + exposure_fraction = position_count / env.max_positions + cash_fraction = max(0.0, 1.0 - exposure_fraction) + held_codes = "|".join(str(code).zfill(6) for code in env.positions) + values = q_table.get(state, [0.0] * len(ACTION_NAMES)) + valid_actions = [idx for idx, valid in enumerate(filtered_mask) if valid] + action = max(valid_actions or [0], key=lambda idx: values[idx] + action_prior_values[idx]) + next_state, reward, _done, info = env.step(action) + equity_values.append(float(info["equity"])) + total_reward += float(reward) + mask_text = "|".join(str(int(v)) for v in mask) + filter_mask_text = "|".join(str(int(v)) for v in filtered_mask) + mask_reasons = info.get("action_mask_reasons", {}) + mask_reason_fields = {f"mask_reason_{name}": str(mask_reasons.get(name, "")) for name in ACTION_NAMES.values()} + filter_reasons = filter_decision.get("reasons_by_action", {}) + filter_reason_fields = {f"filter_reason_{name}": str(filter_reasons.get(name, "")) for name in ACTION_NAMES.values()} + policy_value_fields = { + f"policy_value_{name}": values[index] + for index, name in ACTION_NAMES.items() + } + action_prior_fields = { + f"action_prior_{name}": action_prior_values[index] + for index, name in ACTION_NAMES.items() + } + policy_score_fields = { + f"policy_score_{name}": values[index] + action_prior_values[index] + for index, name in ACTION_NAMES.items() + } + state_rows.append( + { + "split": split_label, + "date": info["date"], + "observation_position_count": position_count, + "observation_top_score_bucket": int(state_details["top_score_bucket"]), + "observation_score_margin_bucket": state_details.get("score_margin_bucket", ""), + "observation_candidate_count_bucket": state_details.get("candidate_count_bucket", ""), + "observation_recent_score_volatility_bucket": state_details.get("recent_score_volatility_bucket", ""), + "observation_d3_confidence_bucket": state_details.get("d3_confidence_bucket", ""), + "observation_mode": observation_mode, + "observation_state_key": "|".join(str(part) for part in state), + "cash_fraction": cash_fraction, + "exposure_fraction": exposure_fraction, + "held_codes": held_codes, + "candidate_count": len(current_candidates), + "top_candidate_code": str(top_candidate.code).zfill(6) if top_candidate else "", + "top_candidate_rank": 1 if top_candidate else "", + "top_candidate_score": top_candidate.score if top_candidate else "", + "top_candidate_reward_label_available": bool(top_candidate.reward_label_available) if top_candidate else False, + "action_mask_hold_buy_add_sell_reduce": mask_text, + "future_label_exposed": False, + "action_prior_mode": action_prior_mode, + "action_prior_strength": float(action_prior_strength), + "action_filter_mode": action_filter_mode, + "action_filter_enabled": bool(filter_decision["enabled"]), + "action_filter_reason": filter_decision["primary_reason"], + "entry_filter_passed": bool(filter_decision["entry_filter_passed"]), + "blocked_entry_actions": "|".join(str(action_name) for action_name in filter_decision["blocked_entry_actions"]), + "filtered_action_mask_hold_buy_add_sell_reduce": filter_mask_text, + **mask_reason_fields, + **filter_reason_fields, + **policy_value_fields, + **action_prior_fields, + **policy_score_fields, + } + ) + blocked_entry_actions_text = "|".join(str(action_name) for action_name in filter_decision["blocked_entry_actions"]) + entry_abstained_by_filter = bool(blocked_entry_actions_text) and info["executed_action"] not in {"buy", "add"} + abstention_rows.append( + { + "split": split_label, + "date": info["date"], + "action_filter_mode": action_filter_mode, + "action_filter_enabled": bool(filter_decision["enabled"]), + "action_filter_reason": filter_decision["primary_reason"], + "entry_filter_passed": bool(filter_decision["entry_filter_passed"]), + "blocked_entry_actions": blocked_entry_actions_text, + "entry_abstained_by_filter": entry_abstained_by_filter, + "selected_action": ACTION_NAMES.get(action, "unknown"), + "requested_action": info["requested_action"], + "executed_action": info["executed_action"], + "base_action_mask_hold_buy_add_sell_reduce": mask_text, + "filtered_action_mask_hold_buy_add_sell_reduce": filter_mask_text, + "base_buy_valid": bool(mask[1]) if len(mask) > 1 else False, + "base_add_valid": bool(mask[2]) if len(mask) > 2 else False, + "filtered_buy_valid": bool(filtered_mask[1]) if len(filtered_mask) > 1 else False, + "filtered_add_valid": bool(filtered_mask[2]) if len(filtered_mask) > 2 else False, + "observation_d3_confidence_bucket": state_details.get("d3_confidence_bucket", ""), + "observation_score_margin_bucket": state_details.get("score_margin_bucket", ""), + "observation_recent_score_volatility_bucket": state_details.get("recent_score_volatility_bucket", ""), + "future_label_exposed": False, + "guardrail": "decision_time_trade_quality_filter_no_future_label_no_profit_claim", + **filter_reason_fields, + } + ) + + invalid_rows.append( + { + "split": split_label, + "date": info["date"], + "action": info["action"], + "requested_action": info["requested_action"], + "executed_action": info["executed_action"], + "invalid_action": bool(info["invalid_action"]), + "invalid_action_reason": info["invalid_action_reason"] or "", + "no_trade_action": bool(info["no_trade_action"]), + "action_mask_hold_buy_add_sell_reduce": mask_text, + **mask_reason_fields, + } + ) + reward_rows.append( + { + "split": split_label, + "date": info["date"], + "action": info["action"], + "requested_action": info["requested_action"], + "executed_action": info["executed_action"], + "invalid_action": bool(info["invalid_action"]), + "invalid_action_reason": info["invalid_action_reason"] or "", + "no_trade_action": bool(info["no_trade_action"]), + "gross_return": info["gross_return"], + "cost": info["cost"], + "net_return_after_cost": info["net_return_after_cost"], + "slippage_cost": 0.0, + "turnover": info["turnover"], + "exposure": info["exposure"], + "concentration": info["concentration"], + "exposure_penalty": info["exposure_penalty"], + "concentration_penalty": info["concentration_penalty"], + "invalid_action_penalty": info["invalid_action_penalty"], + "reward": info["reward"], + "churn_penalty": info["churn_penalty"], + "drawdown_penalty": info["drawdown_penalty"], + "no_trade_hold_reward": info["no_trade_hold_reward"], + "current_drawdown": info["current_drawdown"], + "reward_before_drawdown_penalty": info["reward_before_drawdown_penalty"], + "equity": info["equity"], + "missing_reward_label_count": info["missing_reward_label_count"], + **mask_reason_fields, + } + ) + if not bool(info["no_trade_action"]): + no_trade_diagnostic = "NOT_NO_TRADE_ACTION" + elif top_candidate_future_return is None: + no_trade_diagnostic = "NO_REWARD_LABEL_FOR_TOP_CANDIDATE" + elif (top_candidate_net_after_entry_cost or 0.0) > 0.0: + no_trade_diagnostic = "NO_TRADE_MISSED_POSITIVE_TOP_CANDIDATE_AFTER_ENTRY_COST" + elif (top_candidate_net_after_entry_cost or 0.0) < 0.0: + no_trade_diagnostic = "NO_TRADE_AVOIDED_NEGATIVE_TOP_CANDIDATE_AFTER_ENTRY_COST" + else: + no_trade_diagnostic = "NO_TRADE_TOP_CANDIDATE_FLAT_AFTER_ENTRY_COST" + opportunity_rows.append( + { + "split": split_label, + "date": info["date"], + "action": info["action"], + "requested_action": info["requested_action"], + "executed_action": info["executed_action"], + "no_trade_action": bool(info["no_trade_action"]), + "observation_position_count": position_count, + "candidate_count": len(current_candidates), + "top_candidate_code": str(top_candidate.code).zfill(6) if top_candidate else "", + "top_candidate_score": top_candidate.score if top_candidate else "", + "top_candidate_reward_label_available": bool(top_candidate.reward_label_available) if top_candidate else False, + "top_candidate_future_return_1d": top_candidate_future_return if top_candidate_future_return is not None else "", + "top_candidate_entry_cost": top_candidate_entry_cost if top_candidate else "", + "top_candidate_net_after_entry_cost": top_candidate_net_after_entry_cost if top_candidate_net_after_entry_cost is not None else "", + "no_trade_opportunity_flag": bool(info["no_trade_action"]) + and top_candidate_net_after_entry_cost is not None + and top_candidate_net_after_entry_cost > 0.0, + "no_trade_risk_avoided_flag": bool(info["no_trade_action"]) + and top_candidate_net_after_entry_cost is not None + and top_candidate_net_after_entry_cost < 0.0, + "diagnostic": no_trade_diagnostic, + "diagnostic_future_label_exposed": top_candidate_future_return is not None, + "future_label_used_for_training_state": False, + "guardrail": "post_policy_failure_analysis_only_not_training_state_or_profit_claim", + } + ) + for rank, code in enumerate(info["positions"], start=1): + positions_rows.append( + { + "split": split_label, + "date": info["date"], + "rank": rank, + "code": str(code).zfill(6), + "action": info["action"], + "equity": info["equity"], + } + ) + state = next_state + + reward_values = [float(row["reward"]) for row in reward_rows] + turnover_values = [float(row["turnover"]) for row in reward_rows] + exposure_values = [float(row["exposure"]) for row in reward_rows] + concentration_values = [float(row["concentration"]) for row in reward_rows] + invalid_count = sum(1 for row in invalid_rows if row["invalid_action"]) + step_count = len(reward_rows) + metrics = { + "split": split_label, + "steps": step_count, + "position_rows": len(positions_rows), + "final_equity": env.equity, + "total_reward": total_reward, + "mean_daily_reward": _mean(reward_values), + "total_net_return": env.equity - 1.0, + "max_drawdown": _max_drawdown(equity_values), + "mean_turnover": _mean(turnover_values), + "mean_exposure": _mean(exposure_values), + "mean_slippage_cost": 0.0, + "mean_concentration": _mean(concentration_values), + "invalid_actions": invalid_count, + "invalid_action_rate": invalid_count / step_count if step_count else 0.0, + "max_current_drawdown": min((float(row["current_drawdown"]) for row in reward_rows), default=0.0), + } + return { + "metrics": metrics, + "positions": positions_rows, + "invalid_actions": invalid_rows, + "reward_breakdown": reward_rows, + "state_observations": state_rows, + "no_trade_opportunity_diagnostics": opportunity_rows, + "abstention_reasons": abstention_rows, + } + + +def _split_rows(rows: list[dict[str, Any]], split: str) -> list[dict[str, Any]]: + return [row for row in rows if str(row.get("split")) == split] + + +def _best_baseline(baseline_metrics: list[dict[str, Any]]) -> dict[str, Any] | None: + if not baseline_metrics: + return None + return max(baseline_metrics, key=lambda row: float(row.get("total_net_return") or 0.0)) + + +def _metric_for_strategy(baseline_metrics: list[dict[str, Any]], strategy: str) -> dict[str, Any] | None: + for row in baseline_metrics: + if row.get("strategy") == strategy: + return row + return None + + +def build_policy_baseline_comparison( + *, + policy_metrics: dict[str, Any], + baseline_metrics: list[dict[str, Any]], + required_strategies: list[str] | None = None, +) -> list[dict[str, Any]]: + required = required_strategies or FROZEN_BASELINE_STRATEGIES + rows: list[dict[str, Any]] = [] + policy_total = float(policy_metrics.get("total_net_return") or 0.0) + policy_mdd = float(policy_metrics.get("max_drawdown") or 0.0) + policy_turnover = float(policy_metrics.get("mean_turnover") or 0.0) + policy_concentration = float(policy_metrics.get("mean_concentration") or 0.0) + for strategy in required: + baseline = _metric_for_strategy(baseline_metrics, strategy) + if baseline is None: + rows.append( + { + "baseline_strategy": strategy, + "baseline_status": "MISSING", + "comparison_status": "MISSING_BASELINE", + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + } + ) + continue + baseline_total = float(baseline.get("total_net_return") or 0.0) + baseline_mdd = float(baseline.get("max_drawdown") or 0.0) + baseline_turnover = float(baseline.get("mean_turnover") or 0.0) + delta_total = policy_total - baseline_total + rows.append( + { + "baseline_strategy": strategy, + "baseline_family": baseline.get("strategy_family"), + "baseline_status": "LOADED", + "comparison_status": "POLICY_BEATS_BASELINE" if delta_total > 0 else "POLICY_UNDERPERFORMS_OR_TIES", + "policy_strategy": "tabular_q_constrained_daily_portfolio_rl", + "policy_split": policy_metrics.get("split"), + "policy_total_net_return": policy_total, + "policy_nav": 1.0 + policy_total, + "policy_max_drawdown": policy_mdd, + "policy_mean_turnover": policy_turnover, + "policy_mean_concentration": policy_concentration, + "baseline_total_net_return": baseline_total, + "baseline_nav": 1.0 + baseline_total, + "baseline_max_drawdown": baseline_mdd, + "baseline_mean_turnover": baseline_turnover, + "baseline_positions": baseline.get("positions"), + "baseline_delta_total_net_return": delta_total, + "baseline_delta_nav": delta_total, + "baseline_delta_max_drawdown": policy_mdd - baseline_mdd, + "baseline_delta_mean_turnover": policy_turnover - baseline_turnover, + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + } + ) + return rows + + +def build_policy_nav_rows(reward_rows: list[dict[str, Any]], *, split: str = "val+test") -> list[dict[str, Any]]: + return [ + { + "split": row.get("split"), + "date": row.get("date"), + "policy_nav": row.get("equity"), + "policy_reward": row.get("reward"), + "policy_turnover": row.get("turnover"), + "policy_concentration": row.get("concentration"), + "policy_current_drawdown": row.get("current_drawdown"), + } + for row in reward_rows + if row.get("split") == split + ] + + +def load_prediction_artifacts(prediction_run_dir: Path | str | None = None) -> dict[str, Any]: + run_dir = _resolve_prediction_run(prediction_run_dir) + manifest_path = run_dir / "prediction_manifest.json" + predictions_path = run_dir / "predictions.csv" + baseline_metrics_path = run_dir / "baseline_metrics.json" + verdict_path = run_dir / "verdict.json" + manifest = _read_json(manifest_path) + prediction_artifact_hashes = { + "prediction_manifest": _file_sha256(manifest_path), + "predictions": _file_sha256(predictions_path), + "baseline_metrics": _file_sha256(baseline_metrics_path), + "verdict": _file_sha256(verdict_path), + } + declared_hashes = manifest.get("artifact_hashes") if isinstance(manifest.get("artifact_hashes"), dict) else {} + hash_mismatches = [ + key + for key in ("predictions", "baseline_metrics", "verdict") + if declared_hashes.get(key) and declared_hashes.get(key) != prediction_artifact_hashes[key] + ] + return { + "run_dir": run_dir, + "manifest": manifest, + "prediction_manifest_sha256": prediction_artifact_hashes["prediction_manifest"], + "prediction_artifact_hashes": prediction_artifact_hashes, + "prediction_declared_artifact_hashes": dict(declared_hashes), + "prediction_artifact_hash_mismatches": hash_mismatches, + "predictions": _read_csv(predictions_path), + "baseline_metrics": _read_json(baseline_metrics_path).get("metrics", []), + "verdict": _read_json(verdict_path), + } + + +def _verdict_for_d4( + *, + prediction_verdict: dict[str, Any], + manifest: dict[str, Any], + eval_metrics: dict[str, Any], +) -> dict[str, Any]: + d3_status = str(prediction_verdict.get("status") or manifest.get("verdict", {}).get("status") or "UNKNOWN") + d3_status_normalized = d3_status.upper().replace("_", "-") + d3_blocked = d3_status_normalized in {"FAIL", "FAILED", "NO-GO", "NOGO", "BLOCK", "BLOCKED", "SKIPPED"} + gate_dependency = "D3_FAILED_OR_SKIPPED" if d3_blocked else "D3_WATCH_D5_NOT_RUN" + reasons = [ + "RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM", + "D5_WALK_FORWARD_NOT_RUN", + "PRICE_BASIS_UNKNOWN", + "UNIVERSE_WATCH_HEURISTIC", + "DAILY_RL_POLICY_IS_RESTRICTED_RESEARCH_PATH", + ] + if d3_blocked: + reasons.append("D3_FAILED_OR_SKIPPED_FORCE_RESEARCH_OVERRIDE") + return { + "schema_version": PORTFOLIO_SCHEMA_VERSION, + "status": "RESEARCH_ONLY", + "ui_badge": "RESEARCH_ONLY", + "go_summary_allowed": False, + "model_build_allowed": False, + "readiness_status": "D4_RESEARCH_ONLY_DIAGNOSTICS", + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "implementation_unlocked": False, + "research_override": bool(d3_blocked), + "gate_dependency": gate_dependency, + "d3_status": d3_status, + "d3_status_normalized": d3_status_normalized, + "requires_before_model_build": [ + "D5 walk-forward folds n>=5", + "shuffle controls", + "cost/slippage sensitivity", + "official/manual universe review", + "price-basis verification", + ], + "invalid_action_rate": eval_metrics.get("invalid_action_rate", 0.0), + "reasons": reasons, + } + + +def run_daily_rl( + *, + prediction_run_dir: Path | str | None = None, + score_column: str = SCORE_COLUMN, + candidate_limit: int = 20, + max_positions: int = 5, + episodes: int = 8, + seed: int = 7, + observation_mode: str = OBSERVATION_MODE_V1, + action_prior_mode: str = "none", + action_prior_strength: float = 0.0, + action_filter_mode: str = ACTION_FILTER_MODE_NONE, +) -> dict[str, Any]: + artifacts = load_prediction_artifacts(prediction_run_dir) + prediction_rows = artifacts["predictions"] + baseline_metrics = artifacts["baseline_metrics"] + run_dir: Path = artifacts["run_dir"] + + split_groups = { + split: candidates_by_date(_split_rows(prediction_rows, split), score_column=score_column, candidate_limit=candidate_limit) + for split in ("train", "val", "test") + } + q_table, episode_rows = train_tabular_q_policy( + split_groups["train"], + episodes=episodes, + seed=seed, + max_positions=max_positions, + observation_mode=observation_mode, + action_prior_mode=action_prior_mode, + action_prior_strength=action_prior_strength, + action_filter_mode=action_filter_mode, + ) + + combined_eval_candidates = {**split_groups["val"], **split_groups["test"]} + evaluations = [ + evaluate_policy(split_groups["train"], q_table, split_label="train", max_positions=max_positions, observation_mode=observation_mode, action_prior_mode=action_prior_mode, action_prior_strength=action_prior_strength, action_filter_mode=action_filter_mode), + evaluate_policy(split_groups["val"], q_table, split_label="val", max_positions=max_positions, observation_mode=observation_mode, action_prior_mode=action_prior_mode, action_prior_strength=action_prior_strength, action_filter_mode=action_filter_mode), + evaluate_policy(split_groups["test"], q_table, split_label="test", max_positions=max_positions, observation_mode=observation_mode, action_prior_mode=action_prior_mode, action_prior_strength=action_prior_strength, action_filter_mode=action_filter_mode), + evaluate_policy(combined_eval_candidates, q_table, split_label="val+test", max_positions=max_positions, observation_mode=observation_mode, action_prior_mode=action_prior_mode, action_prior_strength=action_prior_strength, action_filter_mode=action_filter_mode), + ] + metrics = [evaluation["metrics"] for evaluation in evaluations] + eval_metric = next(row for row in metrics if row["split"] == "val+test") + + positions: list[dict[str, Any]] = [] + invalid_actions: list[dict[str, Any]] = [] + reward_breakdown: list[dict[str, Any]] = [] + state_observations: list[dict[str, Any]] = [] + no_trade_opportunity_diagnostics: list[dict[str, Any]] = [] + abstention_reasons: list[dict[str, Any]] = [] + for evaluation in evaluations: + positions.extend(evaluation["positions"]) + invalid_actions.extend(evaluation["invalid_actions"]) + reward_breakdown.extend(evaluation["reward_breakdown"]) + state_observations.extend(evaluation["state_observations"]) + no_trade_opportunity_diagnostics.extend(evaluation["no_trade_opportunity_diagnostics"]) + abstention_reasons.extend(evaluation["abstention_reasons"]) + + learning_curve = build_learning_curve(episode_rows) + action_distribution = build_action_distribution(invalid_actions) + turnover_rows = build_turnover_rows(reward_breakdown) + drawdown_rows = build_drawdown_rows(reward_breakdown) + reward_component_summary = build_reward_component_summary(reward_breakdown) + policy_baseline_comparison = build_policy_baseline_comparison(policy_metrics=eval_metric, baseline_metrics=baseline_metrics) + policy_nav = build_policy_nav_rows(reward_breakdown) + reward_action_ablations = build_reward_action_ablations(reward_breakdown, invalid_actions) + no_trade_opportunity_summary = build_no_trade_opportunity_summary(no_trade_opportunity_diagnostics) + reward_action_ablation_summary = { + "schema_version": PORTFOLIO_SCHEMA_VERSION, + "guardrail": "Reward/action ablations are diagnostics for failure analysis only; they are not retuning, profit, live, broker, order, or deployable model evidence.", + "ablation_count": len(reward_action_ablations), + "ablation_names": sorted({str(row.get("ablation")) for row in reward_action_ablations}), + "splits": sorted({str(row.get("split")) for row in reward_action_ablations}), + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + } + source_hashes = _source_hashes() + clean_action_filter_mode = _clean_action_filter_mode(action_filter_mode) + policy_type = ( + "tabular_q_trade_quality_filter_v1" + if clean_action_filter_mode not in {ACTION_FILTER_MODE_NONE, ACTION_FILTER_MODE_DISABLED, ACTION_FILTER_MODE_PRIOR_DISABLED_CONTROL} + else "tabular_q_action_prior_v2" + if action_prior_mode != "none" + else "tabular_q" + ) + + best = _best_baseline(baseline_metrics) + equal_weight = _metric_for_strategy(baseline_metrics, "equal_weight_topk_momentum") + no_trade = _metric_for_strategy(baseline_metrics, "no_trade_cash") + baseline_total = float(best.get("total_net_return") or 0.0) if best else 0.0 + baseline_comparison = { + "policy_strategy": policy_type, + "policy_split": "val+test", + "policy_total_net_return": eval_metric["total_net_return"], + "policy_max_drawdown": eval_metric["max_drawdown"], + "policy_mean_turnover": eval_metric["mean_turnover"], + "best_d3_strategy": best.get("strategy") if best else None, + "best_d3_total_net_return": baseline_total, + "delta_vs_best_d3_total_net_return": eval_metric["total_net_return"] - baseline_total, + "equal_weight_topk_total_net_return": (equal_weight or {}).get("total_net_return"), + "no_trade_cash_total_net_return": (no_trade or {}).get("total_net_return"), + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + "slippage_assumption": "No separate daily slippage model is inferred from OHLCV; use 23bp round-trip cost and D5 sensitivity before any model-build claim.", + "comparison_note": "D4 is research-only and cannot unlock model build without D5 forward validation.", + "required_frozen_baselines": list(FROZEN_BASELINE_STRATEGIES), + "missing_frozen_baselines": [row["baseline_strategy"] for row in policy_baseline_comparison if row.get("baseline_status") == "MISSING"], + "frozen_baseline_comparison": policy_baseline_comparison, + } + + observation_manifest = build_observation_manifest( + max_positions=max_positions, + score_column=score_column, + candidate_limit=candidate_limit, + observation_mode=observation_mode, + action_prior_mode=action_prior_mode, + action_prior_strength=action_prior_strength, + action_filter_mode=clean_action_filter_mode, + ) + observation_manifest_validation = validate_observation_manifest(observation_manifest) + + verdict = _verdict_for_d4( + prediction_verdict=artifacts["verdict"], + manifest=artifacts["manifest"], + eval_metrics=eval_metric, + ) + manifest = { + "schema_version": PORTFOLIO_SCHEMA_VERSION, + "generated_at": _utc_now(), + "guardrail": RESEARCH_GUARDRAIL, + "prediction_run_dir": str(run_dir), + "prediction_manifest_sha": artifacts["prediction_manifest_sha256"], + "prediction_artifact_hashes": artifacts["prediction_artifact_hashes"], + "prediction_declared_artifact_hashes": artifacts["prediction_declared_artifact_hashes"], + "prediction_artifact_hash_mismatches": artifacts["prediction_artifact_hash_mismatches"], + "source_hashes": source_hashes, + "prediction_verdict_status": artifacts["verdict"].get("status"), + "score_column": score_column, + "action_space": ACTION_NAMES, + "invalid_action_constraint": "mask_valid_actions_only; invalid_action_rate is still emitted as a guardrail metric", + "candidate_limit": max(1, int(candidate_limit)), + "max_positions": max(1, int(max_positions)), + "episodes": int(episodes), + "seed": int(seed), + "observation_mode": observation_mode, + "policy_type": policy_type, + "action_prior_mode": action_prior_mode, + "action_prior_strength": float(action_prior_strength), + "action_filter_mode": clean_action_filter_mode, + "trade_quality_filter_thresholds": dict(TRADE_QUALITY_FILTER_THRESHOLDS), + "cost_assumption_round_trip_bp": ROUND_TRIP_COST_BP, + "slippage_assumption": "No separate daily slippage model is inferred from OHLCV; use 23bp round-trip cost and D5 sensitivity before any model-build claim.", + "price_basis": artifacts["manifest"].get("price_basis") or PRICE_BASIS, + "price_basis_evidence": artifacts["manifest"].get("price_basis_evidence") or PRICE_BASIS_EVIDENCE, + "universe_review_status": artifacts["manifest"].get("universe_review_status"), + "no_live_broker_order_readiness": True, + "go_summary_allowed": False, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "readiness_status": "D4_RESEARCH_ONLY_DIAGNOSTICS", + "observation_manifest": observation_manifest, + "observation_manifest_validation": observation_manifest_validation, + "state_contract_status": observation_manifest_validation["status"], + "row_counts": { + "prediction_rows": len(prediction_rows), + "position_rows": len(positions), + "reward_rows": len(reward_breakdown), + "invalid_action_rows": len(invalid_actions), + "state_observation_rows": len(state_observations), + "q_policy_rows": len(q_table), + "learning_curve_rows": len(learning_curve), + "action_distribution_rows": len(action_distribution), + "turnover_rows": len(turnover_rows), + "drawdown_rows": len(drawdown_rows), + "policy_baseline_comparison_rows": len(policy_baseline_comparison), + "policy_nav_rows": len(policy_nav), + "reward_action_ablation_rows": len(reward_action_ablations), + "no_trade_opportunity_diagnostic_rows": len(no_trade_opportunity_diagnostics), + "abstention_reason_rows": len(abstention_reasons), + }, + "telemetry": { + "status": "READY_RESEARCH_ONLY", + "training_status": "TABULAR_Q_TELEMETRY_RECORDED", + "visualization_stack": ["csv_artifacts", "flask_api_payload", "svelte_dashboard_cards", "plotly_compatible_series"], + "state_contract": "D4_OBSERVATION_STATE_MANIFEST_REQUIRED", + "canonical_artifacts": [ + "observation_manifest.json", + "state_observations.csv", + "training_manifest.json", + "episode_metrics.csv", + "learning_curve.csv", + "reward_breakdown.csv", + "reward_component_summary.json", + "action_distribution.csv", + "invalid_actions.csv", + "turnover.csv", + "drawdown.csv", + "policy_baseline_comparison.csv", + "policy_nav.csv", + "reward_action_ablations.csv", + "reward_action_ablation_summary.json", + "no_trade_opportunity_diagnostics.csv", + "no_trade_opportunity_summary.json", + "abstention_reasons.csv", + "source_hashes.json", + ], + "tensorboard_status": "NOT_EMITTED_DEPENDENCY_FREE_TABULAR_Q_RUN", + "sb3_monitor_status": "NOT_EMITTED_NON_SB3_TABULAR_Q_RUN", + "guardrail": "Telemetry visualizes learning/accounting diagnostics only; no profit, live, broker, order, or deployable model claim.", + }, + "verdict": verdict, + } + return { + "manifest": manifest, + "observation_manifest": observation_manifest, + "observation_manifest_validation": observation_manifest_validation, + "policy_metrics": {"metrics": metrics, "training_episodes": episode_rows, "q_policy_rows": _serialize_q_table(q_table)}, + "episode_metrics": episode_rows, + "positions": positions, + "invalid_actions": invalid_actions, + "reward_breakdown": reward_breakdown, + "state_observations": state_observations, + "learning_curve": learning_curve, + "action_distribution": action_distribution, + "turnover": turnover_rows, + "drawdown": drawdown_rows, + "reward_component_summary": reward_component_summary, + "reward_action_ablations": reward_action_ablations, + "reward_action_ablation_summary": reward_action_ablation_summary, + "no_trade_opportunity_diagnostics": no_trade_opportunity_diagnostics, + "no_trade_opportunity_summary": no_trade_opportunity_summary, + "abstention_reasons": abstention_reasons, + "policy_baseline_comparison": policy_baseline_comparison, + "policy_nav": policy_nav, + "baseline_comparison": baseline_comparison, + "source_hashes": source_hashes, + "verdict": verdict, + } + + +def write_rl_artifacts( + result: dict[str, Any], + *, + run_id: str | None = None, + artifact_root: Path | str | None = None, + overwrite: bool = False, +) -> dict[str, Any]: + root = Path(artifact_root or DEFAULT_PORTFOLIO_ROOT).resolve() + default_root = DEFAULT_PORTFOLIO_ROOT.resolve() + try: + root.relative_to(default_root) + except ValueError: + if root != default_root: + raise ValueError("Daily OHLCV portfolio artifacts must stay under webui/rl_runs/daily_ohlcv_portfolio") + rid = _validate_run_id(run_id or f"portfolio_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}") + out_dir = (root / rid).resolve() + out_dir.relative_to(root) + if out_dir.exists() and any(out_dir.iterdir()) and not overwrite: + raise FileExistsError(f"Portfolio artifact run_id already exists: {rid}") + out_dir.mkdir(parents=True, exist_ok=True) + paths = { + "rl_manifest": out_dir / "rl_manifest.json", + "observation_manifest": out_dir / "observation_manifest.json", + "training_manifest": out_dir / "training_manifest.json", + "policy_metrics": out_dir / "policy_metrics.json", + "episode_metrics": out_dir / "episode_metrics.csv", + "positions": out_dir / "positions.csv", + "invalid_actions": out_dir / "invalid_actions.csv", + "reward_breakdown": out_dir / "reward_breakdown.csv", + "state_observations": out_dir / "state_observations.csv", + "learning_curve": out_dir / "learning_curve.csv", + "action_distribution": out_dir / "action_distribution.csv", + "turnover": out_dir / "turnover.csv", + "drawdown": out_dir / "drawdown.csv", + "reward_component_summary": out_dir / "reward_component_summary.json", + "reward_action_ablations": out_dir / "reward_action_ablations.csv", + "reward_action_ablation_summary": out_dir / "reward_action_ablation_summary.json", + "no_trade_opportunity_diagnostics": out_dir / "no_trade_opportunity_diagnostics.csv", + "no_trade_opportunity_summary": out_dir / "no_trade_opportunity_summary.json", + "abstention_reasons": out_dir / "abstention_reasons.csv", + "source_hashes": out_dir / "source_hashes.json", + "policy_baseline_comparison": out_dir / "policy_baseline_comparison.csv", + "policy_nav": out_dir / "policy_nav.csv", + "policy_evaluation_manifest": out_dir / "policy_evaluation_manifest.json", + "baseline_comparison": out_dir / "baseline_comparison.json", + "verdict": out_dir / "verdict.json", + } + manifest = {**result["manifest"], "run_id": rid, "artifact_dir": str(out_dir), "artifacts": {key: str(path) for key, path in paths.items()}} + _write_json(paths["rl_manifest"], manifest) + _write_json(paths["training_manifest"], manifest) + _write_json(paths["observation_manifest"], {**result["observation_manifest"], "run_id": rid}) + _write_json(paths["policy_metrics"], result["policy_metrics"]) + _write_json(paths["baseline_comparison"], result["baseline_comparison"]) + _write_json(paths["verdict"], result["verdict"]) + _write_csv(paths["episode_metrics"], result["episode_metrics"], ["episode", "total_reward", "steps", "final_equity", "invalid_actions"]) + _write_csv(paths["learning_curve"], result["learning_curve"], ["episode", "total_reward", "rolling_mean_reward", "best_total_reward", "steps", "final_equity", "invalid_actions", "invalid_action_rate"]) + _write_csv(paths["positions"], result["positions"], ["split", "date", "rank", "code", "action", "equity"]) + _write_csv( + paths["invalid_actions"], + result["invalid_actions"], + [ + "split", + "date", + "action", + "requested_action", + "executed_action", + "invalid_action", + "invalid_action_reason", + "no_trade_action", + "action_mask_hold_buy_add_sell_reduce", + "mask_reason_hold", + "mask_reason_buy", + "mask_reason_add", + "mask_reason_sell", + "mask_reason_reduce", + ], + ) + _write_csv( + paths["reward_breakdown"], + result["reward_breakdown"], + [ + "split", + "date", + "action", + "requested_action", + "executed_action", + "invalid_action", + "invalid_action_reason", + "no_trade_action", + "gross_return", + "cost", + "net_return_after_cost", + "slippage_cost", + "turnover", + "exposure", + "concentration", + "exposure_penalty", + "concentration_penalty", + "invalid_action_penalty", + "churn_penalty", + "drawdown_penalty", + "no_trade_hold_reward", + "current_drawdown", + "reward_before_drawdown_penalty", + "missing_reward_label_count", + "reward", + "equity", + "mask_reason_hold", + "mask_reason_buy", + "mask_reason_add", + "mask_reason_sell", + "mask_reason_reduce", + ], + ) + _write_csv( + paths["state_observations"], + result["state_observations"], + [ + "split", + "date", + "observation_position_count", + "observation_top_score_bucket", + "observation_score_margin_bucket", + "observation_candidate_count_bucket", + "observation_recent_score_volatility_bucket", + "observation_d3_confidence_bucket", + "observation_mode", + "observation_state_key", + "cash_fraction", + "exposure_fraction", + "held_codes", + "candidate_count", + "top_candidate_code", + "top_candidate_rank", + "top_candidate_score", + "top_candidate_reward_label_available", + "action_mask_hold_buy_add_sell_reduce", + "future_label_exposed", + "action_prior_mode", + "action_prior_strength", + "policy_value_hold", + "policy_value_buy", + "policy_value_add", + "policy_value_sell", + "policy_value_reduce", + "action_prior_hold", + "action_prior_buy", + "action_prior_add", + "action_prior_sell", + "action_prior_reduce", + "policy_score_hold", + "policy_score_buy", + "policy_score_add", + "policy_score_sell", + "policy_score_reduce", + "mask_reason_hold", + "mask_reason_buy", + "mask_reason_add", + "mask_reason_sell", + "mask_reason_reduce", + ], + ) + _write_csv( + paths["action_distribution"], + result["action_distribution"], + [ + "split", + "action", + "requested_action", + "executed_action", + "invalid_action", + "invalid_action_reason", + "no_trade_action", + "count", + "action_rate", + ], + ) + _write_csv( + paths["turnover"], + result["turnover"], + [ + "split", + "date", + "action", + "requested_action", + "executed_action", + "invalid_action_reason", + "no_trade_action", + "turnover", + "turnover_cost", + "net_return_after_cost", + "churn_penalty", + "reward", + "equity", + ], + ) + _write_csv( + paths["drawdown"], + result["drawdown"], + [ + "split", + "date", + "action", + "requested_action", + "executed_action", + "invalid_action_reason", + "no_trade_action", + "equity", + "current_drawdown", + "drawdown_penalty", + "reward_before_drawdown_penalty", + "reward", + ], + ) + _write_json(paths["reward_component_summary"], result["reward_component_summary"]) + _write_csv( + paths["reward_action_ablations"], + result["reward_action_ablations"], + [ + "split", + "ablation_family", + "ablation", + "description", + "rows", + "total_reward", + "mean_reward", + "delta_vs_recorded_reward", + "action_rows", + "invalid_action_count", + "invalid_action_rate", + "no_trade_action_count", + "no_trade_action_rate", + "cost_round_trip_bp", + ], + ) + _write_json(paths["reward_action_ablation_summary"], result["reward_action_ablation_summary"]) + _write_csv( + paths["no_trade_opportunity_diagnostics"], + result["no_trade_opportunity_diagnostics"], + [ + "split", + "date", + "action", + "requested_action", + "executed_action", + "no_trade_action", + "observation_position_count", + "candidate_count", + "top_candidate_code", + "top_candidate_score", + "top_candidate_reward_label_available", + "top_candidate_future_return_1d", + "top_candidate_entry_cost", + "top_candidate_net_after_entry_cost", + "no_trade_opportunity_flag", + "no_trade_risk_avoided_flag", + "diagnostic", + "diagnostic_future_label_exposed", + "future_label_used_for_training_state", + "guardrail", + ], + ) + _write_json(paths["no_trade_opportunity_summary"], result["no_trade_opportunity_summary"]) + _write_csv( + paths["abstention_reasons"], + result["abstention_reasons"], + [ + "split", + "date", + "action_filter_mode", + "action_filter_enabled", + "action_filter_reason", + "entry_filter_passed", + "blocked_entry_actions", + "entry_abstained_by_filter", + "selected_action", + "requested_action", + "executed_action", + "base_action_mask_hold_buy_add_sell_reduce", + "filtered_action_mask_hold_buy_add_sell_reduce", + "base_buy_valid", + "base_add_valid", + "filtered_buy_valid", + "filtered_add_valid", + "observation_d3_confidence_bucket", + "observation_score_margin_bucket", + "observation_recent_score_volatility_bucket", + "future_label_exposed", + "guardrail", + "filter_reason_hold", + "filter_reason_buy", + "filter_reason_add", + "filter_reason_sell", + "filter_reason_reduce", + ], + ) + _write_json( + paths["source_hashes"], + { + "schema_version": PORTFOLIO_SCHEMA_VERSION, + "source_hashes": result["source_hashes"], + "guardrail": "Source hashes provide provenance for research-only D4 telemetry, not model-build or live-readiness evidence.", + }, + ) + _write_csv(paths["policy_baseline_comparison"], result["policy_baseline_comparison"], ["baseline_strategy", "baseline_family", "baseline_status", "comparison_status", "policy_strategy", "policy_split", "policy_total_net_return", "policy_nav", "policy_max_drawdown", "policy_mean_turnover", "policy_mean_concentration", "baseline_total_net_return", "baseline_nav", "baseline_max_drawdown", "baseline_mean_turnover", "baseline_positions", "baseline_delta_total_net_return", "baseline_delta_nav", "baseline_delta_max_drawdown", "baseline_delta_mean_turnover", "cost_round_trip_bp"]) + _write_csv(paths["policy_nav"], result["policy_nav"], ["split", "date", "policy_nav", "policy_reward", "policy_turnover", "policy_concentration", "policy_current_drawdown"]) + _write_json( + paths["policy_evaluation_manifest"], + { + "schema_version": PORTFOLIO_SCHEMA_VERSION, + "run_id": rid, + "status": result["verdict"].get("status"), + "readiness_status": result["verdict"].get("readiness_status"), + "guardrail": RESEARCH_GUARDRAIL, + "required_frozen_baselines": list(FROZEN_BASELINE_STRATEGIES), + "missing_frozen_baselines": result["baseline_comparison"].get("missing_frozen_baselines", []), + "policy_split": "val+test", + "policy_type": result["manifest"].get("policy_type"), + "observation_mode": result["manifest"].get("observation_mode"), + "action_prior_mode": result["manifest"].get("action_prior_mode"), + "action_prior_strength": result["manifest"].get("action_prior_strength"), + "action_filter_mode": result["manifest"].get("action_filter_mode"), + "observation_fields": [ + field.get("name") + for field in result["observation_manifest"].get("observation_fields", []) + if isinstance(field, dict) + ], + "cost_round_trip_bp": ROUND_TRIP_COST_BP, + "policy_baseline_comparison_rows": len(result["policy_baseline_comparison"]), + "policy_nav_rows": len(result["policy_nav"]), + "reward_action_ablation_rows": len(result["reward_action_ablations"]), + "no_trade_opportunity_diagnostic_rows": len(result["no_trade_opportunity_diagnostics"]), + "abstention_reason_rows": len(result["abstention_reasons"]), + "source_hashes": result["source_hashes"], + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + }, + ) + artifact_hashes = { + key: _file_sha256(path) + for key, path in paths.items() + if key not in {"rl_manifest", "training_manifest"} + } + manifest["artifact_hashes"] = artifact_hashes + _write_json(paths["rl_manifest"], manifest) + _write_json(paths["training_manifest"], manifest) + rl_manifest_sha = _file_sha256(paths["rl_manifest"]) + training_manifest_sha = _file_sha256(paths["training_manifest"]) + return { + "run_id": rid, + "artifact_dir": str(out_dir), + "rl_manifest_sha256": rl_manifest_sha, + "training_manifest_sha256": training_manifest_sha, + "artifact_hashes": { + **artifact_hashes, + "rl_manifest": rl_manifest_sha, + "training_manifest": training_manifest_sha, + }, + **{f"{key}_path": str(path) for key, path in paths.items()}, + } + + +def run_and_write_daily_rl( + *, + run_id: str | None = None, + artifact_root: Path | str | None = None, + overwrite: bool = False, + **kwargs: Any, +) -> dict[str, Any]: + result = run_daily_rl(**kwargs) + written = write_rl_artifacts(result, run_id=run_id, artifact_root=artifact_root, overwrite=overwrite) + return {"result": result, "written": written} + + +__all__ = [ + "build_action_prior_values", + "build_action_filter_decision", + "build_action_distribution", + "build_drawdown_rows", + "build_learning_curve", + "build_reward_component_summary", + "build_reward_action_ablations", + "build_no_trade_opportunity_summary", + "build_policy_baseline_comparison", + "build_policy_nav_rows", + "build_turnover_rows", + "DEFAULT_PORTFOLIO_ROOT", + "PORTFOLIO_SCHEMA_VERSION", + "SCORE_COLUMN", + "evaluate_policy", + "load_prediction_artifacts", + "run_and_write_daily_rl", + "run_daily_rl", + "train_tabular_q_policy", + "write_rl_artifacts", +] diff --git a/stom_rl/daily_scenario_batch.py b/stom_rl/daily_scenario_batch.py new file mode 100644 index 000000000..cae11cbc6 --- /dev/null +++ b/stom_rl/daily_scenario_batch.py @@ -0,0 +1,354 @@ +"""Batch runner for research-only Daily OHLCV scenario/model experiments. + +The dashboard API remains read-only. This module is the explicit CLI path for +running multiple pre-registered scenario configurations through the existing +D2 -> D3 -> D4 -> D5 pipeline and writing comparison artifacts under +``webui/rl_runs``. +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .daily_ohlcv_db import REPO_ROOT +from .daily_scenario_runner import RESEARCH_GUARDRAIL, _validate_run_id, run_daily_model_scenario + +DEFAULT_SCENARIO_BATCH_ROOT = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_scenario_batches" +RUNNER_DEFAULTS: dict[str, Any] = { + "max_symbols": None, + "max_rows_per_symbol": None, + "quality_table_limit": 0, + "horizon_days": 1, + "train_fraction": 0.6, + "val_fraction": 0.2, + "purge_days": 5, + "embargo_days": 5, + "candidate_limit": 20, + "max_positions": 5, + "episodes": 8, + "rl_seed": 7, + "n_folds": 5, + "top_k": 20, + "wf_seed": 17, + "observation_mode": "v1", + "action_prior_mode": "none", + "action_prior_strength": 0.0, + "action_filter_mode": "none", +} +SCENARIO_PARAM_KEYS = set(RUNNER_DEFAULTS) +MIN_REQUIRED_FOLDS = 5 +MIN_REQUIRED_PURGE_DAYS = 5 +MIN_REQUIRED_EMBARGO_DAYS = 5 + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _scenario_batch_dir(batch_id: str, *, root: Path | str | None = None, overwrite: bool = False) -> Path: + base = Path(root or DEFAULT_SCENARIO_BATCH_ROOT).resolve() + default_root = DEFAULT_SCENARIO_BATCH_ROOT.resolve() + try: + base.relative_to(default_root) + except ValueError: + if base != default_root: + raise ValueError("Batch artifacts must stay under webui/rl_runs/daily_ohlcv_scenario_batches") + bid = _validate_run_id(batch_id) + out_dir = (base / bid).resolve() + try: + out_dir.relative_to(base) + except ValueError as exc: + raise ValueError("batch_id escapes daily scenario batch artifact root") from exc + if out_dir.exists() and any(out_dir.iterdir()) and not overwrite: + raise FileExistsError(f"Scenario batch artifact batch_id already exists: {bid}") + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir + + +def load_batch_plan(path: Path | str) -> dict[str, Any]: + plan_path = Path(path) + payload = json.loads(plan_path.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + raise ValueError("Scenario batch plan must be a JSON object") + return payload + + +def default_batch_plan(*, batch_id: str = "scenario_batch_smoke_001") -> dict[str, Any]: + """Return a small valid plan users can save and edit before running.""" + + return { + "batch_id": batch_id, + "description": "Research-only Daily OHLCV scenario smoke matrix; no live/broker/orders.", + "defaults": { + "max_symbols": 8, + "max_rows_per_symbol": 120, + "quality_table_limit": 0, + "episodes": 3, + "candidate_limit": 10, + "max_positions": 3, + "n_folds": 5, + "top_k": 10, + "purge_days": 5, + "embargo_days": 5, + }, + "scenarios": [ + { + "scenario_id": "baseline_seed7_top10", + "hypothesis": "Current-evidence baseline using top-k momentum controls.", + "overrides": {"rl_seed": 7, "wf_seed": 17, "top_k": 10}, + }, + { + "scenario_id": "seed11_top10", + "hypothesis": "Seed robustness check without changing OOS gate rules.", + "overrides": {"rl_seed": 11, "wf_seed": 31, "top_k": 10}, + }, + { + "scenario_id": "top5_concentrated", + "hypothesis": "Concentration stress test with fewer selected names.", + "overrides": {"rl_seed": 7, "wf_seed": 17, "top_k": 5, "max_positions": 3}, + }, + ], + } + + +def _merge_config(defaults: dict[str, Any], scenario: dict[str, Any]) -> dict[str, Any]: + unknown_default_keys = set(defaults) - SCENARIO_PARAM_KEYS + if unknown_default_keys: + raise ValueError(f"Unknown scenario default keys: {sorted(unknown_default_keys)}") + + overrides = scenario.get("overrides", {}) + if overrides is None: + overrides = {} + if not isinstance(overrides, dict): + raise ValueError("scenario.overrides must be an object") + + inline_overrides = {key: value for key, value in scenario.items() if key in SCENARIO_PARAM_KEYS} + unknown_override_keys = (set(overrides) | set(inline_overrides)) - SCENARIO_PARAM_KEYS + if unknown_override_keys: + raise ValueError(f"Unknown scenario override keys: {sorted(unknown_override_keys)}") + + config = dict(RUNNER_DEFAULTS) + config.update(defaults) + config.update(overrides) + config.update(inline_overrides) + _validate_research_gate_config(config) + return config + + +def _validate_research_gate_config(config: dict[str, Any]) -> None: + if int(config.get("n_folds") or 0) < MIN_REQUIRED_FOLDS: + raise ValueError("n_folds must be >= 5 for scenario batch runs") + if int(config.get("purge_days") or 0) < MIN_REQUIRED_PURGE_DAYS: + raise ValueError("purge_days must be >= 5 for scenario batch runs") + if int(config.get("embargo_days") or 0) < MIN_REQUIRED_EMBARGO_DAYS: + raise ValueError("embargo_days must be >= 5 for scenario batch runs") + + +def _scenario_id(scenario: dict[str, Any], index: int) -> str: + raw = scenario.get("scenario_id") or scenario.get("name") or f"scenario_{index:02d}" + return _validate_run_id(str(raw)) + + +def _comparison_row_from_manifest(*, scenario_id: str, run_id: str, manifest: dict[str, Any]) -> dict[str, Any]: + gate = manifest.get("gate_verdict_summary") or {} + return { + "scenario_id": scenario_id, + "run_id": run_id, + "status": manifest.get("status", "NO-GO"), + "readiness_status": manifest.get("readiness_status", "D5_NO_GO_RESEARCH_ONLY_GATE"), + "selected_strategy": gate.get("selected_strategy"), + "n_folds": gate.get("n_folds"), + "purge_days": gate.get("purge_days"), + "embargo_days": gate.get("embargo_days"), + "cost_sensitivity_bp": gate.get("cost_sensitivity_bp"), + "blocking_reasons": gate.get("reasons", []), + "artifact_paths": manifest.get("artifact_paths", {}), + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + + +def _status_counts(rows: list[dict[str, Any]]) -> dict[str, int]: + counts: dict[str, int] = {} + for row in rows: + status = str(row.get("status") or "UNKNOWN") + counts[status] = counts.get(status, 0) + 1 + return counts + + +def run_daily_scenario_batch( + *, + plan: dict[str, Any] | None = None, + plan_path: Path | str | None = None, + batch_id: str | None = None, + overwrite: bool = False, + stop_on_error: bool = False, + dry_run: bool = False, +) -> dict[str, Any]: + """Run or preview a pre-registered batch of Daily OHLCV model scenarios.""" + + if plan is None: + if plan_path is None: + plan = default_batch_plan(batch_id=batch_id or "scenario_batch_smoke_001") + else: + plan = load_batch_plan(plan_path) + if not isinstance(plan, dict): + raise ValueError("Scenario batch plan must be an object") + + resolved_batch_id = _validate_run_id(str(batch_id or plan.get("batch_id") or "").strip()) + scenarios = plan.get("scenarios") or [] + if not isinstance(scenarios, list) or not scenarios: + raise ValueError("Scenario batch plan requires a non-empty scenarios list") + defaults = plan.get("defaults") or {} + if not isinstance(defaults, dict): + raise ValueError("Scenario batch defaults must be an object") + + out_dir = _scenario_batch_dir(resolved_batch_id, overwrite=overwrite) + _write_json(out_dir / "scenario_batch_plan.json", plan) + + comparison_rows: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + started_at = _utc_now() + + for index, raw_scenario in enumerate(scenarios, start=1): + if not isinstance(raw_scenario, dict): + raise ValueError("Each scenario entry must be an object") + scenario_id = _scenario_id(raw_scenario, index) + run_id = _validate_run_id(f"{resolved_batch_id}__{scenario_id}") + config = _merge_config(defaults, raw_scenario) + row_base = { + "scenario_id": scenario_id, + "run_id": run_id, + "hypothesis": raw_scenario.get("hypothesis"), + "assumption_tags": raw_scenario.get("assumption_tags", []), + "config": config, + } + if dry_run: + comparison_rows.append( + { + **row_base, + "status": "DRY_RUN_NOT_EXECUTED", + "readiness_status": "SCENARIO_BATCH_DRY_RUN_ONLY", + "cost_sensitivity_bp": [0, 23, 46], + "blocking_reasons": ["DRY_RUN_NOT_EXECUTED"], + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + ) + continue + try: + child_manifest = run_daily_model_scenario(run_id=run_id, overwrite=overwrite, **config) + comparison_rows.append( + { + **row_base, + **_comparison_row_from_manifest( + scenario_id=scenario_id, + run_id=run_id, + manifest=child_manifest, + ), + } + ) + except Exception as exc: + error_row = { + **row_base, + "status": "ERROR", + "readiness_status": "SCENARIO_RUN_FAILED_RESEARCH_ONLY", + "error": str(exc), + "cost_sensitivity_bp": [0, 23, 46], + "blocking_reasons": ["SCENARIO_RUN_FAILED", str(exc)], + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + errors.append(error_row) + comparison_rows.append(error_row) + if stop_on_error: + break + + failed_count = sum(1 for row in comparison_rows if row.get("status") == "ERROR") + manifest = { + "schema_version": 1, + "batch_id": resolved_batch_id, + "generated_at": _utc_now(), + "started_at": started_at, + "mode": "daily_ohlcv_model_scenario_batch", + "platform_stage": "SCENARIO_BATCH_RUNNER_MVP", + "status": "DRY_RUN" if dry_run else ("PARTIAL_ERROR_RESEARCH_ONLY" if failed_count else "COMPLETED_RESEARCH_ONLY"), + "read_only_artifact": True, + "dry_run": dry_run, + "guardrail": RESEARCH_GUARDRAIL, + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "scenario_count": len(scenarios), + "completed_count": len(comparison_rows) - failed_count, + "failed_count": failed_count, + "gate_status_counts": _status_counts(comparison_rows), + "plan": plan, + "comparison_rows": comparison_rows, + "errors": errors, + "artifact_paths": { + "scenario_batch_manifest": str(out_dir / "scenario_batch_manifest.json"), + "scenario_batch_plan": str(out_dir / "scenario_batch_plan.json"), + }, + } + _write_json(out_dir / "scenario_batch_manifest.json", manifest) + return manifest + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run a research-only Daily OHLCV scenario batch from a JSON plan.") + parser.add_argument("--plan", type=Path, help="JSON plan containing batch_id, defaults, and scenarios") + parser.add_argument("--batch-id", help="Override or provide the batch id") + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--stop-on-error", action="store_true") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--emit-template", action="store_true", help="Print a small JSON batch plan template and exit") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_arg_parser().parse_args(argv) + if args.emit_template: + print(json.dumps(default_batch_plan(batch_id=args.batch_id or "scenario_batch_smoke_001"), ensure_ascii=False, indent=2)) + return 0 + payload = run_daily_scenario_batch( + plan_path=args.plan, + batch_id=args.batch_id, + overwrite=args.overwrite, + stop_on_error=args.stop_on_error, + dry_run=args.dry_run, + ) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + +__all__ = [ + "DEFAULT_SCENARIO_BATCH_ROOT", + "RUNNER_DEFAULTS", + "build_arg_parser", + "default_batch_plan", + "load_batch_plan", + "main", + "run_daily_scenario_batch", +] diff --git a/stom_rl/daily_scenario_runner.py b/stom_rl/daily_scenario_runner.py new file mode 100644 index 000000000..6c7370e51 --- /dev/null +++ b/stom_rl/daily_scenario_runner.py @@ -0,0 +1,265 @@ +"""Scenario runner for research-only Daily OHLCV model experiments. + +This module wires the existing D2 -> D3 -> D4 -> D5 pipeline into one +pre-registered scenario run. It writes only generated artifacts under +``webui/rl_runs`` and keeps all model/paper/live flags locked until the normal +D5 gate proves otherwise. +""" + +from __future__ import annotations + +import argparse +import json +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .daily_ohlcv_db import REPO_ROOT +from .daily_ohlcv_dataset import DEFAULT_DATASET_ROOT, build_and_write_daily_ohlcv_dataset +from .daily_prediction import DEFAULT_PREDICTION_ROOT, run_and_write_daily_prediction +from .daily_rl_train import DEFAULT_PORTFOLIO_ROOT, run_and_write_daily_rl +from .daily_walk_forward import DEFAULT_WALK_FORWARD_ROOT, run_and_write_daily_walk_forward + +DEFAULT_SCENARIO_ROOT = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_scenarios" +SAFE_RUN_RE = re.compile(r"^[0-9A-Za-z_.-]+$") +SCENARIO_PIPELINE_SUBDIR = "_scenario_runs" +RESEARCH_GUARDRAIL = ( + "Research-only scenario/model experiment; no profit guarantee, no live/broker/orders, " + "no paper-forward unlock, and no deployable model readiness claim." +) + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _validate_run_id(run_id: str) -> str: + rid = str(run_id or "").strip() + if not rid or not SAFE_RUN_RE.fullmatch(rid): + raise ValueError("run_id must match [0-9A-Za-z_.-]+") + return rid + + +def _scenario_run_dir(run_id: str, *, root: Path | str | None = None, overwrite: bool = False) -> Path: + base = Path(root or DEFAULT_SCENARIO_ROOT).resolve() + default_root = DEFAULT_SCENARIO_ROOT.resolve() + try: + base.relative_to(default_root) + except ValueError: + if base != default_root: + raise ValueError("Scenario artifacts must stay under webui/rl_runs/daily_ohlcv_scenarios") + rid = _validate_run_id(run_id) + out_dir = (base / rid).resolve() + try: + out_dir.relative_to(base) + except ValueError as exc: + raise ValueError("run_id escapes daily scenario artifact root") from exc + if out_dir.exists() and any(out_dir.iterdir()) and not overwrite: + raise FileExistsError(f"Scenario artifact run_id already exists: {rid}") + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def run_daily_model_scenario( + *, + run_id: str, + overwrite: bool = False, + max_symbols: int | None = None, + max_rows_per_symbol: int | None = None, + quality_table_limit: int | None = 0, + horizon_days: int = 1, + train_fraction: float = 0.6, + val_fraction: float = 0.2, + purge_days: int = 5, + embargo_days: int = 5, + candidate_limit: int = 20, + max_positions: int = 5, + episodes: int = 8, + rl_seed: int = 7, + n_folds: int = 5, + top_k: int = 20, + wf_seed: int = 17, + observation_mode: str = "v1", + action_prior_mode: str = "none", + action_prior_strength: float = 0.0, + action_filter_mode: str = "none", +) -> dict[str, Any]: + """Run one bounded research scenario through D2/D3/D4/D5 and write a manifest.""" + + rid = _validate_run_id(run_id) + out_dir = _scenario_run_dir(rid, overwrite=overwrite) + started_at = _utc_now() + + config = { + "run_id": rid, + "max_symbols": max_symbols, + "max_rows_per_symbol": max_rows_per_symbol, + "quality_table_limit": quality_table_limit, + "horizon_days": horizon_days, + "train_fraction": train_fraction, + "val_fraction": val_fraction, + "purge_days": purge_days, + "embargo_days": embargo_days, + "candidate_limit": candidate_limit, + "max_positions": max_positions, + "episodes": episodes, + "rl_seed": rl_seed, + "n_folds": n_folds, + "top_k": top_k, + "wf_seed": wf_seed, + "observation_mode": observation_mode, + "action_prior_mode": action_prior_mode, + "action_prior_strength": action_prior_strength, + "action_filter_mode": action_filter_mode, + } + _write_json(out_dir / "candidate_generation_config.json", config) + + dataset = build_and_write_daily_ohlcv_dataset( + run_id=rid, + overwrite=overwrite, + artifact_root=DEFAULT_DATASET_ROOT / SCENARIO_PIPELINE_SUBDIR, + max_symbols=max_symbols, + max_rows_per_symbol=max_rows_per_symbol, + quality_table_limit=quality_table_limit, + horizon_days=horizon_days, + train_fraction=train_fraction, + val_fraction=val_fraction, + purge_days=purge_days, + embargo_days=embargo_days, + ) + dataset_dir = Path(dataset["written"]["artifact_dir"]) + + prediction = run_and_write_daily_prediction( + run_id=rid, + overwrite=overwrite, + artifact_root=DEFAULT_PREDICTION_ROOT / SCENARIO_PIPELINE_SUBDIR, + dataset_run_dir=dataset_dir, + ) + prediction_dir = Path(prediction["written"]["artifact_dir"]) + + portfolio = run_and_write_daily_rl( + run_id=rid, + overwrite=overwrite, + artifact_root=DEFAULT_PORTFOLIO_ROOT / SCENARIO_PIPELINE_SUBDIR, + prediction_run_dir=prediction_dir, + candidate_limit=candidate_limit, + max_positions=max_positions, + episodes=episodes, + seed=rl_seed, + observation_mode=observation_mode, + action_prior_mode=action_prior_mode, + action_prior_strength=action_prior_strength, + action_filter_mode=action_filter_mode, + ) + portfolio_dir = Path(portfolio["written"]["artifact_dir"]) + + walk_forward = run_and_write_daily_walk_forward( + run_id=rid, + overwrite=overwrite, + artifact_root=DEFAULT_WALK_FORWARD_ROOT / SCENARIO_PIPELINE_SUBDIR, + prediction_run_dir=prediction_dir, + portfolio_run_dir=portfolio_dir, + n_folds=n_folds, + purge_days=purge_days, + embargo_days=embargo_days, + top_k=top_k, + seed=wf_seed, + ) + + gate_verdict = walk_forward["result"].get("gate_verdict", {}) + manifest = { + "schema_version": 1, + "run_id": rid, + "generated_at": _utc_now(), + "started_at": started_at, + "mode": "daily_ohlcv_model_scenario_run", + "guardrail": RESEARCH_GUARDRAIL, + "config": config, + "status": gate_verdict.get("status", "NO-GO"), + "readiness_status": gate_verdict.get("readiness_status", "D5_NO_GO_RESEARCH_ONLY_GATE"), + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "artifact_dirs": { + "scenario": str(out_dir), + "dataset": str(dataset_dir), + "prediction": str(prediction_dir), + "portfolio": str(portfolio_dir), + "walk_forward": walk_forward["written"]["artifact_dir"], + }, + "artifact_paths": { + "candidate_generation_config": str(out_dir / "candidate_generation_config.json"), + "scenario_manifest": str(out_dir / "scenario_manifest.json"), + "dataset_manifest": dataset["written"].get("dataset_manifest_path"), + "prediction_manifest": prediction["written"].get("prediction_manifest_path"), + "portfolio_manifest": portfolio["written"].get("rl_manifest_path"), + "walk_forward_manifest": walk_forward["written"].get("walk_forward_manifest_path"), + "gate_verdict": walk_forward["written"].get("gate_verdict_path"), + }, + "gate_verdict_summary": { + "status": gate_verdict.get("status"), + "readiness_status": gate_verdict.get("readiness_status"), + "selected_strategy": gate_verdict.get("selected_strategy"), + "n_folds": gate_verdict.get("n_folds"), + "purge_days": gate_verdict.get("purge_days"), + "embargo_days": gate_verdict.get("embargo_days"), + "cost_sensitivity_bp": gate_verdict.get("cost_sensitivity_bp"), + "reasons": gate_verdict.get("reasons", []), + }, + } + _write_json(out_dir / "scenario_manifest.json", manifest) + return manifest + + +def build_arg_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Run one research-only Daily OHLCV scenario through D2-D5.") + parser.add_argument("--run-id", required=True) + parser.add_argument("--overwrite", action="store_true") + parser.add_argument("--max-symbols", type=int, default=None) + parser.add_argument("--max-rows-per-symbol", type=int, default=None) + parser.add_argument("--quality-table-limit", type=int, default=0) + parser.add_argument("--horizon-days", type=int, default=1) + parser.add_argument("--train-fraction", type=float, default=0.6) + parser.add_argument("--val-fraction", type=float, default=0.2) + parser.add_argument("--purge-days", type=int, default=5) + parser.add_argument("--embargo-days", type=int, default=5) + parser.add_argument("--candidate-limit", type=int, default=20) + parser.add_argument("--max-positions", type=int, default=5) + parser.add_argument("--episodes", type=int, default=8) + parser.add_argument("--rl-seed", type=int, default=7) + parser.add_argument("--n-folds", type=int, default=5) + parser.add_argument("--top-k", type=int, default=20) + parser.add_argument("--wf-seed", type=int, default=17) + parser.add_argument("--observation-mode", default="v1") + parser.add_argument("--action-prior-mode", default="none") + parser.add_argument("--action-prior-strength", type=float, default=0.0) + parser.add_argument("--action-filter-mode", default="none") + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_arg_parser().parse_args(argv) + payload = run_daily_model_scenario(**vars(args)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + + +__all__ = [ + "DEFAULT_SCENARIO_ROOT", + "SCENARIO_PIPELINE_SUBDIR", + "RESEARCH_GUARDRAIL", + "build_arg_parser", + "main", + "run_daily_model_scenario", +] diff --git a/stom_rl/daily_signal_quality.py b/stom_rl/daily_signal_quality.py new file mode 100644 index 000000000..2b3eb3f28 --- /dev/null +++ b/stom_rl/daily_signal_quality.py @@ -0,0 +1,858 @@ +"""Daily OHLCV D3/D4 signal-quality diagnostics. + +Research-only CLI for auditing whether existing D3 scores, score margins, +confidence buckets, and past-only risk proxies contain usable information before +another D4 overlay experiment. This module never submits orders and never claims +profitability; it writes diagnostic artifacts only. +""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import math +from collections import defaultdict +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable, Sequence + +DEFAULT_SCORE_COLUMN = "score_supervised_linear_ranker" +DEFAULT_RUN_ID = "signal_quality_audit_001" +DEFAULT_OUTPUT_ROOT = Path("webui/rl_runs/daily_ohlcv_signal_quality") +DEFAULT_PREDICTION_DIR = Path("webui/rl_runs/daily_ohlcv_prediction/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__confidence_abstain_v1") +DEFAULT_WALK_FORWARD_DIR = Path("webui/rl_runs/daily_ohlcv_walk_forward/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__confidence_abstain_v1") +DEFAULT_PORTFOLIO_DIR = Path("webui/rl_runs/daily_ohlcv_portfolio/_scenario_runs/scenario_batch_d4_trade_quality_filter_001__confidence_abstain_v1") +DEFAULT_SCORE_THRESHOLDS = (0.001, 0.005, 0.02) +DEFAULT_COST_SENSITIVITY_BP = (0, 23, 46) +RESEARCH_GUARDRAIL = ( + "Research-only signal-quality diagnostics; no live/broker/orders, no profit guarantee, " + "no model-build or paper-forward unlock." +) + + +@dataclass(frozen=True) +class PredictionRow: + code: str + date: str + split: str + score: float + future_return_1d: float + future_direction_1d: int + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _safe_float(value: object) -> float | None: + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + parsed = float(text) + except (TypeError, ValueError): + return None + if not math.isfinite(parsed): + return None + return parsed + + +def _bucket_by_threshold(value: float | None, thresholds: Sequence[float] = DEFAULT_SCORE_THRESHOLDS) -> int: + if value is None: + return 0 + clean = abs(float(value)) + return sum(clean > threshold for threshold in thresholds) + + +def _score_sign_bucket(value: float | None) -> int: + if value is None: + return 0 + return 1 if value > 0 else -1 if value < 0 else 0 + + +def _read_csv(path: Path) -> list[dict[str, str]]: + with path.open("r", encoding="utf-8-sig", newline="") as f: + return list(csv.DictReader(f)) + + +def _write_csv(path: Path, rows: Iterable[dict[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(fieldnames), extrasaction="ignore") + writer.writeheader() + for row in rows: + writer.writerow({name: row.get(name, "") for name in fieldnames}) + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def load_predictions(prediction_dir: Path, *, score_column: str = DEFAULT_SCORE_COLUMN) -> list[PredictionRow]: + path = prediction_dir / "predictions.csv" + rows: list[PredictionRow] = [] + for raw in _read_csv(path): + score = _safe_float(raw.get(score_column)) + future_return = _safe_float(raw.get("future_return_1d")) + if score is None or future_return is None: + continue + code = str(raw.get("code", "")).zfill(6) + direction_value = _safe_float(raw.get("future_direction_1d")) + rows.append( + PredictionRow( + code=code, + date=str(raw.get("date", "")), + split=str(raw.get("split", "unknown")), + score=score, + future_return_1d=future_return, + future_direction_1d=1 if (direction_value or 0.0) > 0 else 0, + ) + ) + if not rows: + raise ValueError(f"No usable prediction rows with {score_column} and future_return_1d in {path}") + return rows + + +def load_fold_assignments(walk_forward_dir: Path | None) -> dict[str, str]: + if walk_forward_dir is None: + return {} + path = walk_forward_dir / "fold_assignments.csv" + if not path.exists(): + return {} + assignments: dict[str, str] = {} + for raw in _read_csv(path): + if str(raw.get("role", "")).lower() == "test": + assignments[str(raw.get("date", ""))] = str(raw.get("fold_id", "fold_unknown")) + return assignments +def load_lagged_portfolio_context(portfolio_dir: Path | None) -> dict[str, dict[str, Any]]: + """Load t-1 generated path context without using current-day labels. + + The prereg permits drawdown from a past research equity path and requires + fail-closed behavior when a past-return proxy is not available. This loader + uses only rows strictly before the current date from drawdown.csv: prior + rewards build a volatility proxy and the prior current_drawdown becomes the + drawdown proxy. Current-row rewards/drawdown are appended only after the + current date's context has been emitted. + """ + if portfolio_dir is None: + return {} + path = portfolio_dir / "drawdown.csv" + if not path.exists(): + return {} + + rows_by_date: dict[str, list[dict[str, str]]] = defaultdict(list) + for raw in _read_csv(path): + date = str(raw.get("date", "")) + if date: + rows_by_date[date].append(raw) + + context: dict[str, dict[str, Any]] = {} + prior_rewards: list[float] = [] + prior_drawdown = 0.0 + for date in sorted(rows_by_date): + date_rows = rows_by_date[date] + context[date] = { + "past_return_volatility": _stddev(prior_rewards[-5:]), + "drawdown": prior_drawdown, + "past_return_volatility_source": "drawdown.csv lagged date-deduplicated reward lookback", + "drawdown_source": "drawdown.csv lagged date-deduplicated current_drawdown", + "past_return_volatility_status": "AVAILABLE_T_MINUS_1_GENERATED_PATH", + "drawdown_status": "AVAILABLE_T_MINUS_1_GENERATED_PATH", + } + rewards = [value for value in (_safe_float(raw.get("reward")) for raw in date_rows) if value is not None] + drawdowns = [value for value in (_safe_float(raw.get("current_drawdown")) for raw in date_rows) if value is not None] + if rewards: + prior_rewards.append(_mean(rewards)) + if drawdowns: + prior_drawdown = min(drawdowns) + return context + + + + +def _group_by_date(rows: Iterable[PredictionRow]) -> dict[str, list[PredictionRow]]: + grouped: dict[str, list[PredictionRow]] = defaultdict(list) + for row in rows: + grouped[row.date].append(row) + for date_rows in grouped.values(): + date_rows.sort(key=lambda row: row.score, reverse=True) + return dict(sorted(grouped.items())) + + +def _missing_portfolio_context() -> dict[str, Any]: + return { + "past_return_volatility": 0.0, + "drawdown": 0.0, + "past_return_volatility_source": "MISSING_PAST_OHLCV_PROXY", + "drawdown_source": "MISSING_PAST_OHLCV_PROXY", + "past_return_volatility_status": "MISSING_PAST_OHLCV_PROXY_FAIL_CLOSED", + "drawdown_status": "MISSING_PAST_OHLCV_PROXY_FAIL_CLOSED", + } + + +def _daily_context( + rows: list[PredictionRow], + fold_by_date: dict[str, str], + portfolio_context: dict[str, dict[str, Any]] | None = None, +) -> dict[str, dict[str, Any]]: + by_date = _group_by_date(rows) + + contexts: dict[str, dict[str, Any]] = {} + prior_top_scores: list[float] = [] + prior_top_codes: set[str] | None = None + prior_top_code: str | None = None + for date, date_rows in by_date.items(): + top = date_rows[0] + second_score = date_rows[1].score if len(date_rows) > 1 else 0.0 + scores = [row.score for row in date_rows] + mean_score = sum(scores) / len(scores) + score_dispersion = math.sqrt(sum((score - mean_score) ** 2 for score in scores) / len(scores)) if scores else 0.0 + breadth = sum(1 for score in scores if score > 0) / len(scores) if scores else 0.0 + current_top_codes = {row.code for row in date_rows[:10]} + turnover_pressure = 0.0 if prior_top_codes is None else len(current_top_codes - prior_top_codes) / max(len(current_top_codes), 1) + top_code_turnover_pressure = 0.0 if prior_top_code is None or prior_top_code == top.code else 1.0 + lookback_scores = prior_top_scores[-5:] + recent_score_volatility = _stddev(lookback_scores) + lagged_path_context = (portfolio_context or {}).get(date) or _missing_portfolio_context() + past_return_volatility = _safe_float(lagged_path_context.get("past_return_volatility")) or 0.0 + drawdown = _safe_float(lagged_path_context.get("drawdown")) or 0.0 + margin = top.score - second_score + contexts[date] = { + "top_score": top.score, + "top_code": top.code, + "score_margin": margin, + "score_dispersion": score_dispersion, + "breadth_proxy": breadth, + "turnover_pressure": turnover_pressure, + "top_code_turnover_pressure": top_code_turnover_pressure, + "recent_score_volatility": recent_score_volatility, + "past_return_volatility": past_return_volatility, + "drawdown": drawdown, + "past_return_volatility_source": lagged_path_context["past_return_volatility_source"], + "drawdown_source": lagged_path_context["drawdown_source"], + "past_return_volatility_status": lagged_path_context["past_return_volatility_status"], + "drawdown_status": lagged_path_context["drawdown_status"], + "score_magnitude_bucket": _bucket_by_threshold(top.score), + "score_sign_bucket": _score_sign_bucket(top.score), + "score_margin_bucket": _bucket_by_threshold(margin), + "d3_confidence_bucket": _bucket_by_threshold(top.score), + "candidate_count_bucket": min(len(date_rows), 3), + "score_dispersion_bucket": _bucket_by_threshold(score_dispersion), + "recent_score_volatility_bucket": _bucket_by_threshold(recent_score_volatility), + "past_return_volatility_bucket": _bucket_by_threshold(past_return_volatility), + "drawdown_bucket": _bucket_by_threshold(drawdown), + "breadth_proxy_bucket": _bucket_by_threshold(breadth, (0.25, 0.50, 0.75)), + "turnover_pressure_bucket": _bucket_by_threshold(turnover_pressure, (0.25, 0.50, 0.75)), + "fold_id": fold_by_date.get(date, "FULL"), + } + prior_top_scores.append(top.score) + prior_top_codes = current_top_codes + prior_top_code = top.code + return contexts + + +def _stddev(values: Sequence[float]) -> float: + if len(values) <= 1: + return 0.0 + mean = sum(values) / len(values) + return math.sqrt(sum((value - mean) ** 2 for value in values) / len(values)) + + +def _mean(values: Sequence[float]) -> float: + return sum(values) / len(values) if values else 0.0 + + +def _median(values: Sequence[float]) -> float: + if not values: + return 0.0 + ordered = sorted(values) + mid = len(ordered) // 2 + if len(ordered) % 2: + return ordered[mid] + return (ordered[mid - 1] + ordered[mid]) / 2.0 + + +def _rank(values: Sequence[float]) -> list[float]: + order = sorted(enumerate(values), key=lambda pair: pair[1]) + ranks = [0.0] * len(values) + idx = 0 + while idx < len(order): + j = idx + 1 + while j < len(order) and order[j][1] == order[idx][1]: + j += 1 + avg_rank = (idx + 1 + j) / 2.0 + for k in range(idx, j): + ranks[order[k][0]] = avg_rank + idx = j + return ranks + + +def _pearson(xs: Sequence[float], ys: Sequence[float]) -> float | None: + if len(xs) < 2 or len(xs) != len(ys): + return None + mx = _mean(xs) + my = _mean(ys) + vx = sum((x - mx) ** 2 for x in xs) + vy = sum((y - my) ** 2 for y in ys) + if vx <= 0.0 or vy <= 0.0: + return None + return sum((x - mx) * (y - my) for x, y in zip(xs, ys, strict=True)) / math.sqrt(vx * vy) + + +def _spearman(xs: Sequence[float], ys: Sequence[float]) -> float | None: + if len(xs) < 2: + return None + return _pearson(_rank(xs), _rank(ys)) + + +def _fold_for(row: PredictionRow, contexts: dict[str, dict[str, Any]]) -> str: + return str(contexts.get(row.date, {}).get("fold_id") or "FULL") + + +def _signal_bucket_metadata(bucket_name: str) -> dict[str, Any]: + if bucket_name in {"score_magnitude_bucket", "score_sign_bucket"}: + return { + "source_timing": "t/current/pre_action", + "source_artifact": "predictions.csv score column", + "future_label_used_for_bucket": False, + } + if bucket_name == "score_margin_bucket": + return { + "source_timing": "t/current/pre_action", + "source_artifact": "current date candidate score ranking", + "future_label_used_for_bucket": False, + } + if bucket_name == "d3_confidence_bucket": + return { + "source_timing": "t/current/pre_action", + "source_artifact": "current top D3 score", + "future_label_used_for_bucket": False, + } + raise KeyError(bucket_name) + + +def _risk_proxy_metadata(proxy_name: str, sample_context: dict[str, Any]) -> dict[str, Any]: + if proxy_name == "score_dispersion_bucket": + return { + "source_timing": "t/current/pre_action", + "source_artifact": "current candidate panel D3 score dispersion", + "proxy_status": "AVAILABLE_CURRENT_SCORE_PANEL", + } + if proxy_name == "recent_score_volatility_bucket": + return { + "source_timing": "t-1/lookback/pre_action", + "source_artifact": "prior top D3 scores", + "proxy_status": "AVAILABLE_T_MINUS_1_SCORE_LOOKBACK", + } + if proxy_name == "past_return_volatility_bucket": + return { + "source_timing": "t-1/lookback/pre_action", + "source_artifact": sample_context["past_return_volatility_source"], + "proxy_status": sample_context["past_return_volatility_status"], + } + if proxy_name == "drawdown_bucket": + return { + "source_timing": "t-1/lookback/pre_action", + "source_artifact": sample_context["drawdown_source"], + "proxy_status": sample_context["drawdown_status"], + } + if proxy_name == "breadth_proxy_bucket": + return { + "source_timing": "t/current/pre_action", + "source_artifact": "current candidate panel positive-score breadth", + "proxy_status": "AVAILABLE_CURRENT_SCORE_PANEL", + } + if proxy_name == "turnover_pressure_bucket": + return { + "source_timing": "t/current_plus_t_minus_1/pre_action", + "source_artifact": "current top-k candidate replacement versus prior top-k candidates", + "proxy_status": "AVAILABLE_SCORE_PANEL_TURNOVER_PROXY", + } + raise KeyError(proxy_name) + + +def signal_bucket_metrics( + rows: list[PredictionRow], + contexts: dict[str, dict[str, Any]], + *, + cost_bp_values: Sequence[int] = DEFAULT_COST_SENSITIVITY_BP, +) -> list[dict[str, Any]]: + enriched: list[dict[str, Any]] = [] + for row in rows: + ctx = contexts[row.date] + enriched.append( + { + "split": row.split, + "fold": _fold_for(row, contexts), + "future_return_1d": row.future_return_1d, + "future_direction_1d": row.future_direction_1d, + "score": row.score, + "score_magnitude_bucket": _bucket_by_threshold(row.score), + "score_sign_bucket": _score_sign_bucket(row.score), + "score_margin_bucket": ctx["score_margin_bucket"], + "d3_confidence_bucket": ctx["d3_confidence_bucket"], + "score_margin": ctx["score_margin"], + } + ) + metric_rows: list[dict[str, Any]] = [] + for bucket_name in ["score_magnitude_bucket", "score_sign_bucket", "score_margin_bucket", "d3_confidence_bucket"]: + metadata = _signal_bucket_metadata(bucket_name) + grouped: dict[tuple[str, str, int], list[dict[str, Any]]] = defaultdict(list) + for row in enriched: + grouped[(row["split"], row["fold"], int(row[bucket_name]))].append(row) + for (split, fold, bucket_value), bucket_rows in sorted(grouped.items()): + returns = [float(row["future_return_1d"]) for row in bucket_rows] + scores = [float(row["score"]) for row in bucket_rows] + margins = [float(row["score_margin"]) for row in bucket_rows] + for cost_bp in cost_bp_values: + cost_rate = cost_bp / 10000.0 + net_returns = [value - cost_rate for value in returns] + metric_rows.append( + { + "split": split, + "fold": fold, + "bucket_name": bucket_name, + "bucket_value": bucket_value, + "count": len(bucket_rows), + "mean_future_return_1d": _mean(returns), + "mean_net_return_1d": _mean(net_returns), + "median_future_return_1d": _median(returns), + "hit_rate": _mean([1.0 if value > 0 else 0.0 for value in returns]), + "mean_score": _mean(scores), + "mean_margin": _mean(margins), + "cost_bp": cost_bp, + "source_timing": metadata["source_timing"], + "source_artifact": metadata["source_artifact"], + "future_label_used_for_bucket": metadata["future_label_used_for_bucket"], + "future_label_used_for_evaluation": True, + "threshold_policy": "frozen_absolute_no_quantile_search_no_oos_retune", + } + ) + return metric_rows + + +def rank_correlations(rows: list[PredictionRow], contexts: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + groups: dict[tuple[str, str], list[PredictionRow]] = defaultdict(list) + for row in rows: + groups[(row.split, _fold_for(row, contexts))].append(row) + output: list[dict[str, Any]] = [] + for (split, fold), group_rows in sorted(groups.items()): + scores = [row.score for row in group_rows] + returns = [row.future_return_1d for row in group_rows] + output.append( + { + "split": split, + "fold": fold, + "score_field": DEFAULT_SCORE_COLUMN, + "spearman_rank_corr": _spearman(scores, returns), + "pearson_corr": _pearson(scores, returns), + "n": len(group_rows), + } + ) + return output + + +def risk_proxy_metrics( + rows: list[PredictionRow], + contexts: dict[str, dict[str, Any]], + *, + cost_bp_values: Sequence[int] = DEFAULT_COST_SENSITIVITY_BP, +) -> list[dict[str, Any]]: + proxy_names = [ + "score_dispersion_bucket", + "recent_score_volatility_bucket", + "past_return_volatility_bucket", + "drawdown_bucket", + "breadth_proxy_bucket", + "turnover_pressure_bucket", + ] + top_return_by_date = { + date: date_rows[0].future_return_1d + for date, date_rows in _group_by_date(rows).items() + } + grouped: dict[tuple[str, str, str, int], list[PredictionRow]] = defaultdict(list) + for row in rows: + ctx = contexts[row.date] + for proxy in proxy_names: + grouped[(row.split, _fold_for(row, contexts), proxy, int(ctx[proxy]))].append(row) + output: list[dict[str, Any]] = [] + for (split, fold, proxy, bucket), group_rows in sorted(grouped.items()): + returns = [row.future_return_1d for row in group_rows] + context_rows = [contexts[row.date] for row in group_rows] + turnover_values = [float(ctx["turnover_pressure"]) for ctx in context_rows] + d3_turnover_values = [float(ctx["top_code_turnover_pressure"]) for ctx in context_rows] + d3_returns = [top_return_by_date[row.date] for row in group_rows] + metadata = _risk_proxy_metadata(proxy, context_rows[0]) + for cost_bp in cost_bp_values: + cost_rate = cost_bp / 10000.0 + net_returns = [ + value - (cost_rate * turnover) + for value, turnover in zip(returns, turnover_values, strict=True) + ] + d3_net_returns = [ + value - (cost_rate * turnover) + for value, turnover in zip(d3_returns, d3_turnover_values, strict=True) + ] + output.append( + { + "split": split, + "fold": fold, + "proxy_name": proxy, + "bucket_value": bucket, + "count": len(group_rows), + "policy_delta_vs_d3": _mean(net_returns) - _mean(d3_net_returns), + "future_return_mean": _mean(returns), + "net_return_mean": _mean(net_returns), + "d3_baseline_net_return_mean": _mean(d3_net_returns), + "mdd_proxy": min(net_returns) if net_returns else 0.0, + "turnover_proxy": _mean(turnover_values), + "cost_bp": cost_bp, + "source_timing": metadata["source_timing"], + "source_artifact": metadata["source_artifact"], + "proxy_status": metadata["proxy_status"], + "future_label_used_for_proxy": False, + "future_label_used_for_evaluation": True, + } + ) + return output + + +def _baseline_drawdown(returns: Sequence[float]) -> float: + nav = 1.0 + peak = 1.0 + max_drawdown = 0.0 + for value in returns: + nav *= 1.0 + value + peak = max(peak, nav) + if peak: + max_drawdown = min(max_drawdown, nav / peak - 1.0) + return max_drawdown + + +def baseline_control_metrics( + rows: list[PredictionRow], + contexts: dict[str, dict[str, Any]], + *, + cost_bp_values: Sequence[int] = DEFAULT_COST_SENSITIVITY_BP, + top_k: int = 5, +) -> list[dict[str, Any]]: + by_date = _group_by_date(rows) + strategy_daily: dict[str, list[dict[str, Any]]] = defaultdict(list) + prior_codes: dict[str, set[str]] = {} + + for date, date_rows in by_date.items(): + ctx = contexts[date] + split = date_rows[0].split + fold = str(ctx["fold_id"]) + selections = { + "no_trade_cash": [], + "frozen_d3_baseline": [date_rows[0]], + "equal_weight_topk": date_rows[: min(top_k, len(date_rows))], + "shuffle_control": [date_rows[int(hashlib.sha256(date.encode("utf-8")).hexdigest(), 16) % len(date_rows)]], + } + for strategy, selected_rows in selections.items(): + selected_codes = {row.code for row in selected_rows} + previous_codes = prior_codes.get(strategy, set()) + if not selected_rows: + gross_return = 0.0 + turnover = 0.0 + else: + gross_return = _mean([row.future_return_1d for row in selected_rows]) + denominator = max(len(selected_codes | previous_codes), 1) + turnover = len(selected_codes.symmetric_difference(previous_codes)) / denominator + prior_codes[strategy] = selected_codes + strategy_daily[strategy].append( + { + "split": split, + "fold": fold, + "gross_return": gross_return, + "turnover": turnover, + } + ) + + rows_out: list[dict[str, Any]] = [] + for strategy, daily_rows in sorted(strategy_daily.items()): + grouped: dict[tuple[str, str], list[dict[str, Any]]] = defaultdict(list) + for row in daily_rows: + grouped[(row["split"], row["fold"])].append(row) + for (split, fold), group_rows in sorted(grouped.items()): + gross_returns = [float(row["gross_return"]) for row in group_rows] + turnovers = [float(row["turnover"]) for row in group_rows] + for cost_bp in cost_bp_values: + cost_rate = cost_bp / 10000.0 + net_returns = [ + gross_return - (cost_rate * turnover) + for gross_return, turnover in zip(gross_returns, turnovers, strict=True) + ] + nav = math.prod([1.0 + value for value in net_returns]) if net_returns else 1.0 + rows_out.append( + { + "split": split, + "fold": fold, + "baseline_strategy": strategy, + "baseline_status": "MEASURED_RESEARCH_ONLY", + "count_days": len(group_rows), + "mean_gross_return": _mean(gross_returns), + "mean_net_return": _mean(net_returns), + "hit_rate": _mean([1.0 if value > 0 else 0.0 for value in net_returns]), + "nav": nav, + "total_net_return": nav - 1.0, + "max_drawdown": _baseline_drawdown(net_returns), + "mean_turnover_proxy": _mean(turnovers), + "cost_bp": cost_bp, + "source_timing": "t/current/pre_action_selection_then_post_bucket_evaluation", + "source_artifact": "predictions.csv frozen score-ranked panel", + "future_label_used_for_selection": False, + "future_label_used_for_evaluation": True, + } + ) + return rows_out + + +def leakage_audit_rows(*, portfolio_context_available: bool) -> list[dict[str, Any]]: + lagged_source = "drawdown.csv lagged reward/current_drawdown path" if portfolio_context_available else "MISSING_PAST_OHLCV_PROXY" + lagged_verdict = "PASS" if portfolio_context_available else "MISSING_PAST_OHLCV_PROXY_FAIL_CLOSED" + return [ + {"feature_name": "score_magnitude_bucket", "timing": "t/current/pre_action", "source_artifact": "predictions.csv score column", "future_label_used": False, "verdict": "PASS"}, + {"feature_name": "score_margin_bucket", "timing": "t/current/pre_action", "source_artifact": "current date candidate scores", "future_label_used": False, "verdict": "PASS"}, + {"feature_name": "d3_confidence_bucket", "timing": "t/current/pre_action", "source_artifact": "current top D3 score", "future_label_used": False, "verdict": "PASS"}, + {"feature_name": "recent_score_volatility_bucket", "timing": "t-1/lookback/pre_action", "source_artifact": "prior top D3 scores", "future_label_used": False, "verdict": "PASS"}, + {"feature_name": "past_return_volatility_bucket", "timing": "t-1/lookback/pre_action", "source_artifact": lagged_source, "future_label_used": False, "verdict": lagged_verdict}, + {"feature_name": "drawdown_bucket", "timing": "t-1/lookback/pre_action", "source_artifact": lagged_source, "future_label_used": False, "verdict": lagged_verdict}, + {"feature_name": "future_return_1d", "timing": "post_bucket/evaluation_label", "source_artifact": "predictions.csv", "future_label_used": True, "verdict": "PASS_EVALUATION_LABEL_ONLY"}, + ] + + +def run_signal_quality_audit( + *, + prediction_dir: Path = DEFAULT_PREDICTION_DIR, + walk_forward_dir: Path | None = DEFAULT_WALK_FORWARD_DIR, + portfolio_dir: Path | None = DEFAULT_PORTFOLIO_DIR, + output_root: Path = DEFAULT_OUTPUT_ROOT, + run_id: str = DEFAULT_RUN_ID, + score_column: str = DEFAULT_SCORE_COLUMN, + cost_bp: int = 23, +) -> dict[str, Any]: + rows = load_predictions(prediction_dir, score_column=score_column) + fold_by_date = load_fold_assignments(walk_forward_dir) + portfolio_context = load_lagged_portfolio_context(portfolio_dir) + contexts = _daily_context(rows, fold_by_date, portfolio_context) + out_dir = output_root / run_id + out_dir.mkdir(parents=True, exist_ok=True) + + bucket_rows = signal_bucket_metrics(rows, contexts) + corr_rows = rank_correlations(rows, contexts) + risk_rows = risk_proxy_metrics(rows, contexts) + baseline_rows = baseline_control_metrics(rows, contexts) + leakage_rows = leakage_audit_rows(portfolio_context_available=bool(portfolio_context)) + + paths = { + "signal_quality_bucket_metrics": out_dir / "signal_quality_bucket_metrics.csv", + "signal_quality_rank_correlations": out_dir / "signal_quality_rank_correlations.csv", + "risk_proxy_bucket_metrics": out_dir / "risk_proxy_bucket_metrics.csv", + "baseline_control_metrics": out_dir / "baseline_control_metrics.csv", + "signal_quality_leakage_audit": out_dir / "signal_quality_leakage_audit.json", + "signal_quality_manifest": out_dir / "signal_quality_manifest.json", + } + _write_csv( + paths["signal_quality_bucket_metrics"], + bucket_rows, + [ + "split", + "fold", + "bucket_name", + "bucket_value", + "count", + "mean_future_return_1d", + "mean_net_return_1d", + "median_future_return_1d", + "hit_rate", + "mean_score", + "mean_margin", + "cost_bp", + "source_timing", + "source_artifact", + "future_label_used_for_bucket", + "future_label_used_for_evaluation", + "threshold_policy", + ], + ) + _write_csv( + paths["signal_quality_rank_correlations"], + corr_rows, + ["split", "fold", "score_field", "spearman_rank_corr", "pearson_corr", "n"], + ) + _write_csv( + paths["risk_proxy_bucket_metrics"], + risk_rows, + [ + "split", + "fold", + "proxy_name", + "bucket_value", + "count", + "policy_delta_vs_d3", + "future_return_mean", + "net_return_mean", + "d3_baseline_net_return_mean", + "mdd_proxy", + "turnover_proxy", + "cost_bp", + "source_timing", + "source_artifact", + "proxy_status", + "future_label_used_for_proxy", + "future_label_used_for_evaluation", + ], + ) + _write_csv( + paths["baseline_control_metrics"], + baseline_rows, + [ + "split", + "fold", + "baseline_strategy", + "baseline_status", + "count_days", + "mean_gross_return", + "mean_net_return", + "hit_rate", + "nav", + "total_net_return", + "max_drawdown", + "mean_turnover_proxy", + "cost_bp", + "source_timing", + "source_artifact", + "future_label_used_for_selection", + "future_label_used_for_evaluation", + ], + ) + _write_json(paths["signal_quality_leakage_audit"], {"schema_version": 1, "rows": leakage_rows, "verdict": "PASS", "guardrail": RESEARCH_GUARDRAIL}) + + splits = sorted({row.split for row in rows}) + fold_ids = sorted({fold for fold in fold_by_date.values()}) + drawdown_path = (portfolio_dir / "drawdown.csv") if portfolio_dir else None + manifest = { + "schema_version": 1, + "run_id": run_id, + "status": "COMPLETED_RESEARCH_ONLY", + "generated_at": _utc_now(), + "mode": "daily_ohlcv_signal_quality_audit", + "platform_stage": "D3_D4_SIGNAL_QUALITY_AUDIT_MVP", + "guardrail": RESEARCH_GUARDRAIL, + "read_only_artifact": True, + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "result_verdict": "WATCH_DIAGNOSTIC_ONLY", + "promotion_status": "NO-GO_RESEARCH_ONLY", + "score_column": score_column, + "score_thresholds": list(DEFAULT_SCORE_THRESHOLDS), + "threshold_policy": "frozen_absolute_no_quantile_search_no_oos_retune", + "cost_round_trip_bp": cost_bp, + "cost_sensitivity_bp": list(DEFAULT_COST_SENSITIVITY_BP), + "cost_sensitivity_artifact_fields": { + "signal_quality_bucket_metrics": "cost_bp has one row set per 0/23/46bp stress", + "risk_proxy_bucket_metrics": "cost_bp has one row set per 0/23/46bp stress", + "baseline_control_metrics": "cost_bp has one row set per 0/23/46bp stress", + }, + "baseline_controls": ["no_trade_cash", "shuffle_control", "equal_weight_topk", "frozen_d3_baseline"], + "baseline_controls_measured": True, + "input_artifacts": { + "prediction_dir": str(prediction_dir), + "predictions_csv": str(prediction_dir / "predictions.csv"), + "walk_forward_dir": str(walk_forward_dir) if walk_forward_dir else None, + "fold_assignments_csv": str((walk_forward_dir / "fold_assignments.csv") if walk_forward_dir else ""), + "portfolio_dir": str(portfolio_dir) if portfolio_dir else None, + "drawdown_csv": str(drawdown_path) if drawdown_path else None, + }, + "source_hashes": { + "predictions_csv": _sha256_file(prediction_dir / "predictions.csv"), + "fold_assignments_csv": _sha256_file(walk_forward_dir / "fold_assignments.csv") if walk_forward_dir and (walk_forward_dir / "fold_assignments.csv").exists() else None, + "drawdown_csv": _sha256_file(drawdown_path) if drawdown_path and drawdown_path.exists() else None, + }, + "row_counts": { + "predictions": len(rows), + "bucket_metrics": len(bucket_rows), + "rank_correlations": len(corr_rows), + "risk_proxy_metrics": len(risk_rows), + "baseline_control_metrics": len(baseline_rows), + "leakage_audit": len(leakage_rows), + }, + "splits": splits, + "fold_ids": fold_ids, + "required_artifacts": {key: str(value) for key, value in paths.items()}, + "past_only_proxy_sources": { + "score_dispersion_bucket": "current D3 score panel before action", + "recent_score_volatility_bucket": "prior top D3 scores", + "past_return_volatility_bucket": "lagged drawdown.csv reward path" if portfolio_context else "MISSING_PAST_OHLCV_PROXY_FAIL_CLOSED", + "drawdown_bucket": "lagged drawdown.csv current_drawdown path" if portfolio_context else "MISSING_PAST_OHLCV_PROXY_FAIL_CLOSED", + "breadth_proxy_bucket": "current candidate score breadth before action", + "turnover_pressure_bucket": "current top-k candidate replacement versus prior top-k candidates", + }, + "abstention_reasons_requirement": "not_applicable_pure_signal_quality_diagnostic_no_action_filter_or_overlay_execution", + "no_future_label_policy": "future_return_1d is evaluation_label_only after bucket/proxy construction", + } + _write_json(paths["signal_quality_manifest"], manifest) + return manifest + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run research-only Daily OHLCV D3/D4 signal-quality diagnostics.") + parser.add_argument("--prediction-dir", type=Path, default=DEFAULT_PREDICTION_DIR) + parser.add_argument("--walk-forward-dir", type=Path, default=DEFAULT_WALK_FORWARD_DIR) + parser.add_argument("--portfolio-dir", type=Path, default=DEFAULT_PORTFOLIO_DIR) + parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUTPUT_ROOT) + parser.add_argument("--run-id", default=DEFAULT_RUN_ID) + parser.add_argument("--score-column", default=DEFAULT_SCORE_COLUMN) + parser.add_argument("--cost-bp", type=int, default=23) + parser.add_argument("--json", action="store_true", help="Print full manifest JSON instead of a compact summary.") + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + manifest = run_signal_quality_audit( + prediction_dir=args.prediction_dir, + walk_forward_dir=args.walk_forward_dir, + portfolio_dir=args.portfolio_dir, + output_root=args.out_dir, + run_id=args.run_id, + score_column=args.score_column, + cost_bp=args.cost_bp, + ) + if args.json: + print(json.dumps(manifest, ensure_ascii=False, indent=2)) + else: + print( + json.dumps( + { + "run_id": manifest["run_id"], + "status": manifest["status"], + "promotion_status": manifest["promotion_status"], + "row_counts": manifest["row_counts"], + "output_dir": str(Path(manifest["required_artifacts"]["signal_quality_manifest"]).parent), + }, + ensure_ascii=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/stom_rl/daily_signal_quality_batch.py b/stom_rl/daily_signal_quality_batch.py new file mode 100644 index 000000000..30fdc1626 --- /dev/null +++ b/stom_rl/daily_signal_quality_batch.py @@ -0,0 +1,191 @@ +"""Batch runner for Daily OHLCV signal-quality diagnostics.""" + +from __future__ import annotations + +import argparse +import json +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from .daily_signal_quality import ( + DEFAULT_COST_SENSITIVITY_BP, + DEFAULT_OUTPUT_ROOT, + DEFAULT_PREDICTION_DIR, + DEFAULT_SCORE_COLUMN, + DEFAULT_PORTFOLIO_DIR, + DEFAULT_WALK_FORWARD_DIR, + RESEARCH_GUARDRAIL, + run_signal_quality_audit, +) + +DEFAULT_BATCH_ROOT = Path("webui/rl_runs/daily_ohlcv_signal_quality_batches") +DEFAULT_BATCH_ID = "scenario_batch_signal_quality_audit_001" + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _merge_config(defaults: dict[str, Any], overrides: dict[str, Any]) -> dict[str, Any]: + merged = dict(defaults) + merged.update(overrides) + return merged + + +def run_signal_quality_batch( + *, + plan_path: Path, + batch_id: str | None = None, + batch_root: Path = DEFAULT_BATCH_ROOT, + output_root: Path = DEFAULT_OUTPUT_ROOT, + overwrite: bool = False, +) -> dict[str, Any]: + plan = _read_json(plan_path) + resolved_batch_id = batch_id or str(plan.get("batch_id") or DEFAULT_BATCH_ID) + batch_dir = batch_root / resolved_batch_id + if batch_dir.exists() and overwrite: + shutil.rmtree(batch_dir) + batch_dir.mkdir(parents=True, exist_ok=True) + + defaults = plan.get("defaults") if isinstance(plan.get("defaults"), dict) else {} + scenarios = plan.get("scenarios") if isinstance(plan.get("scenarios"), list) else [] + if not scenarios: + raise ValueError("Signal-quality batch plan must include at least one scenario") + + runs: list[dict[str, Any]] = [] + gate_status_counts: dict[str, int] = {} + started_at = _utc_now() + for scenario in scenarios: + if not isinstance(scenario, dict): + raise ValueError("Each scenario must be an object") + scenario_id = str(scenario.get("scenario_id") or "scenario") + config = _merge_config(defaults, scenario.get("overrides") if isinstance(scenario.get("overrides"), dict) else {}) + run_id = f"{resolved_batch_id}__{scenario_id}" + manifest = run_signal_quality_audit( + prediction_dir=Path(config.get("prediction_dir") or DEFAULT_PREDICTION_DIR), + walk_forward_dir=Path(config.get("walk_forward_dir") or DEFAULT_WALK_FORWARD_DIR), + portfolio_dir=Path(config.get("portfolio_dir") or DEFAULT_PORTFOLIO_DIR), + output_root=output_root, + run_id=run_id, + score_column=str(config.get("score_column") or DEFAULT_SCORE_COLUMN), + cost_bp=int(config.get("cost_bp") or 23), + ) + status = str(scenario.get("status") or "WATCH") + gate_status_counts[status] = gate_status_counts.get(status, 0) + 1 + runs.append( + { + "scenario_id": scenario_id, + "run_id": run_id, + "status": status, + "promotion_status": manifest.get("promotion_status"), + "hypothesis": scenario.get("hypothesis"), + "diagnostic_focus": scenario.get("diagnostic_focus"), + "assumption_tags": scenario.get("assumption_tags") or [], + "config": config, + "row_counts": manifest.get("row_counts"), + "cost_sensitivity_bp": manifest.get("cost_sensitivity_bp"), + "baseline_controls": manifest.get("baseline_controls"), + "artifact_paths": manifest.get("required_artifacts"), + "baseline_control_metrics": (manifest.get("row_counts") or {}).get("baseline_control_metrics"), + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + ) + + copied_plan = batch_dir / "scenario_batch_plan.json" + _write_json(copied_plan, plan) + manifest_payload = { + "schema_version": 1, + "batch_id": resolved_batch_id, + "generated_at": _utc_now(), + "started_at": started_at, + "mode": "daily_ohlcv_signal_quality_batch", + "platform_stage": "D3_D4_SIGNAL_QUALITY_AUDIT_BATCH_MVP", + "status": "COMPLETED_RESEARCH_ONLY", + "read_only_artifact": True, + "guardrail": RESEARCH_GUARDRAIL, + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "scenario_count": len(runs), + "completed_count": len(runs), + "failed_count": 0, + "gate_status_counts": gate_status_counts, + "cost_sensitivity_bp": list(DEFAULT_COST_SENSITIVITY_BP), + "plan": plan, + "runs": runs, + "comparison_rows": [ + { + "scenario_id": row["scenario_id"], + "status": row["status"], + "promotion_status": row["promotion_status"], + "bucket_metric_rows": (row.get("row_counts") or {}).get("bucket_metrics"), + "rank_correlation_rows": (row.get("row_counts") or {}).get("rank_correlations"), + "risk_proxy_rows": (row.get("row_counts") or {}).get("risk_proxy_metrics"), + "baseline_control_rows": (row.get("row_counts") or {}).get("baseline_control_metrics"), + "baseline_controls_measured": True, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + for row in runs + ], + "artifact_paths": { + "scenario_batch_manifest": str(batch_dir / "scenario_batch_manifest.json"), + "scenario_batch_plan": str(copied_plan), + }, + } + _write_json(batch_dir / "scenario_batch_manifest.json", manifest_payload) + return manifest_payload + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a research-only Daily OHLCV signal-quality diagnostic batch.") + parser.add_argument("--plan", type=Path, required=True) + parser.add_argument("--batch-id", default=None) + parser.add_argument("--batch-root", type=Path, default=DEFAULT_BATCH_ROOT) + parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUTPUT_ROOT) + parser.add_argument("--overwrite", action="store_true") + return parser.parse_args() + + +def main() -> None: + args = _parse_args() + manifest = run_signal_quality_batch( + plan_path=args.plan, + batch_id=args.batch_id, + batch_root=args.batch_root, + output_root=args.out_dir, + overwrite=args.overwrite, + ) + print( + json.dumps( + { + "batch_id": manifest["batch_id"], + "status": manifest["status"], + "scenario_count": manifest["scenario_count"], + "completed_count": manifest["completed_count"], + "failed_count": manifest["failed_count"], + "gate_status_counts": manifest["gate_status_counts"], + }, + ensure_ascii=False, + ) + ) + + +if __name__ == "__main__": + main() diff --git a/stom_rl/daily_walk_forward.py b/stom_rl/daily_walk_forward.py new file mode 100644 index 000000000..bac4b18b9 --- /dev/null +++ b/stom_rl/daily_walk_forward.py @@ -0,0 +1,984 @@ +"""Daily OHLCV forward-validation gates for research-only model/RL evidence. + +D5 consumes frozen D3 prediction artifacts and D4 constrained-RL artifacts. It +creates forward-only evaluation folds and gate evidence; it does not retune on +out-of-sample data, place orders, claim profit, or unlock live/broker readiness. +""" + +from __future__ import annotations + +import csv +import hashlib +import json +import math +import random +import re +from collections import defaultdict +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Iterable + +from .daily_ohlcv_db import PRICE_BASIS, PRICE_BASIS_EVIDENCE, REPO_ROOT +from .daily_prediction import DEFAULT_PREDICTION_ROOT, ROUND_TRIP_COST_BP, evaluate_strategy +from .daily_rl_train import DEFAULT_PORTFOLIO_ROOT + +DEFAULT_WALK_FORWARD_ROOT = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_walk_forward" +WALK_FORWARD_SCHEMA_VERSION = 1 +SAFE_RUN_RE = re.compile(r"^[0-9A-Za-z_.-]+$") +PREREGISTERED_D5_STRATEGY = "equal_weight_topk_momentum" +MAX_ALLOWED_FOLD_DRAWDOWN = -0.20 +MAX_ALLOWED_MEAN_TURNOVER = 1.00 +MIN_REQUIRED_PURGE_DAYS = 5 +MIN_REQUIRED_EMBARGO_DAYS = 5 +REQUIRED_BASELINE_COMPARISON_FIELDS = ( + "policy_total_net_return", + "best_d3_total_net_return", + "delta_vs_best_d3_total_net_return", + "cost_round_trip_bp", +) +REQUIRED_D4_BASELINES = ( + "no_trade_cash", + "shuffle_control", + PREREGISTERED_D5_STRATEGY, +) +REQUIRED_D4_CSV_FIELDS = { + "state_observations": {"split", "date", "future_label_exposed"}, + "reward_breakdown": {"split", "date", "reward", "turnover", "exposure", "invalid_action"}, + "invalid_actions": {"split", "date", "invalid_action"}, + "policy_baseline_comparison": {"baseline_strategy", "baseline_status", "cost_round_trip_bp"}, + "policy_nav": {"split", "date", "policy_nav"}, + "reward_action_ablations": {"split", "ablation_family", "ablation", "cost_round_trip_bp"}, +} +D4_CSV_SCHEMA_ISSUES = { + "state_observations": "D4_STATE_OBSERVATIONS_SCHEMA_INVALID", + "reward_breakdown": "D4_REWARD_BREAKDOWN_SCHEMA_INVALID", + "invalid_actions": "D4_INVALID_ACTIONS_SCHEMA_INVALID", + "policy_baseline_comparison": "D4_POLICY_BASELINE_COMPARISON_SCHEMA_INVALID", + "policy_nav": "D4_POLICY_NAV_SCHEMA_INVALID", + "reward_action_ablations": "D4_REWARD_ACTION_ABLATIONS_SCHEMA_INVALID", +} +SCORE_BY_STRATEGY = { + "equal_weight_topk_momentum": "score_equal_weight_topk_momentum", + "vol_adjusted_momentum": "score_vol_adjusted_momentum", + "mean_reversion": "score_mean_reversion", + "supervised_linear_ranker": "score_supervised_linear_ranker", + "supervised_direction_classifier": "score_supervised_direction_classifier", +} +RESEARCH_GUARDRAIL = ( + "Research-only D5 daily walk-forward/gate evidence; no profit guarantee, " + "no live/broker/orders, no deployable model readiness claim." +) +D4_STATE_MANIFEST_GATE = "D4_OBSERVATION_STATE_MANIFEST" +REQUIRED_D4_STATE_ARTIFACTS = { + "observation_manifest": "observation_manifest.json", + "state_observations": "state_observations.csv", + "reward_breakdown": "reward_breakdown.csv", + "invalid_actions": "invalid_actions.csv", + "policy_baseline_comparison": "policy_baseline_comparison.csv", + "policy_nav": "policy_nav.csv", + "reward_action_ablations": "reward_action_ablations.csv", + "reward_action_ablation_summary": "reward_action_ablation_summary.json", + "source_hashes": "source_hashes.json", +} + + +def _utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def _validate_run_id(run_id: str) -> str: + rid = str(run_id or "").strip() + if not SAFE_RUN_RE.match(rid) or rid in {".", ".."} or "/" in rid or "\\" in rid: + raise ValueError("run_id contains unsafe characters") + return rid + + +def _latest_run_dir(root: Path, required_file: str) -> Path: + candidates = sorted(root.glob(f"*/{required_file}"), key=lambda p: p.stat().st_mtime, reverse=True) + if not candidates: + raise FileNotFoundError(f"No {required_file} under {root}") + return candidates[0].parent + + +def _read_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _read_csv(path: Path) -> list[dict[str, Any]]: + with path.open("r", encoding="utf-8", newline="") as handle: + return [dict(row) for row in csv.DictReader(handle)] + + +def _read_json_contract(path: Path, *, issue: str, issues: list[str]) -> dict[str, Any]: + try: + payload = _read_json(path) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + issues.append(issue) + return {} + if not isinstance(payload, dict): + issues.append(issue) + return {} + return payload + + +def _read_csv_contract(path: Path, *, issue: str, issues: list[str]) -> list[dict[str, Any]]: + try: + return _read_csv(path) + except (csv.Error, OSError, UnicodeDecodeError): + issues.append(issue) + return [] + +def _file_sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _artifact_hashes(paths: dict[str, Path]) -> dict[str, str]: + return {key: _file_sha256(path) for key, path in paths.items() if path.exists()} + + +def _load_d4_state_contract(portfolio_dir: Path, portfolio_manifest: dict[str, Any]) -> dict[str, Any]: + artifact_paths = {key: portfolio_dir / filename for key, filename in REQUIRED_D4_STATE_ARTIFACTS.items()} + missing_artifacts = [filename for filename in REQUIRED_D4_STATE_ARTIFACTS.values() if not (portfolio_dir / filename).exists()] + issues = [f"D4_REQUIRED_ARTIFACT_MISSING_{name}" for name in missing_artifacts] + observation_manifest = ( + _read_json_contract( + artifact_paths["observation_manifest"], + issue="D4_OBSERVATION_MANIFEST_JSON_INVALID", + issues=issues, + ) + if artifact_paths["observation_manifest"].exists() + else dict(portfolio_manifest.get("observation_manifest") or {}) + ) + validation = dict(portfolio_manifest.get("observation_manifest_validation") or {}) + state_rows = ( + _read_csv_contract( + artifact_paths["state_observations"], + issue="D4_STATE_OBSERVATIONS_CSV_INVALID", + issues=issues, + ) + if artifact_paths["state_observations"].exists() + else [] + ) + invalid_rows = ( + _read_csv_contract(artifact_paths["invalid_actions"], issue="D4_INVALID_ACTIONS_CSV_INVALID", issues=issues) + if artifact_paths["invalid_actions"].exists() + else [] + ) + reward_rows = ( + _read_csv_contract(artifact_paths["reward_breakdown"], issue="D4_REWARD_BREAKDOWN_CSV_INVALID", issues=issues) + if artifact_paths["reward_breakdown"].exists() + else [] + ) + baseline_rows = ( + _read_csv_contract( + artifact_paths["policy_baseline_comparison"], + issue="D4_POLICY_BASELINE_COMPARISON_CSV_INVALID", + issues=issues, + ) + if artifact_paths["policy_baseline_comparison"].exists() + else [] + ) + nav_rows = ( + _read_csv_contract(artifact_paths["policy_nav"], issue="D4_POLICY_NAV_CSV_INVALID", issues=issues) + if artifact_paths["policy_nav"].exists() + else [] + ) + ablation_rows = ( + _read_csv_contract( + artifact_paths["reward_action_ablations"], + issue="D4_REWARD_ACTION_ABLATIONS_CSV_INVALID", + issues=issues, + ) + if artifact_paths["reward_action_ablations"].exists() + else [] + ) + ablation_summary = ( + _read_json_contract( + artifact_paths["reward_action_ablation_summary"], + issue="D4_REWARD_ACTION_ABLATION_SUMMARY_JSON_INVALID", + issues=issues, + ) + if artifact_paths["reward_action_ablation_summary"].exists() + else {} + ) + source_hash_payload = ( + _read_json_contract(artifact_paths["source_hashes"], issue="D4_SOURCE_HASHES_JSON_INVALID", issues=issues) + if artifact_paths["source_hashes"].exists() + else {} + ) + source_hashes = ( + source_hash_payload.get("source_hashes") + if isinstance(source_hash_payload.get("source_hashes"), dict) + else {} + ) + + csv_rows_by_artifact = { + "state_observations": state_rows, + "reward_breakdown": reward_rows, + "invalid_actions": invalid_rows, + "policy_baseline_comparison": baseline_rows, + "policy_nav": nav_rows, + "reward_action_ablations": ablation_rows, + } + for artifact_key, required_fields in REQUIRED_D4_CSV_FIELDS.items(): + rows = csv_rows_by_artifact[artifact_key] + if artifact_paths[artifact_key].exists() and rows: + observed_fields = {field for row in rows for field in row.keys()} + if not required_fields <= observed_fields: + issues.append(D4_CSV_SCHEMA_ISSUES[artifact_key]) + + manifest_required_baselines = ( + observation_manifest.get("frozen_d3_comparison", {}).get("required_baselines") + if isinstance(observation_manifest.get("frozen_d3_comparison"), dict) + else None + ) + if not isinstance(manifest_required_baselines, list) or not manifest_required_baselines: + issues.append("D4_FROZEN_D3_BASELINE_REQUIREMENTS_MISSING") + manifest_required_baselines = [] + required_baselines = sorted({*(str(baseline) for baseline in manifest_required_baselines), *REQUIRED_D4_BASELINES}) + loaded_baselines = { + str(row.get("baseline_strategy") or "") + for row in baseline_rows + if str(row.get("baseline_status") or "") == "LOADED" + } + missing_baselines = [baseline for baseline in required_baselines if baseline not in loaded_baselines] + + if observation_manifest.get("gate") != D4_STATE_MANIFEST_GATE: + issues.append("D4_OBSERVATION_STATE_MANIFEST_GATE_INVALID") + if validation.get("status") != "PASS": + issues.append("D4_OBSERVATION_MANIFEST_VALIDATION_NOT_PASS") + if portfolio_manifest.get("state_contract_status") != "PASS": + issues.append("D4_STATE_CONTRACT_STATUS_NOT_PASS") + if observation_manifest.get("reward_action_telemetry_sufficient_for_d4") is not False: + issues.append("D4_REWARD_ACTION_TELEMETRY_FLAG_NOT_FALSE") + if not state_rows: + issues.append( + "D4_STATE_OBSERVATIONS_EMPTY" + if artifact_paths["state_observations"].exists() + else "D4_STATE_OBSERVATIONS_MISSING" + ) + if not reward_rows: + issues.append("D4_REWARD_BREAKDOWN_EMPTY") + if not invalid_rows: + issues.append("D4_INVALID_ACTIONS_EMPTY") + if not baseline_rows: + issues.append("D4_POLICY_BASELINE_COMPARISON_EMPTY") + if not nav_rows: + issues.append("D4_POLICY_NAV_EMPTY") + if missing_baselines: + issues.extend(f"D4_FROZEN_D3_BASELINE_MISSING_{baseline}" for baseline in missing_baselines) + if artifact_paths["reward_action_ablations"].exists() and not ablation_rows: + issues.append("D4_REWARD_ACTION_ABLATIONS_EMPTY") + if artifact_paths["reward_action_ablation_summary"].exists() and not ablation_summary: + issues.append("D4_REWARD_ACTION_ABLATION_SUMMARY_EMPTY") + if artifact_paths["source_hashes"].exists() and not source_hashes: + issues.append("D4_SOURCE_HASHES_MISSING") + + return { + "schema_version": WALK_FORWARD_SCHEMA_VERSION, + "required_gate": D4_STATE_MANIFEST_GATE, + "status": "PASS" if not issues else "BLOCKED", + "gate": observation_manifest.get("gate"), + "observation_manifest_status": observation_manifest.get("status"), + "observation_manifest_validation_status": validation.get("status"), + "state_contract_status": portfolio_manifest.get("state_contract_status"), + "reward_action_telemetry_sufficient_for_d4": observation_manifest.get( + "reward_action_telemetry_sufficient_for_d4" + ), + "observation_fields": validation.get("observation_fields") or [], + "missing_artifacts": missing_artifacts, + "missing_frozen_d3_baselines": missing_baselines, + "source_hash_count": len(source_hashes), + "reward_action_ablation_summary_status": "PASS" if ablation_summary else "MISSING", + "row_counts": { + "state_observations": len(state_rows), + "invalid_actions": len(invalid_rows), + "reward_breakdown": len(reward_rows), + "policy_baseline_comparison": len(baseline_rows), + "policy_nav": len(nav_rows), + "reward_action_ablations": len(ablation_rows), + "source_hashes": len(source_hashes), + }, + "artifacts": {key: str(path) for key, path in artifact_paths.items()}, + "issues": issues, + "guardrail": "D5 consumes D4 state-aware artifacts only as research evidence; no live/broker/orders or profit claim.", + } + + +def _write_json(path: Path, payload: dict[str, Any]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _write_csv(path: Path, rows: list[dict[str, Any]], fallback_fields: list[str]) -> None: + if rows: + field_set = {key for row in rows for key in row.keys()} + fields = [field for field in fallback_fields if field in field_set] + fields.extend(sorted(field_set - set(fields))) + else: + fields = fallback_fields + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def _resolve_run(run_dir: Path | str | None, root: Path, required_file: str) -> Path: + if run_dir is not None: + resolved = Path(run_dir).resolve() + resolved.relative_to(root.resolve()) + if not (resolved / required_file).exists(): + raise FileNotFoundError(resolved / required_file) + return resolved + return _latest_run_dir(root, required_file) + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + if value in (None, ""): + return default + result = float(value) + if math.isnan(result) or math.isinf(result): + return default + return result + except (TypeError, ValueError): + return default + + +def _finite_float(value: Any) -> float | None: + try: + if value in (None, ""): + return None + result = float(value) + if math.isnan(result) or math.isinf(result): + return None + return result + except (TypeError, ValueError): + return None + + +def _safe_int(value: Any, default: int = 0) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _baseline_comparison_issues(baseline_comparison: dict[str, Any]) -> list[str]: + issues: list[str] = [] + for field in REQUIRED_BASELINE_COMPARISON_FIELDS: + if _finite_float(baseline_comparison.get(field)) is None: + issues.append(f"D5_BASELINE_COMPARISON_FIELD_INVALID_{field}") + cost_bp = _finite_float(baseline_comparison.get("cost_round_trip_bp")) + if cost_bp is not None and cost_bp != float(ROUND_TRIP_COST_BP): + issues.append("D5_BASELINE_COMPARISON_COST_MISMATCH") + return issues + + +def _mean(values: Iterable[float]) -> float: + clean = [float(value) for value in values] + return sum(clean) / len(clean) if clean else 0.0 + + +def _max_drawdown(equity_values: Iterable[float]) -> float: + peak = 1.0 + max_dd = 0.0 + for value in equity_values: + peak = max(peak, value) + if peak: + max_dd = min(max_dd, value / peak - 1.0) + return max_dd + + +def _product_return(returns: Iterable[float]) -> tuple[float, list[float]]: + equity = 1.0 + curve: list[float] = [] + for value in returns: + equity *= 1.0 + float(value) + curve.append(equity) + return equity - 1.0, curve + + +def _selected_d5_strategy(_baseline_metrics: list[dict[str, Any]]) -> str: + return PREREGISTERED_D5_STRATEGY + + +def _dates_from_rows(rows: Iterable[dict[str, Any]], *, splits: set[str] | None = None) -> list[str]: + selected = [] + for row in rows: + if splits is not None and str(row.get("split")) not in splits: + continue + date = row.get("date") + if date is not None: + selected.append(str(date)) + return sorted(set(selected)) + + +def assign_forward_folds( + all_dates: list[str], + eval_dates: list[str], + *, + n_folds: int = 5, + purge_days: int = 5, + embargo_days: int = 5, +) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[str]]: + fold_errors: list[str] = [] + try: + fold_count = int(n_folds) + except (TypeError, ValueError): + fold_count = 0 + fold_errors.append("N_FOLDS_INVALID") + try: + purge_count = int(purge_days) + except (TypeError, ValueError): + purge_count = 0 + fold_errors.append("PURGE_DAYS_INVALID") + try: + embargo_count = int(embargo_days) + except (TypeError, ValueError): + embargo_count = 0 + fold_errors.append("EMBARGO_DAYS_INVALID") + if fold_count < 5: + fold_errors.append("N_FOLDS_BELOW_5") + if purge_count < MIN_REQUIRED_PURGE_DAYS: + fold_errors.append("PURGE_DAYS_BELOW_REQUIRED_MIN") + if embargo_count < MIN_REQUIRED_EMBARGO_DAYS: + fold_errors.append("EMBARGO_DAYS_BELOW_REQUIRED_MIN") + if fold_errors: + return [], [], fold_errors + ordered_all = sorted(set(all_dates)) + ordered_eval = sorted(set(eval_dates)) + if len(ordered_eval) < fold_count: + return [], [], ["INSUFFICIENT_EVAL_DATES_FOR_5_FOLDS"] + + fold_rows: list[dict[str, Any]] = [] + assignment_rows: list[dict[str, Any]] = [] + fold_size = math.ceil(len(ordered_eval) / fold_count) + for fold_index in range(fold_count): + start = fold_index * fold_size + end = min(len(ordered_eval), (fold_index + 1) * fold_size) + test_dates = ordered_eval[start:end] + if not test_dates: + continue + test_start = test_dates[0] + test_end = test_dates[-1] + all_start_index = ordered_all.index(test_start) + all_end_index = ordered_all.index(test_end) + purge_window = ordered_all[max(0, all_start_index - purge_count) : all_start_index] + train_dates = ordered_all[: max(0, all_start_index - purge_count)] + embargo_window = ordered_all[all_end_index + 1 : all_end_index + 1 + embargo_count] + fold_id = f"F{fold_index + 1:02d}" + fold_rows.append( + { + "fold_id": fold_id, + "fold_index": fold_index + 1, + "train_start_date": train_dates[0] if train_dates else "", + "train_end_date": train_dates[-1] if train_dates else "", + "test_start_date": test_start, + "test_end_date": test_end, + "test_days": len(test_dates), + "purge_days": purge_count, + "embargo_days": embargo_count, + "purge_start_date": purge_window[0] if purge_window else "", + "purge_end_date": purge_window[-1] if purge_window else "", + "embargo_start_date": embargo_window[0] if embargo_window else "", + "embargo_end_date": embargo_window[-1] if embargo_window else "", + "forward_only": (not train_dates) or train_dates[-1] < test_start, + "retuned_on_oos": False, + } + ) + for date in test_dates: + assignment_rows.append({"fold_id": fold_id, "date": date, "role": "test"}) + for date in purge_window: + assignment_rows.append({"fold_id": fold_id, "date": date, "role": "purge"}) + for date in embargo_window: + assignment_rows.append({"fold_id": fold_id, "date": date, "role": "embargo"}) + if len(fold_rows) < 5: + return fold_rows, assignment_rows, ["N_FOLDS_BELOW_5_AFTER_ASSIGNMENT"] + return fold_rows, assignment_rows, [] + + +def _rows_for_dates(rows: list[dict[str, Any]], dates: set[str]) -> list[dict[str, Any]]: + return [row for row in rows if str(row.get("date")) in dates] + + +def _shuffle_scores(rows: list[dict[str, Any]], *, score_column: str, seed: int) -> list[dict[str, Any]]: + rng = random.Random(seed) + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for row in rows: + grouped[str(row.get("date"))].append(row) + shuffled_rows: list[dict[str, Any]] = [] + for date, date_rows in sorted(grouped.items()): + values = [row.get(score_column) for row in date_rows] + rng.shuffle(values) + for row, value in zip(date_rows, values): + copied = dict(row) + copied[score_column] = value + shuffled_rows.append(copied) + return shuffled_rows + + +def _evaluate_prediction_fold( + rows: list[dict[str, Any]], + *, + fold_id: str, + strategy: str, + top_k: int, + cost_bp: float, + control: str = "actual", +) -> dict[str, Any]: + _positions, metrics, _daily, _turnover = evaluate_strategy( + rows, + strategy=strategy, + top_k=top_k, + cost_rate=float(cost_bp) / 10_000.0, + splits=("val", "test"), + ) + return { + "fold_id": fold_id, + "strategy": strategy, + "control": control, + "cost_bp": float(cost_bp), + "trade_days": metrics.get("trade_days", 0), + "positions": metrics.get("positions", 0), + "total_net_return": metrics.get("total_net_return", 0.0), + "mean_daily_net_return": metrics.get("mean_daily_net_return", 0.0), + "hit_rate": metrics.get("hit_rate", 0.0), + "max_drawdown": metrics.get("max_drawdown", 0.0), + "mean_turnover": metrics.get("mean_turnover", 0.0), + } + + +def _evaluate_rl_fold(rows: list[dict[str, Any]], *, fold_id: str) -> dict[str, Any]: + rewards = [_safe_float(row.get("reward")) for row in rows] + total_return, equity_curve = _product_return(rewards) + return { + "fold_id": fold_id, + "strategy": "tabular_q_constrained_daily_portfolio_rl", + "trade_days": len(rows), + "total_net_return": total_return, + "mean_daily_reward": _mean(rewards), + "max_drawdown": _max_drawdown(equity_curve), + "mean_turnover": _mean(_safe_float(row.get("turnover")) for row in rows), + "mean_exposure": _mean(_safe_float(row.get("exposure")) for row in rows), + "invalid_action_rate": _mean(1.0 if str(row.get("invalid_action", "False")) == "True" else 0.0 for row in rows), + } + + +def _fold_summary(rows: list[dict[str, Any]], *, strategy: str) -> dict[str, Any]: + selected = [ + row + for row in rows + if row.get("strategy") == strategy + and row.get("control") == "actual" + and float(row.get("cost_bp") or 0.0) == ROUND_TRIP_COST_BP + ] + values = [_safe_float(row.get("total_net_return")) for row in selected] + drawdowns = [_safe_float(row.get("max_drawdown")) for row in selected] + turnovers = [_safe_float(row.get("mean_turnover")) for row in selected] + deltas_vs_no_trade = [_safe_float(row.get("delta_vs_no_trade_total_net_return")) for row in selected] + deltas_vs_shuffle = [_safe_float(row.get("delta_vs_shuffled_total_net_return")) for row in selected] + return { + "strategy": strategy, + "fold_count": len(selected), + "positive_folds": sum(1 for value in values if value > 0), + "negative_folds": sum(1 for value in values if value <= 0), + "folds_beating_no_trade": sum(1 for value in deltas_vs_no_trade if value > 0), + "folds_beating_shuffle": sum(1 for value in deltas_vs_shuffle if value > 0), + "mean_fold_total_net_return": _mean(values), + "worst_fold_total_net_return": min(values) if values else 0.0, + "best_fold_total_net_return": max(values) if values else 0.0, + "worst_fold_max_drawdown": min(drawdowns) if drawdowns else 0.0, + "mean_fold_turnover": _mean(turnovers), + } + +def _attach_fold_deltas(rows: list[dict[str, Any]], *, strategy: str) -> None: + no_trade_by_fold = { + str(row.get("fold_id")): _safe_float(row.get("total_net_return")) + for row in rows + if row.get("strategy") == "no_trade_cash" and row.get("control") == "actual" + } + shuffled_by_fold = { + str(row.get("fold_id")): _safe_float(row.get("total_net_return")) + for row in rows + if row.get("strategy") == strategy and row.get("control") == "shuffled_score" + } + for row in rows: + fold_id = str(row.get("fold_id")) + total_return = _safe_float(row.get("total_net_return")) + row["delta_vs_no_trade_total_net_return"] = total_return - no_trade_by_fold.get(fold_id, 0.0) + if row.get("strategy") == strategy and row.get("control") == "actual": + row["delta_vs_shuffled_total_net_return"] = total_return - shuffled_by_fold.get(fold_id, 0.0) + else: + row["delta_vs_shuffled_total_net_return"] = "" + + + +def run_daily_walk_forward( + *, + prediction_run_dir: Path | str | None = None, + portfolio_run_dir: Path | str | None = None, + n_folds: int = 5, + purge_days: int = 5, + embargo_days: int = 5, + top_k: int = 20, + seed: int = 17, +) -> dict[str, Any]: + prediction_dir = _resolve_run(prediction_run_dir, DEFAULT_PREDICTION_ROOT, "prediction_manifest.json") + portfolio_dir = _resolve_run(portfolio_run_dir, DEFAULT_PORTFOLIO_ROOT, "rl_manifest.json") + prediction_manifest = _read_json(prediction_dir / "prediction_manifest.json") + prediction_verdict = _read_json(prediction_dir / "verdict.json") + baseline_metrics = _read_json(prediction_dir / "baseline_metrics.json").get("metrics", []) + prediction_rows = _read_csv(prediction_dir / "predictions.csv") + portfolio_manifest = _read_json(portfolio_dir / "rl_manifest.json") + portfolio_verdict = _read_json(portfolio_dir / "verdict.json") + prediction_artifact_hashes = _artifact_hashes( + { + "prediction_manifest": prediction_dir / "prediction_manifest.json", + "predictions": prediction_dir / "predictions.csv", + "baseline_metrics": prediction_dir / "baseline_metrics.json", + "verdict": prediction_dir / "verdict.json", + } + ) + portfolio_artifact_hashes = _artifact_hashes( + { + "rl_manifest": portfolio_dir / "rl_manifest.json", + "verdict": portfolio_dir / "verdict.json", + "baseline_comparison": portfolio_dir / "baseline_comparison.json", + **{key: portfolio_dir / filename for key, filename in REQUIRED_D4_STATE_ARTIFACTS.items()}, + } + ) + d4_state_contract = _load_d4_state_contract(portfolio_dir, portfolio_manifest) + d5_input_artifact_issues: list[str] = [] + reward_breakdown_path = portfolio_dir / "reward_breakdown.csv" + rl_reward_rows = ( + [ + row + for row in _read_csv_contract( + reward_breakdown_path, + issue="D4_REWARD_BREAKDOWN_CSV_INVALID", + issues=d5_input_artifact_issues, + ) + if row.get("split") == "val+test" + ] + if reward_breakdown_path.exists() + else [] + ) + baseline_comparison_path = portfolio_dir / "baseline_comparison.json" + baseline_comparison: dict[str, Any] = {} + baseline_comparison_loaded = False + if baseline_comparison_path.exists(): + try: + baseline_payload = _read_json(baseline_comparison_path) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + d5_input_artifact_issues.append("D5_BASELINE_COMPARISON_JSON_INVALID") + else: + if isinstance(baseline_payload, dict): + baseline_comparison = baseline_payload + baseline_comparison_loaded = True + else: + d5_input_artifact_issues.append("D5_BASELINE_COMPARISON_JSON_INVALID") + else: + d5_input_artifact_issues.append("D5_BASELINE_COMPARISON_MISSING") + if baseline_comparison_loaded: + d5_input_artifact_issues.extend(_baseline_comparison_issues(baseline_comparison)) + + selected_strategy = _selected_d5_strategy(baseline_metrics) + selected_score_column = SCORE_BY_STRATEGY.get(selected_strategy, "score_equal_weight_topk_momentum") + all_dates = _dates_from_rows(prediction_rows) + eval_dates = _dates_from_rows(prediction_rows, splits={"val", "test"}) + fold_rows, assignment_rows, fold_errors = assign_forward_folds( + all_dates, + eval_dates, + n_folds=n_folds, + purge_days=purge_days, + embargo_days=embargo_days, + ) + + metric_rows: list[dict[str, Any]] = [] + shuffle_rows: list[dict[str, Any]] = [] + cost_rows: list[dict[str, Any]] = [] + rl_rows: list[dict[str, Any]] = [] + if not fold_errors: + for fold in fold_rows: + fold_dates = {row["date"] for row in assignment_rows if row["fold_id"] == fold["fold_id"] and row["role"] == "test"} + fold_prediction_rows = _rows_for_dates(prediction_rows, fold_dates) + metric_rows.append( + _evaluate_prediction_fold( + fold_prediction_rows, + fold_id=fold["fold_id"], + strategy=selected_strategy, + top_k=top_k, + cost_bp=ROUND_TRIP_COST_BP, + control="actual", + ) + ) + metric_rows.append( + _evaluate_prediction_fold( + fold_prediction_rows, + fold_id=fold["fold_id"], + strategy="no_trade_cash", + top_k=top_k, + cost_bp=ROUND_TRIP_COST_BP, + control="actual", + ) + ) + shuffled = _shuffle_scores(fold_prediction_rows, score_column=selected_score_column, seed=seed + int(fold["fold_index"])) + shuffle_metric = _evaluate_prediction_fold( + shuffled, + fold_id=fold["fold_id"], + strategy=selected_strategy, + top_k=top_k, + cost_bp=ROUND_TRIP_COST_BP, + control="shuffled_score", + ) + shuffle_rows.append(shuffle_metric) + metric_rows.append(shuffle_metric) + for cost_bp in (0.0, float(ROUND_TRIP_COST_BP), float(ROUND_TRIP_COST_BP * 2)): + cost_rows.append( + _evaluate_prediction_fold( + fold_prediction_rows, + fold_id=fold["fold_id"], + strategy=selected_strategy, + top_k=top_k, + cost_bp=cost_bp, + control="cost_sensitivity", + ) + ) + fold_rl_rows = _rows_for_dates(rl_reward_rows, fold_dates) + rl_rows.append(_evaluate_rl_fold(fold_rl_rows, fold_id=fold["fold_id"])) + _attach_fold_deltas(metric_rows, strategy=selected_strategy) + + selected_summary = _fold_summary(metric_rows, strategy=selected_strategy) + shuffled_total = sum(_safe_float(row.get("total_net_return")) for row in shuffle_rows) + selected_total = sum( + _safe_float(row.get("total_net_return")) + for row in metric_rows + if row.get("strategy") == selected_strategy and row.get("control") == "actual" and float(row.get("cost_bp") or 0.0) == ROUND_TRIP_COST_BP + ) + rl_total = sum(_safe_float(row.get("total_net_return")) for row in rl_rows) + rl_delta_vs_best_d3 = _safe_float(baseline_comparison.get("delta_vs_best_d3_total_net_return")) + max_drawdown_limit_exceeded = float(selected_summary.get("worst_fold_max_drawdown") or 0.0) < MAX_ALLOWED_FOLD_DRAWDOWN + mean_turnover_limit_exceeded = float(selected_summary.get("mean_fold_turnover") or 0.0) > MAX_ALLOWED_MEAN_TURNOVER + d4_issues = [*d5_input_artifact_issues, *list(d4_state_contract.get("issues") or [])] + reasons: list[str] = ["RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM"] + status = "NO-GO" + if fold_errors or d4_issues: + reasons.extend(fold_errors) + reasons.extend(d4_issues) + reasons.append("D5_REQUIRED_EVIDENCE_INCOMPLETE") + else: + reasons.append("FORWARD_FOLDS_COMPLETE_NO_OOS_RETUNING") + reasons.append("D4_OBSERVATION_STATE_MANIFEST_CONSUMED") + if selected_total <= shuffled_total: + reasons.append("SHUFFLE_CONTROL_NOT_BEATEN_BY_SELECTED_BASELINE") + if str(prediction_manifest.get("price_basis") or PRICE_BASIS) == "unknown": + reasons.append("PRICE_BASIS_UNKNOWN") + if "WATCH" in str(prediction_manifest.get("universe_review_status") or ""): + reasons.append("UNIVERSE_WATCH_HEURISTIC") + if rl_delta_vs_best_d3 <= 0 or rl_total <= selected_total: + reasons.append("RL_POLICY_UNDERPERFORMS_D3_BASELINE") + else: + reasons.append("D5_RESEARCH_ONLY_MODEL_BUILD_LOCK") + if str(portfolio_verdict.get("status")) == "RESEARCH_ONLY": + reasons.append("D4_RL_RESEARCH_ONLY_LOCK") + if max_drawdown_limit_exceeded: + reasons.append("MAX_DRAWDOWN_LIMIT_EXCEEDED") + if mean_turnover_limit_exceeded: + reasons.append("MEAN_TURNOVER_LIMIT_EXCEEDED") + if not max_drawdown_limit_exceeded and not mean_turnover_limit_exceeded: + reasons.append("MDD_TURNOVER_LIMITS_CHECKED") + readiness_status = "D5_NO_GO_RESEARCH_ONLY_GATE" + gate_verdict = { + "schema_version": WALK_FORWARD_SCHEMA_VERSION, + "status": status, + "readiness_status": readiness_status, + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "selected_strategy": selected_strategy, + "strategy_selection_policy": "preregistered_equal_weight_topk_momentum_no_oos_metric_selection", + "n_folds": len(fold_rows), + "required_min_folds": 5, + "no_oos_retuning": True, + "purge_days": _safe_int(purge_days), + "embargo_days": _safe_int(embargo_days), + "min_required_purge_days": MIN_REQUIRED_PURGE_DAYS, + "min_required_embargo_days": MIN_REQUIRED_EMBARGO_DAYS, + "d4_state_contract_status": d4_state_contract.get("status"), + "d4_observation_manifest_gate": d4_state_contract.get("gate"), + "d4_observation_manifest_validation_status": d4_state_contract.get("observation_manifest_validation_status"), + "d4_state_contract_artifacts_consumed": d4_state_contract.get("status") == "PASS", + "d4_state_observation_rows": (d4_state_contract.get("row_counts") or {}).get("state_observations", 0), + "d4_reward_action_ablation_rows": (d4_state_contract.get("row_counts") or {}).get("reward_action_ablations", 0), + "d4_source_hash_count": (d4_state_contract.get("row_counts") or {}).get("source_hashes", 0), + "d4_reward_action_telemetry_sufficient_for_d4": d4_state_contract.get( + "reward_action_telemetry_sufficient_for_d4" + ), + "d4_artifact_issues": d4_issues, + "max_allowed_fold_drawdown": MAX_ALLOWED_FOLD_DRAWDOWN, + "max_drawdown_limit_exceeded": max_drawdown_limit_exceeded, + "max_allowed_mean_turnover": MAX_ALLOWED_MEAN_TURNOVER, + "mean_turnover_limit_exceeded": mean_turnover_limit_exceeded, + "selected_total_net_return_sum": selected_total, + "shuffled_total_net_return_sum": shuffled_total, + "rl_total_net_return_sum": rl_total, + "rl_delta_vs_best_d3_total_net_return": rl_delta_vs_best_d3, + "fold_consistency": selected_summary, + "price_basis": prediction_manifest.get("price_basis") or PRICE_BASIS, + "price_basis_evidence": prediction_manifest.get("price_basis_evidence") or PRICE_BASIS_EVIDENCE, + "universe_review_status": prediction_manifest.get("universe_review_status"), + "cost_sensitivity_bp": [0, ROUND_TRIP_COST_BP, ROUND_TRIP_COST_BP * 2], + "slippage_assumption": portfolio_manifest.get("slippage_assumption") or "Daily OHLCV has no separate slippage model; use 23bp and D5 sensitivity.", + "prediction_artifact_hashes": prediction_artifact_hashes, + "portfolio_artifact_hashes": portfolio_artifact_hashes, + "reasons": reasons, + } + manifest = { + "schema_version": WALK_FORWARD_SCHEMA_VERSION, + "generated_at": _utc_now(), + "guardrail": RESEARCH_GUARDRAIL, + "prediction_run_dir": str(prediction_dir), + "portfolio_run_dir": str(portfolio_dir), + "prediction_verdict_status": prediction_verdict.get("status"), + "portfolio_verdict_status": portfolio_verdict.get("status"), + "prediction_manifest_sha": prediction_artifact_hashes.get("prediction_manifest"), + "prediction_artifact_hashes": prediction_artifact_hashes, + "portfolio_manifest_sha": portfolio_artifact_hashes.get("rl_manifest"), + "portfolio_artifact_hashes": portfolio_artifact_hashes, + "required_d4_state_artifacts": dict(REQUIRED_D4_STATE_ARTIFACTS), + "portfolio_run_id": portfolio_manifest.get("run_id"), + "d4_state_contract_status": d4_state_contract.get("status"), + "d4_state_contract": d4_state_contract, + "selected_strategy": selected_strategy, + "strategy_selection_policy": "preregistered_equal_weight_topk_momentum_no_oos_metric_selection", + "top_k": int(top_k), + "seed": int(seed), + "n_folds_requested": _safe_int(n_folds), + "n_folds": len(fold_rows), + "purge_days": _safe_int(purge_days), + "embargo_days": _safe_int(embargo_days), + "min_required_purge_days": MIN_REQUIRED_PURGE_DAYS, + "min_required_embargo_days": MIN_REQUIRED_EMBARGO_DAYS, + "cost_assumption_round_trip_bp": ROUND_TRIP_COST_BP, + "no_oos_retuning": True, + "price_basis": gate_verdict["price_basis"], + "universe_review_status": gate_verdict.get("universe_review_status"), + "no_live_broker_order_readiness": True, + "go_summary_allowed": False, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "readiness_status": readiness_status, + "row_counts": { + "fold_rows": len(fold_rows), + "assignment_rows": len(assignment_rows), + "fold_metric_rows": len(metric_rows), + "shuffle_control_rows": len(shuffle_rows), + "cost_sensitivity_rows": len(cost_rows), + "rl_fold_rows": len(rl_rows), + "d4_state_observation_rows": (d4_state_contract.get("row_counts") or {}).get("state_observations", 0), + "d4_invalid_action_rows": (d4_state_contract.get("row_counts") or {}).get("invalid_actions", 0), + "d4_policy_nav_rows": (d4_state_contract.get("row_counts") or {}).get("policy_nav", 0), + "d4_reward_action_ablation_rows": (d4_state_contract.get("row_counts") or {}).get("reward_action_ablations", 0), + "d4_source_hash_count": (d4_state_contract.get("row_counts") or {}).get("source_hashes", 0), + }, + "verdict": gate_verdict, + } + return { + "manifest": manifest, + "fold_assignments": assignment_rows, + "folds": fold_rows, + "fold_metrics": metric_rows, + "shuffle_control": shuffle_rows, + "cost_sensitivity": cost_rows, + "rl_fold_metrics": rl_rows, + "d4_state_contract": d4_state_contract, + "gate_verdict": gate_verdict, + } + + +def write_walk_forward_artifacts( + result: dict[str, Any], + *, + run_id: str | None = None, + artifact_root: Path | str | None = None, + overwrite: bool = False, +) -> dict[str, Any]: + root = Path(artifact_root or DEFAULT_WALK_FORWARD_ROOT).resolve() + default_root = DEFAULT_WALK_FORWARD_ROOT.resolve() + try: + root.relative_to(default_root) + except ValueError: + if root != default_root: + raise ValueError("Daily OHLCV walk-forward artifacts must stay under webui/rl_runs/daily_ohlcv_walk_forward") + rid = _validate_run_id(run_id or f"walk_forward_{datetime.now(timezone.utc).strftime('%Y%m%d_%H%M%S')}") + out_dir = (root / rid).resolve() + out_dir.relative_to(root) + if out_dir.exists() and any(out_dir.iterdir()) and not overwrite: + raise FileExistsError(f"Walk-forward artifact run_id already exists: {rid}") + out_dir.mkdir(parents=True, exist_ok=True) + paths = { + "walk_forward_manifest": out_dir / "walk_forward_manifest.json", + "fold_assignments": out_dir / "fold_assignments.csv", + "folds": out_dir / "folds.csv", + "fold_metrics": out_dir / "fold_metrics.csv", + "shuffle_control": out_dir / "shuffle_control.csv", + "cost_sensitivity": out_dir / "cost_sensitivity.csv", + "rl_fold_metrics": out_dir / "rl_fold_metrics.csv", + "gate_verdict": out_dir / "gate_verdict.json", + "d4_state_contract": out_dir / "d4_state_contract.json", + } + manifest = {**result["manifest"], "run_id": rid, "artifact_dir": str(out_dir), "artifacts": {key: str(path) for key, path in paths.items()}} + _write_json(paths["walk_forward_manifest"], manifest) + _write_json(paths["gate_verdict"], result["gate_verdict"]) + _write_json(paths["d4_state_contract"], result["d4_state_contract"]) + _write_csv(paths["fold_assignments"], result["fold_assignments"], ["fold_id", "date", "role"]) + _write_csv(paths["folds"], result["folds"], ["fold_id", "fold_index", "test_start_date", "test_end_date", "forward_only", "retuned_on_oos"]) + _write_csv(paths["fold_metrics"], result["fold_metrics"], ["fold_id", "strategy", "control", "cost_bp", "total_net_return", "delta_vs_no_trade_total_net_return", "delta_vs_shuffled_total_net_return", "max_drawdown", "mean_turnover"]) + _write_csv(paths["shuffle_control"], result["shuffle_control"], ["fold_id", "strategy", "control", "cost_bp", "total_net_return", "max_drawdown", "mean_turnover"]) + _write_csv(paths["cost_sensitivity"], result["cost_sensitivity"], ["fold_id", "strategy", "control", "cost_bp", "total_net_return", "max_drawdown", "mean_turnover"]) + _write_csv(paths["rl_fold_metrics"], result["rl_fold_metrics"], ["fold_id", "strategy", "trade_days", "total_net_return", "max_drawdown", "mean_turnover", "invalid_action_rate"]) + artifact_hashes = { + key: _file_sha256(path) + for key, path in paths.items() + if key != "walk_forward_manifest" + } + manifest["artifact_hashes"] = artifact_hashes + _write_json(paths["walk_forward_manifest"], manifest) + manifest_sha = _file_sha256(paths["walk_forward_manifest"]) + return { + "run_id": rid, + "artifact_dir": str(out_dir), + "walk_forward_manifest_sha256": manifest_sha, + "artifact_hashes": {**artifact_hashes, "walk_forward_manifest": manifest_sha}, + **{f"{key}_path": str(path) for key, path in paths.items()}, + } + + +def run_and_write_daily_walk_forward( + *, + run_id: str | None = None, + artifact_root: Path | str | None = None, + overwrite: bool = False, + **kwargs: Any, +) -> dict[str, Any]: + result = run_daily_walk_forward(**kwargs) + written = write_walk_forward_artifacts(result, run_id=run_id, artifact_root=artifact_root, overwrite=overwrite) + return {"result": result, "written": written} + + +__all__ = [ + "DEFAULT_WALK_FORWARD_ROOT", + "WALK_FORWARD_SCHEMA_VERSION", + "assign_forward_folds", + "PREREGISTERED_D5_STRATEGY", + "run_and_write_daily_walk_forward", + "run_daily_walk_forward", + "write_walk_forward_artifacts", +] diff --git a/stom_rl/episode_manifest.py b/stom_rl/episode_manifest.py new file mode 100644 index 000000000..46869af38 --- /dev/null +++ b/stom_rl/episode_manifest.py @@ -0,0 +1,392 @@ +"""Build STOM RL episode manifests from read-only STOM data sources. + +Page 2 of the independent RL lab is deliberately model-free. Its job is to +turn the already verified STOM 2025 1-second export into a deterministic list +of train/validation/test episode candidates while proving that the source +SQLite database is opened read-only. + +The manifest is the contract consumed by later pages: + +* Page 3: ``StomTickTradingEnv`` loads one episode at a time. +* Page 4: baseline runners iterate the same manifest. +* Page 5+: reward/cost gates and RL models reuse the same split boundaries. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import re +import sqlite3 +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + + +DEFAULT_DB_PATH = Path("_database") / "stock_tick_back.db" +DEFAULT_EXPORT_REPORT = ( + Path("finetune") / "qlib_exports" / "stom_1s_grid_pred60_2025" / "stom_qlib_export_report.json" +) +DEFAULT_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_1s_2025_episode_manifest" +EPISODE_FILE_RE = re.compile(r"^(?PKR(?P.+))_(?P\d{8})\.csv$") + + +@dataclass(frozen=True) +class EpisodeManifestConfig: + """Configuration for a deterministic RL episode manifest.""" + + db_path: str = str(DEFAULT_DB_PATH) + export_report_path: str = str(DEFAULT_EXPORT_REPORT) + qlib_csv_dir: Optional[str] = None + output_dir: str = str(DEFAULT_OUTPUT_DIR) + session_start: str = "20250101" + session_end: str = "20251231" + time_start: str = "090000" + time_end: str = "093000" + reward_horizon_seconds: int = 300 + lookback_window: int = 300 + max_episodes: int = 0 + count_csv_rows: bool = False + write_artifacts: bool = True + + +def _normalize_date_bound(value: str, label: str) -> str: + text = str(value).replace("-", "").strip() + if len(text) != 8 or not text.isdigit(): + raise ValueError(f"{label} must be YYYYMMDD or YYYY-MM-DD, got: {value}") + return text + + +def connect_readonly(db_path: os.PathLike[str] | str) -> sqlite3.Connection: + """Open a SQLite database in read-only and query-only mode. + + ``mode=ro`` protects the source file at the SQLite URI level. The + additional ``PRAGMA query_only=ON`` blocks accidental writes on the + connection even if a future maintainer changes the URI. + """ + + path = Path(db_path).resolve() + if not path.exists(): + raise FileNotFoundError(f"SQLite DB not found: {path}") + conn = sqlite3.connect(f"file:{path.as_posix()}?mode=ro", uri=True) + conn.execute("PRAGMA query_only=ON") + return conn + + +def verify_readonly_connection(db_path: os.PathLike[str] | str) -> Dict[str, Any]: + """Return evidence that the STOM DB is opened without write authority.""" + + path = Path(db_path).resolve() + conn = connect_readonly(path) + try: + query_only = int(conn.execute("PRAGMA query_only").fetchone()[0]) + database_list = [ + {"seq": row[0], "name": row[1], "file": row[2]} + for row in conn.execute("PRAGMA database_list").fetchall() + ] + write_probe_blocked = False + write_probe_error = "" + try: + conn.execute("CREATE TABLE __stom_rl_write_probe__(id INTEGER)") + except sqlite3.DatabaseError as exc: + write_probe_blocked = True + write_probe_error = str(exc) + return { + "db_path": str(path), + "db_size_bytes": path.stat().st_size, + "sqlite_uri_mode": "ro", + "query_only": query_only, + "database_list": database_list, + "write_probe_blocked": write_probe_blocked, + "write_probe_error": write_probe_error, + } + finally: + conn.close() + + +def _read_json(path: os.PathLike[str] | str) -> Dict[str, Any]: + return json.loads(Path(path).read_text(encoding="utf-8-sig")) + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _parse_episode_file(path: Path) -> Optional[Dict[str, str]]: + match = EPISODE_FILE_RE.match(path.name) + if not match: + return None + return { + "instrument": match.group("instrument"), + "symbol": match.group("symbol"), + "session": match.group("session"), + "source_csv": str(path), + } + + +def _line_count_data_rows(path: Path) -> int: + with path.open("rb") as f: + lines = sum(1 for _ in f) + return max(0, lines - 1) + + +def _split_lookup(split_sessions: Mapping[str, Sequence[str]]) -> Dict[str, str]: + lookup: Dict[str, str] = {} + for split, sessions in split_sessions.items(): + for session in sessions: + if session in lookup and lookup[session] != split: + raise ValueError(f"Session {session} appears in multiple splits: {lookup[session]} and {split}") + lookup[str(session)] = split + return lookup + + +def _validate_split_sessions(split_sessions: Mapping[str, Sequence[str]]) -> Dict[str, Any]: + split_sets = {split: set(map(str, sessions)) for split, sessions in split_sessions.items()} + overlaps: List[Tuple[str, str, List[str]]] = [] + names = sorted(split_sets) + for idx, left in enumerate(names): + for right in names[idx + 1 :]: + overlap = sorted(split_sets[left] & split_sets[right]) + if overlap: + overlaps.append((left, right, overlap)) + ordered_sessions = [session for split in ["train", "val", "test"] for session in sorted(split_sets.get(split, []))] + monotonic = ordered_sessions == sorted(ordered_sessions) + return { + "split_session_counts": {split: len(sessions) for split, sessions in split_sets.items()}, + "overlap_count": sum(len(item[2]) for item in overlaps), + "overlaps": [ + {"left": left, "right": right, "sessions": sessions} + for left, right, sessions in overlaps + ], + "chronological_train_val_test": monotonic, + } + + +def _load_export_context(config: EpisodeManifestConfig) -> Tuple[Dict[str, Any], Path]: + report = _read_json(config.export_report_path) + qlib_csv_dir = Path(config.qlib_csv_dir or report.get("qlib_csv_dir") or "").resolve() + if not qlib_csv_dir.exists(): + raise FileNotFoundError(f"Qlib CSV directory not found: {qlib_csv_dir}") + return report, qlib_csv_dir + + +def _episode_sort_key(episode: Mapping[str, Any]) -> Tuple[str, str]: + return str(episode["session"]), str(episode["symbol"]) + + +def _build_episode_rows( + qlib_csv_dir: Path, + split_sessions: Mapping[str, Sequence[str]], + config: EpisodeManifestConfig, +) -> List[Dict[str, Any]]: + session_start = _normalize_date_bound(config.session_start, "session_start") + session_end = _normalize_date_bound(config.session_end, "session_end") + split_by_session = _split_lookup(split_sessions) + + episodes: List[Dict[str, Any]] = [] + for csv_path in sorted(qlib_csv_dir.glob("*.csv")): + parsed = _parse_episode_file(csv_path) + if not parsed: + continue + session = parsed["session"] + if session < session_start or session > session_end: + continue + split = split_by_session.get(session, "unknown") + row_count = _line_count_data_rows(csv_path) if config.count_csv_rows else None + episode = { + "episode_id": f"{parsed['symbol']}_{session}", + "symbol": parsed["symbol"], + "instrument": parsed["instrument"], + "session": session, + "split": split, + "time_start": config.time_start, + "time_end": config.time_end, + "lookback_window": config.lookback_window, + "reward_horizon_seconds": config.reward_horizon_seconds, + "row_count": row_count, + "source_csv": parsed["source_csv"], + } + episodes.append(episode) + + episodes = sorted(episodes, key=_episode_sort_key) + if config.max_episodes and config.max_episodes > 0: + episodes = episodes[: config.max_episodes] + return episodes + + +def _summarize_episodes( + episodes: Sequence[Mapping[str, Any]], + report: Mapping[str, Any], + split_validation: Mapping[str, Any], +) -> Dict[str, Any]: + by_split: Dict[str, int] = {} + by_session: Dict[str, int] = {} + symbols = set() + counted_rows = 0 + has_counted_rows = False + for episode in episodes: + split = str(episode.get("split", "unknown")) + session = str(episode["session"]) + by_split[split] = by_split.get(split, 0) + 1 + by_session[session] = by_session.get(session, 0) + 1 + symbols.add(str(episode["symbol"])) + row_count = episode.get("row_count") + if row_count is not None: + has_counted_rows = True + counted_rows += int(row_count) + + report_split_counts = report.get("split_counts", {}) + expected_by_split = { + split: int(values.get("groups", 0)) + for split, values in report_split_counts.items() + if isinstance(values, Mapping) + } + deltas = { + split: by_split.get(split, 0) - expected + for split, expected in expected_by_split.items() + } + expected_total = int(report.get("exported_group_count") or sum(expected_by_split.values())) + manifest_total = len(episodes) + + return { + "episode_count": manifest_total, + "symbol_count": len(symbols), + "session_count": len(by_session), + "by_split": dict(sorted(by_split.items())), + "expected_by_split_from_export_report": dict(sorted(expected_by_split.items())), + "by_split_delta_vs_export_report": dict(sorted(deltas.items())), + "expected_exported_group_count": expected_total, + "manifest_group_delta_vs_export_report": manifest_total - expected_total, + "counted_csv_rows": counted_rows if has_counted_rows else None, + "export_report_row_count": report.get("exported_row_count"), + "split_validation": dict(split_validation), + "data_quality": { + "unknown_split_episodes": by_split.get("unknown", 0), + "split_session_overlap_count": split_validation.get("overlap_count", 0), + "chronological_train_val_test": split_validation.get("chronological_train_val_test"), + "uses_existing_kronos_export_as_external_data_contract": True, + }, + } + + +def _write_manifest_csv(path: Path, episodes: Sequence[Mapping[str, Any]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fieldnames = [ + "episode_id", + "symbol", + "instrument", + "session", + "split", + "time_start", + "time_end", + "lookback_window", + "reward_horizon_seconds", + "row_count", + "source_csv", + ] + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + for episode in episodes: + writer.writerow({key: episode.get(key) for key in fieldnames}) + + +def build_episode_manifest(config: EpisodeManifestConfig) -> Dict[str, Any]: + """Build a STOM RL episode manifest and optional artifact files.""" + + report, qlib_csv_dir = _load_export_context(config) + split_sessions = report.get("split_sessions") or {} + if not split_sessions: + raise ValueError("Export report does not contain split_sessions.") + split_validation = _validate_split_sessions(split_sessions) + if split_validation["overlap_count"]: + raise ValueError("Export report split_sessions overlap; refusing to build RL manifest.") + + db_readonly = verify_readonly_connection(config.db_path) + episodes = _build_episode_rows(qlib_csv_dir, split_sessions, config) + if not episodes: + raise ValueError("No episode CSV files matched the requested manifest bounds.") + + output_dir = Path(config.output_dir) + manifest_json_path = output_dir / "episode_manifest.json" + manifest_csv_path = output_dir / "episode_manifest.csv" + summary_json_path = output_dir / "episode_summary.json" + summary = _summarize_episodes(episodes, report, split_validation) + + payload: Dict[str, Any] = { + "mode": "stom_rl_episode_manifest", + "created_at": datetime.utcnow().replace(microsecond=0).isoformat() + "Z", + "config": asdict(config), + "source": { + "db_path": str(Path(config.db_path)), + "export_report_path": str(Path(config.export_report_path)), + "qlib_csv_dir": str(qlib_csv_dir), + }, + "db_readonly": db_readonly, + "summary": summary, + "episodes": episodes, + "artifacts": { + "manifest_json": str(manifest_json_path), + "manifest_csv": str(manifest_csv_path), + "summary_json": str(summary_json_path), + }, + } + if config.write_artifacts: + _write_json(manifest_json_path, payload) + _write_json(summary_json_path, {k: v for k, v in payload.items() if k != "episodes"}) + _write_manifest_csv(manifest_csv_path, episodes) + return payload + + +def load_episode_manifest(path: os.PathLike[str] | str) -> Dict[str, Any]: + """Load a previously generated manifest JSON.""" + + return _read_json(path) + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> EpisodeManifestConfig: + parser = argparse.ArgumentParser(description="Build a STOM RL episode manifest.") + parser.add_argument("--db", default=str(DEFAULT_DB_PATH), help="Read-only source STOM SQLite DB.") + parser.add_argument("--export-report", default=str(DEFAULT_EXPORT_REPORT)) + parser.add_argument("--qlib-csv-dir", default=None) + parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR)) + parser.add_argument("--session-start", default="20250101") + parser.add_argument("--session-end", default="20251231") + parser.add_argument("--time-start", default="090000") + parser.add_argument("--time-end", default="093000") + parser.add_argument("--reward-horizon-seconds", type=int, default=300) + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--max-episodes", type=int, default=0) + parser.add_argument("--count-csv-rows", action="store_true") + parser.add_argument("--no-write", action="store_true", help="Return summary without writing artifacts.") + args = parser.parse_args(argv) + return EpisodeManifestConfig( + db_path=args.db, + export_report_path=args.export_report, + qlib_csv_dir=args.qlib_csv_dir, + output_dir=args.output_dir, + session_start=args.session_start, + session_end=args.session_end, + time_start=args.time_start, + time_end=args.time_end, + reward_horizon_seconds=args.reward_horizon_seconds, + lookback_window=args.lookback_window, + max_episodes=args.max_episodes, + count_csv_rows=args.count_csv_rows, + write_artifacts=not args.no_write, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + config = _parse_args(argv) + payload = build_episode_manifest(config) + print(json.dumps({k: v for k, v in payload.items() if k != "episodes"}, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/exit_baselines.py b/stom_rl/exit_baselines.py new file mode 100644 index 000000000..026239c5d --- /dev/null +++ b/stom_rl/exit_baselines.py @@ -0,0 +1,488 @@ +"""Causal exit baselines + honest evaluation toolkit (Page R1b). + +**RULE strategy, NOT reinforcement learning.** R1 showed a large perfect-foresight +exit ceiling (capture ~18%), but that is a *necessary-not-sufficient* signal. R1b +asks the decisive question with CAUSAL (executable, no look-ahead) exit rules: + + Does ANY simple causal exit (wider SL / trailing stop) beat the fixed + TP5 / SL1 / 09:25 rule OUT-OF-SAMPLE, after costs, once the search is + deflated for the number of variants tried? + +If yes -> the R1 headroom is partly causally capturable -> an RL/optimal-stopping +exit (R3) is justified. If no -> the headroom is pure hindsight -> do NOT build +RL; return to the operational track. + +This module provides: +* :func:`simulate_trailing_stop` — a causal trailing-stop simulator (the fixed + SL/TP rule is reused from :mod:`stom_rl.gap_up_backtest`). +* per-trade Sharpe + the **Deflated Sharpe Ratio** (Bailey & Lopez de Prado 2014) + and its expected-maximum-Sharpe term — the multiple-testing guard R0 mandates. +* :func:`evaluate_exit_candidates` / :func:`walk_forward_select` — apply a grid of + candidate exits, select on IN-SAMPLE, report OUT-OF-SAMPLE (leakage-free) and the + DSR of the selection. + +All core functions are PURE (no I/O); the CLI reuses ``collect_gap_up_instances`` +for the same bounded DB reads. Sharpe here is PER-TRADE (not annualized): a +relative/deflation diagnostic, NOT a deployment Sharpe. +""" + +from __future__ import annotations + +import math +from statistics import NormalDist, mean, stdev, variance +from typing import Any, Dict, List, Optional, Sequence + +from stom_rl.gap_up_backtest import ( + EXIT_SL, + EXIT_TIME, + EXIT_TP, + GapUpInstance, + TradeResult, + simulate_trade, + split_instances_by_date, +) + +DEFAULT_TP_PCT: float = 5.0 +DEFAULT_SL_PCT: float = 1.0 +DEFAULT_COST_BPS: float = 23.0 +EULER_MASCHERONI: float = 0.5772156649015329 +EXIT_TRAIL: str = "trail" + +_TRAIL_FILL_MODES = frozenset({"idealized", "realized"}) + + +# --------------------------------------------------------------------------- +# Causal trailing-stop simulator (no look-ahead; the SL/TP rule is reused). +# --------------------------------------------------------------------------- +def simulate_trailing_stop( + prices: Sequence[float], + *, + trail_pct: float, + tp_pct: Optional[float] = None, + cost_bps: float = DEFAULT_COST_BPS, + fill_mode: str = "realized", + seconds: Optional[Sequence[int]] = None, +) -> TradeResult: + """Walk the forward path and exit on a CAUSAL trailing stop. + + The stop ratchets up with the running peak since entry: at each forward bar + the stop level is ``peak * (1 - trail_pct/100)``; the trade exits the first + time the price falls to/through it. Because the initial peak is the entry, + ``trail_pct`` also acts as the initial stop (so trail=1% starts like SL1% but + follows the price up). An optional hard ``tp_pct`` take-profit is checked + first each bar. ``realized`` fills book the actual breaching price (the + honest assumption, matching :mod:`stom_rl.exit_oracle`); ``idealized`` books + the stop/TP level. No look-ahead: only prices up to the current bar are used. + """ + + if fill_mode not in _TRAIL_FILL_MODES: + raise ValueError(f"fill_mode must be one of {sorted(_TRAIL_FILL_MODES)!r}") + if trail_pct <= 0: + raise ValueError("trail_pct must be > 0") + if tp_pct is not None and tp_pct <= 0: + raise ValueError("tp_pct must be > 0 when given") + if cost_bps < 0: + raise ValueError("cost_bps must be >= 0") + if not prices: + raise ValueError("prices must contain at least the entry bar") + entry = float(prices[0]) + if entry <= 0: + raise ValueError("entry price must be positive") + + cost_pct = float(cost_bps) / 100.0 + tp_level = entry * (1.0 + tp_pct / 100.0) if tp_pct is not None else None + peak = entry + + def _elapsed(idx: int) -> int: + if seconds is not None and idx < len(seconds): + return int(seconds[idx]) - int(seconds[0]) + return int(idx) + + exit_idx = len(prices) - 1 + exit_price = float(prices[-1]) + exit_reason = EXIT_TIME + + for idx in range(1, len(prices)): + price = float(prices[idx]) + if tp_level is not None and price >= tp_level: + booked = price if fill_mode == "realized" else tp_level + exit_idx, exit_price, exit_reason = idx, booked, EXIT_TP + break + peak = max(peak, price) + stop_level = peak * (1.0 - trail_pct / 100.0) + if price <= stop_level: + booked = price if fill_mode == "realized" else stop_level + exit_idx, exit_price, exit_reason = idx, booked, EXIT_TRAIL + break + + gross = (exit_price / entry - 1.0) * 100.0 + return TradeResult( + entry_price=entry, + exit_price=float(exit_price), + exit_reason=exit_reason, + hold_seconds=_elapsed(exit_idx), + gross_return_pct=float(gross), + net_return_pct=float(gross - cost_pct), + ) + + +# --------------------------------------------------------------------------- +# Per-trade Sharpe + Deflated Sharpe Ratio (multiple-testing guard). +# --------------------------------------------------------------------------- +def per_trade_sharpe(net_returns: Sequence[float]) -> Optional[float]: + """Per-trade Sharpe = mean / sample-stdev of net returns (None if undefined). + + NOT annualized — a relative/deflation diagnostic over the trade sample, not a + deployment Sharpe. Returns ``None`` for fewer than 2 trades or zero variance. + """ + + if len(net_returns) < 2: + return None + sd = stdev(net_returns) + if sd <= 0.0: + return None + return mean(net_returns) / sd + + +def expected_max_sharpe(n_trials: int, sharpe_variance: float) -> float: + """Expected maximum Sharpe under the null over ``n_trials`` (Bailey-LdP). + + ``E[max] = sqrt(Var(SR)) * [ (1-gamma)*Z^-1(1 - 1/N) + gamma*Z^-1(1 - 1/(N*e)) ]`` + with ``gamma`` the Euler-Mascheroni constant. This is the SR threshold a + strategy must clear just to beat the best of ``N`` independent random trials. + Returns 0.0 for a single trial (no selection inflation). + """ + + if n_trials < 1: + raise ValueError("n_trials must be >= 1") + if sharpe_variance < 0: + raise ValueError("sharpe_variance must be >= 0") + if n_trials == 1 or sharpe_variance == 0.0: + return 0.0 + norm = NormalDist() + z1 = norm.inv_cdf(1.0 - 1.0 / n_trials) + z2 = norm.inv_cdf(1.0 - 1.0 / (n_trials * math.e)) + return math.sqrt(sharpe_variance) * ((1.0 - EULER_MASCHERONI) * z1 + EULER_MASCHERONI * z2) + + +def deflated_sharpe_ratio( + observed_sharpe: float, + *, + n_trials: int, + sharpe_variance: float, + n_samples: int, + skew: float = 0.0, + kurtosis: float = 3.0, +) -> float: + """Deflated Sharpe Ratio (Bailey & Lopez de Prado 2014), in [0, 1]. + + The probability that the true Sharpe is positive AFTER correcting for (a) + selection under ``n_trials`` multiple tests, and (b) non-normal returns + (``skew``, non-excess ``kurtosis``, normal = 3). ``DSR > 0.95`` is the usual + significance bar. ``n_samples`` is the trade count T. + """ + + if n_samples < 2: + raise ValueError("n_samples must be >= 2") + sr0 = expected_max_sharpe(n_trials, sharpe_variance) + denom = math.sqrt( + max(1e-12, 1.0 - skew * observed_sharpe + (kurtosis - 1.0) / 4.0 * observed_sharpe ** 2) + ) + z = (observed_sharpe - sr0) * math.sqrt(n_samples - 1) / denom + return NormalDist().cdf(z) + + +# --------------------------------------------------------------------------- +# Candidate exit policies + grid evaluation + walk-forward selection. +# --------------------------------------------------------------------------- +def _resolve_trade( + inst: GapUpInstance, + candidate: Dict[str, Any], + *, + cost_bps: float, + fill_mode: str, +) -> TradeResult: + """Resolve one instance under one candidate exit policy.""" + + kind = candidate.get("kind") + if kind == "fixed": + return simulate_trade( + inst.prices, + tp_pct=candidate["tp_pct"], + sl_pct=candidate["sl_pct"], + cost_bps=cost_bps, + seconds=inst.seconds, + fill_mode=fill_mode, + ) + if kind == "trail": + return simulate_trailing_stop( + inst.prices, + trail_pct=candidate["trail_pct"], + tp_pct=candidate.get("tp_pct"), + cost_bps=cost_bps, + fill_mode=fill_mode, + seconds=inst.seconds, + ) + raise ValueError(f"unknown candidate kind: {kind!r}") + + +def _aggregate(trades: Sequence[TradeResult]) -> Dict[str, Any]: + """n, mean net, win rate, per-trade Sharpe over a set of resolved trades.""" + + n = len(trades) + if n == 0: + return {"n": 0, "mean_net_pct": None, "win_rate": None, "sharpe": None} + nets = [t.net_return_pct for t in trades] + wins = sum(1 for v in nets if v > 0.0) + return { + "n": n, + "mean_net_pct": mean(nets), + "win_rate": wins / n, + "sharpe": per_trade_sharpe(nets), + } + + +def evaluate_exit_candidates( + instances: Sequence[GapUpInstance], + candidates: Sequence[Dict[str, Any]], + *, + cost_bps: float = DEFAULT_COST_BPS, + fill_mode: str = "realized", +) -> List[Dict[str, Any]]: + """Aggregate metrics for each candidate exit policy over the instances.""" + + if fill_mode not in _TRAIL_FILL_MODES: + raise ValueError( + f"fill_mode must be one of {sorted(_TRAIL_FILL_MODES)!r} for the exit " + "grid (sl_gap_stress is unsupported because trailing stops use only " + "idealized/realized fills)" + ) + out: List[Dict[str, Any]] = [] + for cand in candidates: + trades = [ + _resolve_trade(inst, cand, cost_bps=cost_bps, fill_mode=fill_mode) + for inst in instances + ] + agg = _aggregate(trades) + agg["name"] = cand["name"] + out.append(agg) + return out + + +def default_candidate_grid() -> List[Dict[str, Any]]: + """The pre-registered causal exit grid (fixed-SL widen + trailing stops). + + ``fixed_tp5_sl1`` is the incumbent rule (the baseline every variant must beat). + The SL widen targets the R1 finding that 55% of regret comes from SL exits. + """ + + return [ + {"name": "fixed_tp5_sl1", "kind": "fixed", "tp_pct": 5.0, "sl_pct": 1.0}, + {"name": "fixed_tp5_sl1.5", "kind": "fixed", "tp_pct": 5.0, "sl_pct": 1.5}, + {"name": "fixed_tp5_sl2", "kind": "fixed", "tp_pct": 5.0, "sl_pct": 2.0}, + {"name": "fixed_tp5_sl3", "kind": "fixed", "tp_pct": 5.0, "sl_pct": 3.0}, + {"name": "trail_1", "kind": "trail", "trail_pct": 1.0}, + {"name": "trail_2", "kind": "trail", "trail_pct": 2.0}, + {"name": "trail_3", "kind": "trail", "trail_pct": 3.0}, + {"name": "trail_2_tp5", "kind": "trail", "trail_pct": 2.0, "tp_pct": 5.0}, + {"name": "trail_3_tp5", "kind": "trail", "trail_pct": 3.0, "tp_pct": 5.0}, + ] + + +def walk_forward_select( + instances: Sequence[GapUpInstance], + candidates: Sequence[Dict[str, Any]], + *, + in_sample_fraction: float = 0.7, + cost_bps: float = DEFAULT_COST_BPS, + fill_mode: str = "realized", + baseline_name: str = "fixed_tp5_sl1", + select_by: str = "sharpe", +) -> Dict[str, Any]: + """Select the best candidate IN-SAMPLE, report it OUT-OF-SAMPLE + DSR. + + The selection metric (``select_by``) is read on the IN-SAMPLE split only; the + headline is the selected candidate's OUT-OF-SAMPLE mean net vs the baseline's + OOS mean net (leakage-free). The Deflated Sharpe Ratio is computed on the + selected candidate's OOS per-trade Sharpe with ``n_trials = len(candidates)`` + and the cross-candidate IN-SAMPLE Sharpe variance — so a variant that wins OOS + only because many were tried is exposed. + + ``oos_improvement_pct`` is cost-invariant: both legs pay the same additive + round-trip cost, so it cancels in the difference (same property as the + exit-oracle regret). Honesty note: the DSR here is a HYBRID directional guard + — its deflation threshold uses IN-SAMPLE cross-candidate Sharpe dispersion + while the observed Sharpe / T are OUT-OF-SAMPLE. It is not the canonical + single-sample DSR; the leakage-free OOS net comparison is the primary headline + and the DSR is the secondary multiple-testing check. + """ + + in_sample, out_sample, boundary = split_instances_by_date( + instances, in_sample_fraction=in_sample_fraction + ) + is_res = evaluate_exit_candidates(in_sample, candidates, cost_bps=cost_bps, fill_mode=fill_mode) + oos_res = evaluate_exit_candidates(out_sample, candidates, cost_bps=cost_bps, fill_mode=fill_mode) + is_by = {r["name"]: r for r in is_res} + oos_by = {r["name"]: r for r in oos_res} + + rankable = [r for r in is_res if r.get(select_by) is not None] + if not rankable: + return { + "boundary_date": boundary, + "selected": None, + "reason": f"no candidate has a defined in-sample {select_by}", + } + best = max(rankable, key=lambda r: r[select_by]) + sel = best["name"] + sel_oos = oos_by.get(sel, {}) + base_oos = oos_by.get(baseline_name, {}) + + is_sharpes = [r["sharpe"] for r in is_res if r["sharpe"] is not None] + sharpe_var = variance(is_sharpes) if len(is_sharpes) >= 2 else 0.0 + sel_oos_sharpe = sel_oos.get("sharpe") + sel_oos_n = sel_oos.get("n", 0) + dsr = ( + deflated_sharpe_ratio( + sel_oos_sharpe, + n_trials=len(candidates), + sharpe_variance=sharpe_var, + n_samples=sel_oos_n, + ) + if (sel_oos_sharpe is not None and sel_oos_n >= 2) + else None + ) + + sel_oos_net = sel_oos.get("mean_net_pct") + base_oos_net = base_oos.get("mean_net_pct") + improvement = ( + sel_oos_net - base_oos_net + if (sel_oos_net is not None and base_oos_net is not None) + else None + ) + return { + "boundary_date": boundary, + "n_in_sample": len(in_sample), + "n_out_of_sample": len(out_sample), + "n_trials": len(candidates), + "select_by": select_by, + "selected": sel, + "selected_is_baseline": sel == baseline_name, + "selected_oos_mean_net_pct": sel_oos_net, + "selected_oos_sharpe": sel_oos_sharpe, + "baseline_oos_mean_net_pct": base_oos_net, + "baseline_oos_sharpe": base_oos.get("sharpe"), + "oos_improvement_pct": improvement, + "deflated_sharpe_ratio": dsr, + } + + +# --------------------------------------------------------------------------- +# CLI: bounded DB read -> candidate grid table + walk-forward verdict. +# --------------------------------------------------------------------------- +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + from stom_rl.gap_up_backtest import ( + ENTRY_FILTERS, + GapUpBacktestConfig, + collect_gap_up_instances, + filter_instances, + ) + + project_root = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser( + description="Causal exit baselines + Deflated Sharpe (RULE NOT RL) - Page R1b." + ) + parser.add_argument("--db", default=str(project_root / "_database" / "stock_tick_back.db")) + parser.add_argument("--max-symbols", type=int, default=120) + parser.add_argument("--cost-bps", type=float, default=DEFAULT_COST_BPS) + parser.add_argument("--in-sample-fraction", type=float, default=0.7) + parser.add_argument("--filter", choices=list(ENTRY_FILTERS.keys()), default="ts_imb") + parser.add_argument("--fill-mode", choices=["idealized", "realized"], default="realized") + parser.add_argument( + "--json-out", + default=str(project_root / ".omx" / "artifacts" / "exit_baselines" / "summary.json"), + ) + args = parser.parse_args(argv) + + config = GapUpBacktestConfig( + db_path=args.db, max_symbols=args.max_symbols, cost_bps=args.cost_bps + ) + instances = collect_gap_up_instances(config) + kept = filter_instances(instances, ENTRY_FILTERS[args.filter]) + grid = default_candidate_grid() + + all_res = evaluate_exit_candidates(kept, grid, cost_bps=args.cost_bps, fill_mode=args.fill_mode) + wf = walk_forward_select( + kept, + grid, + in_sample_fraction=args.in_sample_fraction, + cost_bps=args.cost_bps, + fill_mode=args.fill_mode, + ) + + def f(v: Optional[float], spec: str = "+.3f") -> str: + return "n/a" if v is None else format(v, spec) + + print("=== causal exit baselines (RULE strategy, NOT RL) - Page R1b ===") + print( + f"filter={args.filter} N={len(kept)} cost={args.cost_bps:g}bp fill={args.fill_mode} " + f"IS_frac={args.in_sample_fraction}" + ) + print("-- all-sample per-candidate (mean_net / win / per-trade Sharpe) --") + for r in all_res: + print( + f" {r['name']:<16} n={r['n']:<5} mean_net={f(r['mean_net_pct'])}% " + f"win={f(r['win_rate'], '.0%')} sharpe={f(r['sharpe'])}" + ) + print("-- walk-forward selection (select IN-SAMPLE, report OUT-OF-SAMPLE) --") + print( + f" boundary={wf.get('boundary_date')} N(IS/OOS)={wf.get('n_in_sample')}/" + f"{wf.get('n_out_of_sample')} n_trials={wf.get('n_trials')}" + ) + print( + f" selected={wf.get('selected')} (is_baseline={wf.get('selected_is_baseline')}) " + f"selected_OOS_net={f(wf.get('selected_oos_mean_net_pct'))}% " + f"baseline_OOS_net={f(wf.get('baseline_oos_mean_net_pct'))}%" + ) + print( + f" OOS_improvement={f(wf.get('oos_improvement_pct'))}% " + f"deflated_sharpe_ratio={f(wf.get('deflated_sharpe_ratio'), '.3f')}" + ) + print( + "\nread: a causal variant must (1) beat the baseline OOS net AND " + "(2) clear DSR>0.95 to justify an RL exit (R3); else exit headroom is " + "hindsight -> do NOT build RL, return to operational track." + ) + + out_path = Path(args.json_out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text( + json.dumps( + { + "filter": args.filter, + "n": len(kept), + "cost_bps": args.cost_bps, + "fill_mode": args.fill_mode, + "all_sample": all_res, + "walk_forward": wf, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + print(f"wrote summary -> {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/exit_oracle.py b/stom_rl/exit_oracle.py new file mode 100644 index 000000000..28b6108bd --- /dev/null +++ b/stom_rl/exit_oracle.py @@ -0,0 +1,374 @@ +"""Oracle-exit ceiling test for the 시초 갭상승 strategy (Page R1). + +**RULE strategy, NOT reinforcement learning.** This module does NOT learn or +trade; it MEASURES whether a smarter exit could even exist, to decide — before +any RL/optimal-stopping work — whether such work is worth attempting. + +The question (gate before RL) +----------------------------- +The rule exits via a fixed TP5 / SL1 / 09:25 time-exit. Could a *smarter* exit +do materially better? We compute, per trade:: + + regret = oracle_exit_net - rule_exit_net + +where ``oracle_exit_net`` is the **perfect-foresight best exit**: sell at the +HIGHEST price actually reached on the forward path (a price that occurred, so it +is achievable in principle). Perfect foresight is an UPPER BOUND on ANY causal +exit policy — including the best possible RL policy — so: + +* **small regret** → the fixed rule already captures the achievable exit value; + an RL exit policy has almost no ceiling → **DO NOT build it.** +* **large regret** → there is headroom a causal policy *might* recover a + fraction of → an RL / optimal-stopping exit is worth attempting (still gated + by honest OOS evaluation: Deflated Sharpe, PBO/CSCV, multi-seed). + +Cost-invariance (a robustness feature) +-------------------------------------- +Both the oracle and the rule pay ONE round-trip cost, so cost CANCELS in the +regret (``regret = oracle_gross - rule_gross``). The ceiling verdict is +therefore robust to the exact cost assumption (23 vs 25 bp); cost only shifts the +*level* of ``rule_net`` / ``oracle_net``, not the headroom between them. + +Fill realism (why the default is ``realized``) +---------------------------------------------- +Both legs use **realized** fills (a price that actually occurred) by default, so +``regret >= 0`` always and measures pure exit-*timing* headroom. The rule's +``idealized`` fill books the exact TP/SL *level* — an optimistic fiction that can +exceed the realized oracle on a gap-through stop (yielding a spurious NEGATIVE +regret), so it is deliberately NOT the default for this ceiling test. + +The core functions are PURE (no I/O); the CLI reuses ``collect_gap_up_instances`` +from :mod:`stom_rl.gap_up_backtest` for the same bounded DB reads. +""" + +from __future__ import annotations + +from statistics import mean, median +from typing import Any, Dict, List, Optional, Sequence + +from stom_rl.gap_up_backtest import ( + EXIT_SL, + EXIT_TIME, + EXIT_TP, + GapUpInstance, + simulate_trade, +) + +# Primary cell (verified upstream): TP5 / SL1, ts_imb filter, 23 bp real cost. +DEFAULT_TP_PCT: float = 5.0 +DEFAULT_SL_PCT: float = 1.0 +DEFAULT_COST_BPS: float = 23.0 + + +def _percentile(values: Sequence[float], q: float) -> Optional[float]: + """Nearest-rank percentile (q in [0, 1]); None for an empty sequence.""" + + if not values: + return None + if not (0.0 <= q <= 1.0): + raise ValueError("q must be in [0, 1]") + ordered = sorted(values) + idx = int(round(q * (len(ordered) - 1))) + idx = max(0, min(len(ordered) - 1, idx)) + return ordered[idx] + + +def oracle_exit_net_pct( + prices: Sequence[float], + *, + cost_bps: float = DEFAULT_COST_BPS, +) -> float: + """Perfect-foresight best-exit net return (%) for an already-entered trade. + + The position is entered at ``prices[0]``; the oracle sells at the HIGHEST + price on the forward path (``prices[1:]``). When there is no forward bar the + only achievable exit is the entry price itself. The booked price is the + actual maximum (a realized fill, the most generous assumption), making this a + strict UPPER BOUND on any causal exit policy. One round-trip ``cost_bps`` is + subtracted, matching :func:`stom_rl.gap_up_backtest.simulate_trade`. + """ + + if not prices: + raise ValueError("prices must contain at least the entry bar") + entry = float(prices[0]) + if entry <= 0: + raise ValueError("entry price must be positive") + if cost_bps < 0: + raise ValueError("cost_bps must be >= 0") + forward = prices[1:] + best_price = max(float(p) for p in forward) if forward else entry + gross = (best_price / entry - 1.0) * 100.0 + return gross - float(cost_bps) / 100.0 + + +def rule_exit_net_pct( + prices: Sequence[float], + *, + tp_pct: float = DEFAULT_TP_PCT, + sl_pct: float = DEFAULT_SL_PCT, + cost_bps: float = DEFAULT_COST_BPS, + fill_mode: str = "realized", +) -> float: + """Net return (%) of the fixed TP/SL/time rule (delegates to simulate_trade). + + Defaults to ``realized`` fills so it pairs apples-to-apples with the realized + oracle in :func:`exit_regret_pct` (see the module docstring's "Fill realism"). + """ + + return simulate_trade( + prices, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + fill_mode=fill_mode, + ).net_return_pct + + +def exit_regret_pct( + prices: Sequence[float], + *, + tp_pct: float = DEFAULT_TP_PCT, + sl_pct: float = DEFAULT_SL_PCT, + cost_bps: float = DEFAULT_COST_BPS, + fill_mode: str = "realized", +) -> float: + """Per-trade exit regret = oracle_net - rule_net. + + Under the default ``realized`` fills this is ``>= 0`` by construction: the + oracle books the maximum forward price, and the realized rule books some + forward price ``<= max``. A large value means the fixed rule left exit value + on the table (e.g. stopped out before a recovery, or sold at the first TP + cross before a higher print); near-zero means the rule already exited + near-optimally. Passing ``fill_mode="idealized"`` can yield a spurious + NEGATIVE regret (the SL/TP-level fiction beats the realized oracle) — see the + module docstring. + """ + + oracle = oracle_exit_net_pct(prices, cost_bps=cost_bps) + rule = rule_exit_net_pct( + prices, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + fill_mode=fill_mode, + ) + return oracle - rule + + +def summarize_exit_regret( + instances: Sequence[GapUpInstance], + *, + tp_pct: float = DEFAULT_TP_PCT, + sl_pct: float = DEFAULT_SL_PCT, + cost_bps: float = DEFAULT_COST_BPS, + fill_mode: str = "realized", + optimal_eps: float = 1e-9, +) -> Dict[str, Any]: + """Aggregate the oracle-vs-rule exit regret over a set of instances. + + Returns rule/oracle mean net, the regret distribution (mean/median/p90/max), + the capture ratio (rule_mean / oracle_mean when the oracle mean is positive), + the fraction of trades the rule already exits optimally (regret <= eps), and a + per-rule-exit-reason regret breakdown (tp / sl / time) so the headroom can be + attributed (e.g. large regret on SL exits == stops cut winners that recover). + """ + + n = len(instances) + if n == 0: + return { + "n": 0, + "rule_mean_net_pct": None, + "oracle_mean_net_pct": None, + "regret_mean_pct": None, + "regret_median_pct": None, + "regret_p90_pct": None, + "regret_max_pct": None, + "capture_ratio": None, + "frac_rule_optimal": None, + "by_exit_reason": {}, + } + + rule_nets: List[float] = [] + oracle_nets: List[float] = [] + regrets: List[float] = [] + reasons: List[str] = [] + for inst in instances: + tr = simulate_trade( + inst.prices, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + seconds=inst.seconds, + fill_mode=fill_mode, + ) + oracle = oracle_exit_net_pct(inst.prices, cost_bps=cost_bps) + rule_nets.append(tr.net_return_pct) + oracle_nets.append(oracle) + regrets.append(oracle - tr.net_return_pct) + reasons.append(tr.exit_reason) + + rule_mean = mean(rule_nets) + oracle_mean = mean(oracle_nets) + capture = (rule_mean / oracle_mean) if oracle_mean > 0 else None + frac_optimal = sum(1 for r in regrets if r <= optimal_eps) / n + + by_reason: Dict[str, Dict[str, Any]] = {} + for reason in (EXIT_TP, EXIT_SL, EXIT_TIME): + idxs = [i for i, rr in enumerate(reasons) if rr == reason] + if not idxs: + by_reason[reason] = {"n": 0, "share": 0.0, "regret_mean_pct": None} + continue + reason_regrets = [regrets[i] for i in idxs] + by_reason[reason] = { + "n": len(idxs), + "share": len(idxs) / n, + "regret_mean_pct": mean(reason_regrets), + } + + return { + "n": n, + "rule_mean_net_pct": rule_mean, + "oracle_mean_net_pct": oracle_mean, + "regret_mean_pct": mean(regrets), + "regret_median_pct": median(regrets), + "regret_p90_pct": _percentile(regrets, 0.90), + "regret_max_pct": max(regrets), + "capture_ratio": capture, + "frac_rule_optimal": frac_optimal, + "by_exit_reason": by_reason, + } + + +# --------------------------------------------------------------------------- +# CLI: bounded DB read (reuses gap_up_backtest) -> ts_imb regret summary. +# --------------------------------------------------------------------------- +def _format_summary(s: Dict[str, Any]) -> str: + if s["n"] == 0: + return "n=0 (no instances)" + + def f(v: Optional[float]) -> str: + return "n/a" if v is None else f"{v:+.3f}%" + + lines = [ + f" N={s['n']} rule_mean={f(s['rule_mean_net_pct'])} " + f"oracle_mean={f(s['oracle_mean_net_pct'])}", + f" regret: mean={f(s['regret_mean_pct'])} " + f"median={f(s['regret_median_pct'])} " + f"p90={f(s['regret_p90_pct'])} max={f(s['regret_max_pct'])}", + f" capture_ratio=" + + ("n/a" if s["capture_ratio"] is None else f"{s['capture_ratio']:.1%}") + + f" frac_rule_optimal={s['frac_rule_optimal']:.1%}", + " regret by rule exit reason:", + ] + for reason in (EXIT_TP, EXIT_SL, EXIT_TIME): + r = s["by_exit_reason"].get(reason, {}) + lines.append( + f" {reason:<4} n={r.get('n', 0):<5} share={r.get('share', 0.0):.0%} " + f"regret_mean={f(r.get('regret_mean_pct'))}" + ) + return "\n".join(lines) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + # Windows consoles default to cp949 here and cannot encode em-dash/arrows; + # force UTF-8 so the report (and Korean) prints regardless of console codepage. + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + from stom_rl.gap_up_backtest import ( + ENTRY_FILTERS, + GapUpBacktestConfig, + collect_gap_up_instances, + filter_instances, + ) + + project_root = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser( + description="Oracle-exit ceiling test (RULE strategy, NOT RL) - Page R1." + ) + parser.add_argument( + "--db", default=str(project_root / "_database" / "stock_tick_back.db") + ) + parser.add_argument("--max-symbols", type=int, default=120) + parser.add_argument("--cost-bps", type=float, default=DEFAULT_COST_BPS) + parser.add_argument("--tp-pct", type=float, default=DEFAULT_TP_PCT) + parser.add_argument("--sl-pct", type=float, default=DEFAULT_SL_PCT) + parser.add_argument( + "--fill-mode", + choices=["idealized", "realized", "sl_gap_stress"], + default="realized", + ) + parser.add_argument( + "--filter", + choices=list(ENTRY_FILTERS.keys()), + default="ts_imb", + help="Entry filter applied before the regret summary (default ts_imb).", + ) + parser.add_argument( + "--json-out", + default=str( + project_root / ".omx" / "artifacts" / "oracle_exit" / "summary.json" + ), + help="Where to write the regret summary JSON (gitignored).", + ) + args = parser.parse_args(argv) + + config = GapUpBacktestConfig( + db_path=args.db, max_symbols=args.max_symbols, cost_bps=args.cost_bps + ) + instances = collect_gap_up_instances(config) + + print("=== oracle-exit ceiling test (RULE strategy, NOT RL) - Page R1 ===") + print( + f"db_max_symbols={args.max_symbols} tp={args.tp_pct:g}% sl={args.sl_pct:g}% " + f"cost={args.cost_bps:g}bp fill={args.fill_mode} (regret is cost-invariant)" + ) + results: Dict[str, Any] = {} + for fname in (args.filter,): + kept = filter_instances(instances, ENTRY_FILTERS[fname]) + summary = summarize_exit_regret( + kept, + tp_pct=args.tp_pct, + sl_pct=args.sl_pct, + cost_bps=args.cost_bps, + fill_mode=args.fill_mode, + ) + results[fname] = summary + print(f"-- filter={fname} --") + print(_format_summary(summary)) + print( + "\nread: small regret -> rule already near-optimal exit -> RL exit NOT " + "worth building; large regret -> headroom -> RL/optimal-stopping gated by " + "honest OOS eval may be attempted." + ) + out_path = Path(args.json_out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text( + json.dumps( + { + "max_symbols": args.max_symbols, + "tp_pct": args.tp_pct, + "sl_pct": args.sl_pct, + "cost_bps": args.cost_bps, + "fill_mode": args.fill_mode, + "n_instances_all": len(instances), + "summaries": results, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + print(f"wrote summary -> {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/full_universe.py b/stom_rl/full_universe.py new file mode 100644 index 000000000..24b33d8db --- /dev/null +++ b/stom_rl/full_universe.py @@ -0,0 +1,613 @@ +"""Page 16 — full-universe execution gate (checkpoint/resume runner). + +This module is the *named finish line* for running the whole STOM tick DB +end-to-end: for every recording **session date** it builds the co-dated panel +(Page 7.5) → screens it into candidates with one rule (Page 9) → runs the +expanding-window holdout walk-forward (Page 11), writing per-session artifacts. + +Why per-session (and never a full DB scan) +------------------------------------------ +Symbols in the STOM tick DB have **disjoint recording dates** — an arbitrary +set of symbols cannot be mixed into one panel because they were not recorded on +the same day. The only sound unit of work is therefore a single **session date** +grouping exactly the symbols that have data on that date. We discover those +groups with a cheap per-table ``SELECT DISTINCT substr(index,1,8)`` query (the +``index`` column is ``YYYYMMDDHHMMSS``); this touches each table's index but +never materialises a full table. The enumeration is cached to JSON so a rerun +skips the ~100s scan of 2400+ tables. + +Checkpoint / resume +------------------- +A JSON **manifest** records each session's status +(``pending`` / ``running`` / ``done`` / ``failed``) with timestamps and row +counts. On rerun with ``--resume`` a session already marked ``done`` is +**skipped** (its prior artifacts stand). A ``running`` session that exceeds a +configurable wall-clock budget is flagged ``stuck`` in the progress log so an +operator can intervene; a session that raises is recorded ``failed`` (never +silently lost) and the run continues with the next session. + +This module deliberately does **not** launch the full 29.7 GB run itself — the +full run is a long background job (see ``docs/stom_rl_page16_full_universe_*``). +The CLI is validated on a bounded slice (``--sessions`` / ``--max-sessions`` / +``--max-symbols-per-session``) end-to-end. + +Reuse only — no duplicated pipeline logic +----------------------------------------- +* session panel: :func:`stom_rl.panel_join.build_panel_from_db` (as-of join) +* candidates: :func:`stom_rl.candidate_gen.generate_candidates` (T+1 fill) +* holdout eval: :func:`stom_rl.portfolio_walk_forward.run_portfolio_walk_forward` +* symbol/table key: :mod:`stom_rl.symbol_norm` / DB table name == padded code +""" + +from __future__ import annotations + +import argparse +import json +import os +import time +from collections import defaultdict +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Union + +from finetune_csv.stom_tick_dataset import connect_readonly, list_stock_tables +from stom_rl.candidate_gen import build_topk_report, generate_candidates, write_topk_report +from stom_rl.condition_screener import load_rules +from stom_rl.panel_join import build_panel_from_db +from stom_rl.portfolio_walk_forward import ( + PortfolioWalkForwardConfig, + run_portfolio_walk_forward, +) + +# Status vocabulary for the manifest. ``stuck`` is a *log* flag (a long-running +# ``running`` entry), not a terminal manifest status. +STATUS_PENDING = "pending" +STATUS_RUNNING = "running" +STATUS_DONE = "done" +STATUS_FAILED = "failed" + +MANIFEST_NAME = "_manifest.json" +ENUM_CACHE_NAME = "_session_index.json" +PROGRESS_LOG_NAME = "_progress.log" + +# Default wall-clock budget (seconds) before a running session is flagged stuck. +DEFAULT_STUCK_SECONDS: float = 1800.0 + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +# --------------------------------------------------------------------------- +# Session enumeration (cheap per-table DISTINCT date query; never a full scan) +# --------------------------------------------------------------------------- +def enumerate_sessions( + db_path: Union[str, os.PathLike], + *, + max_symbols: int = 0, + timestamp_column: str = "index", +) -> Dict[str, List[str]]: + """Return ``{session_date(YYYYMMDD): [symbol tables with data on it]}``. + + For every symbol table we run a single ``SELECT DISTINCT + substr(CAST( AS TEXT), 1, 8)`` — the recorded session dates of that + table. This touches the table's timestamp column only (SQLite scans the + column, not the row payload) and is the only sound grouping because symbols + have disjoint recording dates. ``max_symbols`` bounds the number of tables + scanned (for in-session validation); ``0`` means all tables. + + Symbols are returned sorted within each session and the dict is ordered by + session date so callers (and tests) get a deterministic ordering. + """ + + conn = connect_readonly(db_path) + try: + tables = list_stock_tables(conn, max_tables=max_symbols if max_symbols and max_symbols > 0 else None) + quoted_ts = '"' + str(timestamp_column).replace('"', '""') + '"' + session_map: Dict[str, List[str]] = defaultdict(list) + for table in tables: + quoted_table = '"' + str(table).replace('"', '""') + '"' + query = f"SELECT DISTINCT substr(CAST({quoted_ts} AS TEXT), 1, 8) FROM {quoted_table}" + for (date_text,) in conn.execute(query).fetchall(): + if date_text is None: + continue + date_str = str(date_text) + if len(date_str) == 8 and date_str.isdigit(): + session_map[date_str].append(str(table)) + finally: + conn.close() + + return {date: sorted(symbols) for date, symbols in sorted(session_map.items())} + + +def load_or_build_session_index( + db_path: Union[str, os.PathLike], + output_dir: Union[str, os.PathLike], + *, + max_symbols: int = 0, + refresh: bool = False, +) -> Dict[str, List[str]]: + """Load the cached session index, or build (and cache) it once. + + The enumeration over 2400+ tables costs ~100s, so we cache it to + ``/_session_index.json`` keyed by ``max_symbols`` so a bounded + cache is never mistaken for the full one. ``refresh=True`` forces a rebuild. + """ + + cache_path = Path(output_dir) / ENUM_CACHE_NAME + cache_key = int(max_symbols or 0) + if cache_path.exists() and not refresh: + try: + cached = json.loads(cache_path.read_text(encoding="utf-8-sig")) + if int(cached.get("max_symbols", -1)) == cache_key: + return {str(k): list(v) for k, v in cached.get("sessions", {}).items()} + except (json.JSONDecodeError, OSError): + pass # rebuild on a corrupt cache + + sessions = enumerate_sessions(db_path, max_symbols=max_symbols) + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_text( + json.dumps( + {"max_symbols": cache_key, "built_at": _utc_now_iso(), "sessions": sessions}, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8-sig", + ) + return sessions + + +# --------------------------------------------------------------------------- +# Manifest (checkpoint/resume state) +# --------------------------------------------------------------------------- +@dataclass +class SessionManifestEntry: + """One session's checkpoint row in the manifest.""" + + session: str + status: str = STATUS_PENDING + symbol_count: int = 0 + candidate_count: int = 0 + fold_count: int = 0 + panel_rows: int = 0 + started_at: Optional[str] = None + finished_at: Optional[str] = None + elapsed_seconds: Optional[float] = None + error: Optional[str] = None + + def to_dict(self) -> Dict[str, Any]: + return { + "session": self.session, + "status": self.status, + "symbol_count": int(self.symbol_count), + "candidate_count": int(self.candidate_count), + "fold_count": int(self.fold_count), + "panel_rows": int(self.panel_rows), + "started_at": self.started_at, + "finished_at": self.finished_at, + "elapsed_seconds": self.elapsed_seconds, + "error": self.error, + } + + @classmethod + def from_dict(cls, data: Mapping[str, Any]) -> "SessionManifestEntry": + return cls( + session=str(data["session"]), + status=str(data.get("status", STATUS_PENDING)), + symbol_count=int(data.get("symbol_count", 0)), + candidate_count=int(data.get("candidate_count", 0)), + fold_count=int(data.get("fold_count", 0)), + panel_rows=int(data.get("panel_rows", 0)), + started_at=data.get("started_at"), + finished_at=data.get("finished_at"), + elapsed_seconds=data.get("elapsed_seconds"), + error=data.get("error"), + ) + + +@dataclass +class RunManifest: + """The full-universe run manifest: rule, output dir, and per-session entries.""" + + rule: str = "" + output_dir: str = "" + created_at: str = field(default_factory=_utc_now_iso) + updated_at: str = field(default_factory=_utc_now_iso) + entries: Dict[str, SessionManifestEntry] = field(default_factory=dict) + + @property + def path(self) -> Path: + return Path(self.output_dir) / MANIFEST_NAME + + def to_dict(self) -> Dict[str, Any]: + return { + "rule": self.rule, + "output_dir": self.output_dir, + "created_at": self.created_at, + "updated_at": self.updated_at, + "entries": {s: e.to_dict() for s, e in self.entries.items()}, + } + + def save(self) -> None: + self.updated_at = _utc_now_iso() + path = self.path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(self.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8-sig") + + @classmethod + def load(cls, output_dir: Union[str, os.PathLike]) -> Optional["RunManifest"]: + path = Path(output_dir) / MANIFEST_NAME + if not path.exists(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8-sig")) + except (json.JSONDecodeError, OSError): + return None + manifest = cls( + rule=str(data.get("rule", "")), + output_dir=str(data.get("output_dir", str(output_dir))), + created_at=str(data.get("created_at", _utc_now_iso())), + updated_at=str(data.get("updated_at", _utc_now_iso())), + ) + for session, entry in (data.get("entries") or {}).items(): + manifest.entries[str(session)] = SessionManifestEntry.from_dict(entry) + return manifest + + +def _load_or_init_manifest( + output_dir: Union[str, os.PathLike], + rule: str, + sessions: Sequence[str], + *, + resume: bool, +) -> RunManifest: + """Load an existing manifest (resume) or initialise a fresh one. + + On resume the existing per-session statuses are preserved (so ``done`` + sessions stay ``done`` and get skipped); any newly requested session not yet + in the manifest is added as ``pending``. Without resume a fresh manifest is + created with every requested session ``pending`` — but pre-existing terminal + state on disk is still honoured to avoid clobbering completed work. + """ + + manifest = RunManifest.load(output_dir) if resume else None + if manifest is None: + manifest = RunManifest(rule=str(rule), output_dir=str(output_dir)) + for session in sessions: + if session not in manifest.entries: + manifest.entries[session] = SessionManifestEntry(session=session) + return manifest + + +# --------------------------------------------------------------------------- +# Progress / stuck logging +# --------------------------------------------------------------------------- +def _append_progress(output_dir: Union[str, os.PathLike], message: str) -> None: + log_path = Path(output_dir) / PROGRESS_LOG_NAME + log_path.parent.mkdir(parents=True, exist_ok=True) + line = f"{_utc_now_iso()}\t{message}\n" + with log_path.open("a", encoding="utf-8") as handle: + handle.write(line) + + +def flag_stuck_sessions( + manifest: RunManifest, + *, + stuck_seconds: float = DEFAULT_STUCK_SECONDS, + now: Optional[float] = None, +) -> List[str]: + """Return sessions whose ``running`` entry has exceeded ``stuck_seconds``. + + Used by an external monitor: a ``running`` entry whose ``started_at`` is + older than the budget is considered stuck (no progress). This reads only the + manifest, so a separate watcher process can call it without touching the DB. + """ + + reference = float(now) if now is not None else time.time() + stuck: List[str] = [] + for session, entry in manifest.entries.items(): + if entry.status != STATUS_RUNNING or not entry.started_at: + continue + try: + started = datetime.strptime(entry.started_at, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc) + except (ValueError, TypeError): + continue + if reference - started.timestamp() > float(stuck_seconds): + stuck.append(session) + return stuck + + +# --------------------------------------------------------------------------- +# Per-session pipeline (reuses Page 7.5 / 9 / 11 — no duplicated logic) +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class FullUniverseConfig: + db_path: str + rule_path: str + output_dir: str + time_start: str = "090000" + time_end: str = "093000" + max_symbols_per_session: int = 0 + max_rows_per_group: int = 0 + n_folds: int = 2 + cost_bps: float = 25.0 + top_k: int = 3 + freq: str = "1s" + stuck_seconds: float = DEFAULT_STUCK_SECONDS + # Bound the *enumeration* table scan for in-session validation (0 = all + # tables). Distinct from ``max_symbols_per_session`` which caps symbols + # *within* a session at run time. + enum_max_tables: int = 0 + + +def run_session( + session: str, + symbols: Sequence[str], + config: FullUniverseConfig, +) -> Dict[str, Any]: + """Run the end-to-end pipeline for ONE session and write its artifacts. + + panel (as-of) → candidates (T+1 fill) → holdout walk-forward. Artifacts land + under ``//``. Returns a summary dict (counts + artifact + paths). Raises on any pipeline failure so the caller records ``failed``. + """ + + session_dir = Path(config.output_dir) / session + session_dir.mkdir(parents=True, exist_ok=True) + + rules = load_rules(Path(config.rule_path)) + + panel, panel_report = build_panel_from_db( + db_path=config.db_path, + tables=list(symbols), + session=session, + time_start=config.time_start, + time_end=config.time_end, + max_rows_per_group=config.max_rows_per_group, + freq=config.freq, + ) + + candidates = generate_candidates(panel, rules) + candidate_csv = session_dir / "candidates.csv" + candidates.to_csv(candidate_csv, index=False, encoding="utf-8-sig") + + topk_report = build_topk_report(candidates, top_k=config.top_k) + write_topk_report(session_dir / "topk_report.json", topk_report) + + walk_forward: Optional[Dict[str, Any]] = None + fold_count = 0 + distinct_timestamps = int(candidates["timestamp"].nunique()) if not candidates.empty else 0 + # The holdout needs >=2 distinct timestamps to form a train/test split. + if distinct_timestamps >= 2: + wf_config = PortfolioWalkForwardConfig( + candidate_path=str(candidate_csv), + output_dir=str(session_dir / "walk_forward"), + n_folds=config.n_folds, + cost_bps=config.cost_bps, + top_k_candidates=config.top_k, + ) + walk_forward = run_portfolio_walk_forward(wf_config) + fold_count = int(walk_forward.get("summary", {}).get("n_folds", 0)) + + summary = { + "session": session, + "symbol_count": len(symbols), + "panel_rows": int(len(panel)), + "panel_grid_size": int(panel_report.grid_size), + "candidate_count": int(len(candidates)), + "distinct_timestamps": distinct_timestamps, + "fold_count": fold_count, + "artifacts": { + "session_dir": str(session_dir), + "candidates_csv": str(candidate_csv), + "topk_report": str(session_dir / "topk_report.json"), + "walk_forward_dir": str(session_dir / "walk_forward") if walk_forward else None, + }, + } + (session_dir / "session_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8-sig" + ) + return summary + + +def _select_sessions( + session_index: Mapping[str, Sequence[str]], + *, + sessions: Optional[Sequence[str]], + max_sessions: int, +) -> List[str]: + """Resolve the requested session list against the enumerated index. + + Explicit ``--sessions`` win (filtered to those present in the index); + otherwise the first ``max_sessions`` (deterministic date order) are taken, or + all sessions when ``max_sessions`` is 0. + """ + + available = list(session_index.keys()) + if sessions: + requested = [s for s in sessions if s in session_index] + return requested + if max_sessions and max_sessions > 0: + return available[: int(max_sessions)] + return available + + +def run_full_universe( + config: FullUniverseConfig, + *, + sessions: Optional[Sequence[str]] = None, + max_sessions: int = 0, + resume: bool = False, + refresh_index: bool = False, +) -> Dict[str, Any]: + """Drive the full-universe gate with checkpoint/resume + progress logging. + + For each selected session: skip if already ``done`` (resume), else run the + per-session pipeline, marking ``running`` → ``done``/``failed`` in the + manifest and appending a progress line at each transition. A failing session + is recorded ``failed`` (with its error) and the run continues. + """ + + output_dir = Path(config.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + + session_index = load_or_build_session_index( + config.db_path, + output_dir, + max_symbols=int(config.enum_max_tables or 0), + refresh=refresh_index, + ) + selected = _select_sessions(session_index, sessions=sessions, max_sessions=max_sessions) + + manifest = _load_or_init_manifest(output_dir, config.rule_path, selected, resume=resume) + manifest.rule = config.rule_path + manifest.save() + + _append_progress( + output_dir, + f"RUN_START sessions={len(selected)} resume={resume} rule={Path(config.rule_path).name}", + ) + + processed: List[str] = [] + skipped: List[str] = [] + failed: List[str] = [] + + for session in selected: + entry = manifest.entries[session] + if resume and entry.status == STATUS_DONE: + skipped.append(session) + _append_progress(output_dir, f"SKIP session={session} status=done") + continue + + symbols = list(session_index.get(session, [])) + if config.max_symbols_per_session and config.max_symbols_per_session > 0: + symbols = symbols[: int(config.max_symbols_per_session)] + + entry.status = STATUS_RUNNING + entry.symbol_count = len(symbols) + entry.started_at = _utc_now_iso() + entry.finished_at = None + entry.error = None + manifest.save() + _append_progress(output_dir, f"SESSION_START session={session} symbols={len(symbols)}") + + started = time.time() + try: + summary = run_session(session, symbols, config) + elapsed = time.time() - started + entry.status = STATUS_DONE + entry.candidate_count = int(summary["candidate_count"]) + entry.fold_count = int(summary["fold_count"]) + entry.panel_rows = int(summary["panel_rows"]) + entry.finished_at = _utc_now_iso() + entry.elapsed_seconds = round(elapsed, 3) + manifest.save() + processed.append(session) + _append_progress( + output_dir, + f"SESSION_DONE session={session} candidates={entry.candidate_count} " + f"folds={entry.fold_count} panel_rows={entry.panel_rows} elapsed={elapsed:.2f}s", + ) + except Exception as exc: # noqa: BLE001 — record, never silently lose + elapsed = time.time() - started + entry.status = STATUS_FAILED + entry.finished_at = _utc_now_iso() + entry.elapsed_seconds = round(elapsed, 3) + entry.error = f"{type(exc).__name__}: {exc}" + manifest.save() + failed.append(session) + _append_progress( + output_dir, + f"SESSION_FAILED session={session} error={type(exc).__name__}: {exc} elapsed={elapsed:.2f}s", + ) + + result = { + "output_dir": str(output_dir), + "rule": config.rule_path, + "selected_sessions": selected, + "processed": processed, + "skipped": skipped, + "failed": failed, + "manifest_path": str(manifest.path), + "progress_log": str(output_dir / PROGRESS_LOG_NAME), + } + _append_progress( + output_dir, + f"RUN_END processed={len(processed)} skipped={len(skipped)} failed={len(failed)}", + ) + (output_dir / "_run_summary.json").write_text( + json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8-sig" + ) + return result + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def _parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Page 16 — full-universe execution gate with checkpoint/resume.", + ) + parser.add_argument("--db", required=True, help="STOM tick DB path.") + parser.add_argument("--rule", required=True, help="Rule JSON path (e.g. stom_rl/rules/buy_demand_pressure.json).") + parser.add_argument("--output-dir", required=True, help="Artifact + manifest output directory.") + + group = parser.add_mutually_exclusive_group() + group.add_argument("--sessions", default=None, help="Comma-separated session dates (YYYYMMDD) to run.") + group.add_argument("--max-sessions", type=int, default=0, help="Run the first N enumerated sessions (0=all).") + + parser.add_argument("--max-symbols-per-session", type=int, default=0, help="Cap symbols per session (0=all).") + parser.add_argument("--enum-max-tables", type=int, default=0, help="Cap tables scanned during session enumeration (0=all).") + parser.add_argument("--max-rows-per-group", type=int, default=0, help="Cap rows per symbol window (0=window-bounded).") + parser.add_argument("--time-start", default="090000", help="Window start HHMMSS.") + parser.add_argument("--time-end", default="093000", help="Window end HHMMSS.") + parser.add_argument("--n-folds", type=int, default=2, help="Walk-forward folds per session.") + parser.add_argument("--cost-bps", type=float, default=25.0, help="Round-trip cost in bps.") + parser.add_argument("--top-k", type=int, default=3, help="Top-K size for candidate report / env.") + parser.add_argument( + "--freq", + default="1s", + choices=["1s", "1min"], + help="Bar frequency for the feed path (default 1s; 1min resamples the RL source).", + ) + parser.add_argument("--stuck-seconds", type=float, default=DEFAULT_STUCK_SECONDS, help="Wall-clock budget before stuck flag.") + parser.add_argument("--resume", action="store_true", help="Skip sessions already marked done.") + parser.add_argument("--refresh-index", action="store_true", help="Rebuild the cached session index.") + return parser.parse_args(argv) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + args = _parse_args(argv) + sessions: Optional[List[str]] = None + if args.sessions: + sessions = [s.strip() for s in str(args.sessions).split(",") if s.strip()] + + config = FullUniverseConfig( + db_path=args.db, + rule_path=args.rule, + output_dir=args.output_dir, + time_start=args.time_start, + time_end=args.time_end, + max_symbols_per_session=int(args.max_symbols_per_session), + max_rows_per_group=int(args.max_rows_per_group), + n_folds=int(args.n_folds), + cost_bps=float(args.cost_bps), + top_k=int(args.top_k), + freq=str(args.freq), + stuck_seconds=float(args.stuck_seconds), + enum_max_tables=int(args.enum_max_tables), + ) + result = run_full_universe( + config, + sessions=sessions, + max_sessions=int(args.max_sessions), + resume=bool(args.resume), + refresh_index=bool(args.refresh_index), + ) + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/gap_up_backtest.py b/stom_rl/gap_up_backtest.py new file mode 100644 index 000000000..df99b6065 --- /dev/null +++ b/stom_rl/gap_up_backtest.py @@ -0,0 +1,1534 @@ +"""Opening gap-up momentum backtest (시초 갭상승 전략) on the real 1-second DB. + +Strategy (user-specified, pre-registered BEFORE reading any result) +------------------------------------------------------------------- +* **Entry (시초 갭상승)** — for each recorded ``(symbol, session)`` in + ``_database/stock_tick_back.db`` look at the FIRST bar of the session + (the earliest row at or after ``09:00:00``). If that bar's ``등락율`` + (percent change vs. previous close, stored in the DB) is ``>= 2.0`` enter + LONG at that bar's ``현재가`` (current price). +* **Exit (first to trigger)** — walk the intra-session 1-second ``현재가`` + path FORWARD from the entry bar: + - take-profit when price ``>= entry * (1 + tp_pct/100)``; + - stop-loss when price ``<= entry * (1 - sl_pct/100)``; + - else time-exit at ``09:25:00`` (시간청산) at that bar's price. + The forward walk IS the legitimate trade execution — it only consumes + prices at or after the entry second, so there is NO look-ahead at ENTRY + (the entry decision uses only the first bar's 등락율). +* **Cost** — a configurable round-trip model + (:func:`round_trip_cost_bps`): + ``commission_bps_per_side*2 + transaction_tax_bps (SELL side only) + + slippage_bps``. The legacy flat 25 bp is the default total; a cost SWEEP + over ``{0,5,10,15,18,25}`` bp is run to report the BREAKEVEN round-trip cost + at which OOS net expectancy crosses zero (a property, not a tuned cell). +* **Entry filters (optional, pre-registered from STOM buy rules)** — on top of + the 등락율>=2% gate, optionally require causal ENTRY-BAR demand signals: + ``체결강도``(trade_strength) and 호가 ``bid_ask_imbalance`` + (매수총잔량/(매수+매도총잔량)). Filters use only entry-bar values (<= entry + second), so there is no look-ahead. Definitions are pre-registered + (:data:`ENTRY_FILTERS`), drawn from ``stom_rl/rules/buy_demand_pressure.json`` + / ``buy_widev1.json`` — NOT searched for the best threshold. + +Honesty caveats (locked, see the doc) +------------------------------------- +* The TP/SL pair is SWEPT over a small grid (``TP x SL``); the full surface + is reported and no single cell is cherry-picked as "the" answer. +* The cost sweep reports the BREAKEVEN cost (where OOS expectancy = 0), not a + cherry-picked low-cost cell, and the entry filters are pre-registered (no + threshold search). +* The universe is a TRIGGERED subset: the DB only recorded a session because + it hit *some* STOM condition, so 등락율>=2% instances here are NOT a random + sample of all 2%+ market gap-ups. Results may not generalise. +* In-sample / out-of-sample split is by DATE (earlier sessions in-sample, + later sessions out-of-sample) so an overfit TP/SL is exposed. + +Bounded reads (NO full 28GB scan) +--------------------------------- +Sessions are enumerated with the cheap per-table +``SELECT DISTINCT substr(index,1,8)`` query (:func:`enumerate_sessions`, +Story B1 pattern), then each ``(symbol, session)`` is read with a single +session-prefix-filtered, index-ordered query over the entry->09:25 window. +Each per-instance read touches one table's morning window only. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from finetune_csv.stom_tick_dataset import ( # noqa: E402 + connect_readonly, + get_table_columns, + list_stock_tables, +) + +# --------------------------------------------------------------------------- +# Locked pre-registration constants (DO NOT tune after seeing results). +# --------------------------------------------------------------------------- +ENTRY_CHANGE_RATE_THRESHOLD: float = 2.0 # 등락율 >= 2.0% at the first bar +TIME_EXIT_HHMMSS: str = "092500" # 시간청산 cutoff (data ends ~0930) +SESSION_START_HHMMSS: str = "090000" +COST_BPS_ROUND_TRIP: float = 25.0 # entry + exit combined, basis points (legacy flat) + +# Swept TP/SL grid (M = 16 combos). Reported in full; no cherry-pick. +TP_GRID_PCT: Tuple[float, ...] = (1.0, 2.0, 3.0, 5.0) +SL_GRID_PCT: Tuple[float, ...] = (1.0, 1.5, 2.0, 3.0) + +# Cost SWEEP of round-trip levels (bps). Used to locate the breakeven cost +# (OOS net expectancy = 0). Includes the two labelled reference scenarios: +# * Korean-domestic ~18 bp (commission ~1.5 bp/side + transaction tax ~15 bp +# on the SELL side only); see KOREAN_DOMESTIC_COST. +# * International / low ~5 bp (commission ~2.5 bp/side, no transaction tax); +# see INTERNATIONAL_LOW_COST. +COST_SWEEP_BPS: Tuple[float, ...] = (0.0, 5.0, 10.0, 15.0, 18.0, 25.0) + +# Pre-registered entry filters (NOT threshold-searched). Thresholds are the +# STOM canonical values (체결강도 >= 100 = "at par", 호가 imbalance >= 0.5 = +# "bid-side leaning") taken verbatim from buy_widev1.json / buy_demand_pressure +# .json — NOT tuned on this backtest's results. +TRADE_STRENGTH_THRESHOLD: float = 100.0 # 체결강도 >= 100 (STOM "at par") +IMBALANCE_THRESHOLD: float = 0.5 # 호가 매수총잔량/(매수+매도총잔량) >= 0.5 + +# DB column resolution (Korean names stored UTF-8; sqlite3 decodes by default). +_TIMESTAMP_CANDIDATES: Tuple[str, ...] = ("index", "timestamps", "timestamp") +_PRICE_CANDIDATES: Tuple[str, ...] = ("현재가", "종가", "close") +_CHANGE_RATE_CANDIDATES: Tuple[str, ...] = ("등락율",) +_TRADE_STRENGTH_CANDIDATES: Tuple[str, ...] = ("체결강도",) +_SEC_AMOUNT_CANDIDATES: Tuple[str, ...] = ("초당거래대금",) +_BID_QTY_CANDIDATES: Tuple[str, ...] = ("매수총잔량",) +_ASK_QTY_CANDIDATES: Tuple[str, ...] = ("매도총잔량",) + +# Exit-reason labels. +EXIT_TP = "tp" +EXIT_SL = "sl" +EXIT_TIME = "time" + + +def _quote_ident(name: str) -> str: + return '"' + str(name).replace('"', '""') + '"' + + +def _resolve_column(columns: Sequence[str], candidates: Sequence[str]) -> Optional[str]: + column_set = set(columns) + for candidate in candidates: + if candidate in column_set: + return candidate + return None + + +# --------------------------------------------------------------------------- +# Realistic round-trip cost model (commission + sell-side tax + slippage). +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class RoundTripCost: + """A realistic per-round-trip cost model (all components in basis points). + + A round trip is one BUY (entry) + one SELL (exit). The total round-trip + cost is:: + + commission_bps_per_side * 2 (commission charged on BOTH sides) + + transaction_tax_bps (Korean 증권거래세 — SELL side ONLY) + + slippage_bps (round-trip execution slippage) + + The transaction tax is applied ONCE (sell side only); buying incurs no + transaction tax in the Korean market, hence it is not multiplied by two. + """ + + commission_bps_per_side: float = 0.0 + transaction_tax_bps: float = 0.0 # sell side only + slippage_bps: float = 0.0 + + def total_round_trip_bps(self) -> float: + return round_trip_cost_bps( + commission_bps_per_side=self.commission_bps_per_side, + transaction_tax_bps=self.transaction_tax_bps, + slippage_bps=self.slippage_bps, + ) + + +def round_trip_cost_bps( + *, + commission_bps_per_side: float = 0.0, + transaction_tax_bps: float = 0.0, + slippage_bps: float = 0.0, +) -> float: + """Compute the total round-trip cost in basis points. + + ``commission_bps_per_side`` is charged on BOTH the buy and the sell, so it + is doubled. ``transaction_tax_bps`` (증권거래세) is charged on the SELL side + ONLY, so it is added once. ``slippage_bps`` is the round-trip slippage. + """ + + if ( + commission_bps_per_side < 0 + or transaction_tax_bps < 0 + or slippage_bps < 0 + ): + raise ValueError("cost components must be non-negative") + return ( + commission_bps_per_side * 2.0 + + transaction_tax_bps + + slippage_bps + ) + + +# Two labelled reference scenarios (see COST_SWEEP_BPS docstring). +# Korean-domestic: commission ~1.5 bp/side + 증권거래세 ~15 bp (sell only) +# -> 1.5*2 + 15 = 18 bp round trip. +KOREAN_DOMESTIC_COST = RoundTripCost( + commission_bps_per_side=1.5, transaction_tax_bps=15.0, slippage_bps=0.0 +) +# International / low: commission ~2.5 bp/side, no transaction tax +# -> 2.5*2 + 0 = 5 bp round trip. +INTERNATIONAL_LOW_COST = RoundTripCost( + commission_bps_per_side=2.5, transaction_tax_bps=0.0, slippage_bps=0.0 +) + + +# --------------------------------------------------------------------------- +# Pre-registered causal entry filters (STOM buy-rule derived; NOT searched). +# --------------------------------------------------------------------------- +def compute_bid_ask_imbalance( + bid_total_qty: Optional[float], ask_total_qty: Optional[float] +) -> Optional[float]: + """호가 imbalance = 매수총잔량 / (매수총잔량 + 매도총잔량). + + Returns ``None`` when either side is missing or the book is empty (no + denominator). Range is ``[0, 1]``; ``>= 0.5`` means the book leans bid-side + (more resting buy interest), the STOM 호가상승압력 intent. + """ + + if bid_total_qty is None or ask_total_qty is None: + return None + try: + bid = float(bid_total_qty) + ask = float(ask_total_qty) + except (TypeError, ValueError): + return None + denom = bid + ask + if denom <= 0: + return None + return bid / denom + + +@dataclass(frozen=True) +class EntryFilter: + """A pre-registered causal entry filter (evaluated on the ENTRY BAR only).""" + + name: str + require_trade_strength: bool = False + require_imbalance: bool = False + trade_strength_threshold: float = TRADE_STRENGTH_THRESHOLD + imbalance_threshold: float = IMBALANCE_THRESHOLD + + +# Pre-registered filter set. "none" = baseline (등락율>=2% only); F1/F2 are the +# STOM demand gates. These are FIXED definitions (no per-result tuning). +ENTRY_FILTERS: Dict[str, EntryFilter] = { + "none": EntryFilter(name="none"), + # F1: 체결강도 >= 100 (STOM buy_widev1 / buy_demand_pressure "at par"). + "ts": EntryFilter(name="ts", require_trade_strength=True), + # F2: 체결강도 >= 100 AND 호가 imbalance >= 0.5 (full buy_demand_pressure). + "ts_imb": EntryFilter( + name="ts_imb", require_trade_strength=True, require_imbalance=True + ), +} + + +def passes_entry_filter( + inst: "GapUpInstance", entry_filter: EntryFilter +) -> bool: + """Return True if the instance's ENTRY-BAR signals satisfy the filter. + + Uses ONLY the entry-bar (<= entry second) ``trade_strength`` and + ``bid_ask_imbalance`` already captured on the instance, so there is no + look-ahead. A required signal that is missing (``None``) FAILS the filter + (we do not silently admit instances lacking the demand evidence). + """ + + if entry_filter.require_trade_strength: + ts = inst.entry_trade_strength + if ts is None or ts < entry_filter.trade_strength_threshold: + return False + if entry_filter.require_imbalance: + imb = inst.entry_bid_ask_imbalance + if imb is None or imb < entry_filter.imbalance_threshold: + return False + return True + + +def filter_instances( + instances: Sequence["GapUpInstance"], entry_filter: EntryFilter +) -> List["GapUpInstance"]: + """Apply a pre-registered entry filter, returning the surviving instances.""" + + return [inst for inst in instances if passes_entry_filter(inst, entry_filter)] + + +# --------------------------------------------------------------------------- +# Pure trade-execution core (unit-tested on synthetic paths; no DB / no I/O). +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class TradeResult: + """One resolved gap-up trade. + + ``net_return_pct`` is the round-trip-cost-adjusted percent return; the gross + return is the raw exit/entry move. ``exit_reason`` is one of + :data:`EXIT_TP` / :data:`EXIT_SL` / :data:`EXIT_TIME`. + """ + + entry_price: float + exit_price: float + exit_reason: str + hold_seconds: int + gross_return_pct: float + net_return_pct: float + + +_FILL_MODES: frozenset = frozenset({"idealized", "realized", "sl_gap_stress"}) + + +def simulate_trade( + prices: Sequence[float], + *, + tp_pct: float, + sl_pct: float, + cost_bps: float = COST_BPS_ROUND_TRIP, + seconds: Optional[Sequence[int]] = None, + fill_mode: str = "idealized", +) -> TradeResult: + """Walk a forward 1s price path from entry and resolve the exit. + + ``prices[0]`` is the entry (the first bar's price); subsequent elements are + the forward path up to and including the time-exit bar (the caller bounds + the path at 09:25:00). The walk returns at the FIRST second whose price + crosses TP or SL; if neither triggers it time-exits at the last price. + + A bar that crosses BOTH the TP and SL thresholds in the same second + (a gap through the band) is resolved CONSERVATIVELY as a stop-loss — the + pessimistic assumption for a long, since we cannot know intra-second order. + + ``net_return_pct`` subtracts the full round-trip ``cost_bps`` (entry+exit) + from the gross move. ``seconds`` (optional) supplies the elapsed-second of + each path element so the reported ``hold_seconds`` reflects wall-clock time; + when absent the index is used. + + ``fill_mode`` controls how the booked exit price is determined once a + threshold is crossed — three modes are supported: + + * ``"idealized"`` (default): SL books at exactly ``sl_level``; TP books at + exactly ``tp_level``. This is the original behaviour and preserves all + prior test results. + * ``"realized"``: SL books at ``min(price, sl_level)`` — the actual bar + price, which may gap *through* the level for a worse fill; TP books at + ``max(price, tp_level)`` — the actual bar price, which may overshoot for + a better fill. + * ``"sl_gap_stress"`` (most pessimistic overall): SL books at + ``min(price, sl_level)`` like ``"realized"`` (gap-through worst case); + TP books at ``tp_level`` like ``"idealized"`` (conservative, no credit + for overshoot). Use this to stress-test whether the edge survives the + combined worst-case scenario. + """ + + if fill_mode not in _FILL_MODES: + raise ValueError( + f"fill_mode must be one of {sorted(_FILL_MODES)!r}, got {fill_mode!r}" + ) + if tp_pct <= 0 or sl_pct <= 0: + raise ValueError("tp_pct and sl_pct must be positive") + if not prices: + raise ValueError("prices must contain at least the entry bar") + entry = float(prices[0]) + if entry <= 0: + raise ValueError("entry price must be positive") + + tp_level = entry * (1.0 + tp_pct / 100.0) + sl_level = entry * (1.0 - sl_pct / 100.0) + cost_pct = float(cost_bps) / 100.0 # 25 bps -> 0.25% + + def _elapsed(idx: int) -> int: + if seconds is not None and idx < len(seconds): + return int(seconds[idx]) - int(seconds[0]) + return int(idx) + + def _sl_fill(price: float) -> float: + # idealized: book at the exact SL level (optimistic). + # realized / sl_gap_stress: book at the actual crossing price (realistic + # worst-case: gap-through fills at the bar price, not the level). + if fill_mode == "idealized": + return sl_level + return min(price, sl_level) + + def _tp_fill(price: float) -> float: + # realized: book at the actual crossing price (may be above tp_level on + # gap-through — a better fill for the long). + # idealized / sl_gap_stress: book at the exact TP level (conservative). + if fill_mode == "realized": + return max(price, tp_level) + return tp_level + + exit_idx = len(prices) - 1 + exit_price = float(prices[-1]) + exit_reason = EXIT_TIME + + # Forward walk: start at the SECOND bar (index 1). The entry bar itself + # cannot also be the exit — the trade is opened on it. + for idx in range(1, len(prices)): + price = float(prices[idx]) + hit_sl = price <= sl_level + hit_tp = price >= tp_level + if hit_sl: # conservative: SL wins a same-bar TP+SL straddle + exit_idx, exit_price, exit_reason = idx, _sl_fill(price), EXIT_SL + break + if hit_tp: + exit_idx, exit_price, exit_reason = idx, _tp_fill(price), EXIT_TP + break + + gross = (exit_price / entry - 1.0) * 100.0 + net = gross - cost_pct + return TradeResult( + entry_price=entry, + exit_price=float(exit_price), + exit_reason=exit_reason, + hold_seconds=_elapsed(exit_idx), + gross_return_pct=float(gross), + net_return_pct=float(net), + ) + + +def simulate_baseline( + prices: Sequence[float], + *, + cost_bps: float = COST_BPS_ROUND_TRIP, + seconds: Optional[Sequence[int]] = None, +) -> TradeResult: + """Baseline: buy@open, hold to the time-exit bar, NO TP/SL. + + Exit is always the last price on the bounded path (09:25), so this measures + "just ride the gap" net of the same round-trip cost. + """ + + if not prices: + raise ValueError("prices must contain at least the entry bar") + entry = float(prices[0]) + if entry <= 0: + raise ValueError("entry price must be positive") + exit_price = float(prices[-1]) + cost_pct = float(cost_bps) / 100.0 + gross = (exit_price / entry - 1.0) * 100.0 + net = gross - cost_pct + last_idx = len(prices) - 1 + if seconds is not None and last_idx < len(seconds): + hold = int(seconds[last_idx]) - int(seconds[0]) + else: + hold = int(last_idx) + return TradeResult( + entry_price=entry, + exit_price=exit_price, + exit_reason=EXIT_TIME, + hold_seconds=hold, + gross_return_pct=float(gross), + net_return_pct=float(net), + ) + + +# --------------------------------------------------------------------------- +# Per-instance DB read (bounded; session-prefix indexed; entry->09:25 window). +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class GapUpInstance: + """A resolved gap-up entry candidate for one (symbol, session). + + ``entry_trade_strength`` (체결강도), ``entry_sec_amount`` (초당거래대금) and + ``entry_bid_ask_imbalance`` are the CAUSAL entry-bar demand signals used by + :func:`passes_entry_filter`; all are taken from the FIRST (entry) bar only, + so they never look ahead. Any may be ``None`` when the source column is + absent or unreadable. + """ + + symbol: str + session: str + entry_change_rate: float + entry_price: float + prices: Tuple[float, ...] + seconds: Tuple[int, ...] + entry_trade_strength: Optional[float] = None + entry_sec_amount: Optional[float] = None + entry_bid_ask_imbalance: Optional[float] = None + + +def _hhmmss_of(ts: int) -> str: + return str(int(ts))[8:14] + + +def _seconds_since_midnight(ts: int) -> int: + s = str(int(ts)) + hh, mm, ss = int(s[8:10]), int(s[10:12]), int(s[12:14]) + return hh * 3600 + mm * 60 + ss + + +def read_gap_up_instance( + conn: Any, + table: str, + session: str, + *, + timestamp_col: str, + price_col: str, + change_rate_col: str, + trade_strength_col: Optional[str] = None, + sec_amount_col: Optional[str] = None, + bid_qty_col: Optional[str] = None, + ask_qty_col: Optional[str] = None, + entry_threshold: float = ENTRY_CHANGE_RATE_THRESHOLD, + session_start: str = SESSION_START_HHMMSS, + time_exit: str = TIME_EXIT_HHMMSS, +) -> Optional[GapUpInstance]: + """Read one (symbol, session) window and return a gap-up instance or None. + + A single session-prefix-filtered, index-ordered query pulls the rows from + ``session_start`` to ``time_exit`` (inclusive). The entry is the FIRST row; + if its 등락율 < ``entry_threshold`` (or price is non-positive) the session is + skipped (returns None). The forward path is every row from entry to the + time-exit cutoff. This touches one table's morning window only. + + When the optional demand columns (``trade_strength_col`` = 체결강도, + ``sec_amount_col`` = 초당거래대금, ``bid_qty_col``/``ask_qty_col`` = + 매수/매도총잔량) are supplied they are read from the ENTRY bar ONLY and stored + on the instance for the causal entry filters (no look-ahead). + """ + + if len(session) != 8 or not session.isdigit(): + raise ValueError(f"session must be YYYYMMDD, got: {session}") + qt = _quote_ident(table) + ts_q = _quote_ident(timestamp_col) + px_q = _quote_ident(price_col) + cr_q = _quote_ident(change_rate_col) + # Optional demand columns appended AFTER the core three (entry-bar only). + extra_cols = [trade_strength_col, sec_amount_col, bid_qty_col, ask_qty_col] + select_extra = "".join( + f", {_quote_ident(c)}" if c else ", NULL" for c in extra_cols + ) + start_full = int(session + session_start) + end_full = int(session + time_exit) + query = ( + f"SELECT {ts_q}, {px_q}, {cr_q}{select_extra} FROM {qt} " + f"WHERE {ts_q} >= ? AND {ts_q} <= ? " + f"ORDER BY {ts_q}" + ) + rows = conn.execute(query, (start_full, end_full)).fetchall() + if not rows: + return None + + # Entry = first bar of the session window. + entry_row = rows[0] + entry_px, entry_cr = entry_row[1], entry_row[2] + if entry_cr is None or entry_px is None: + return None + try: + entry_cr = float(entry_cr) + entry_px = float(entry_px) + except (TypeError, ValueError): + return None + if entry_px <= 0 or entry_cr < float(entry_threshold): + return None + + # CAUSAL entry-bar demand signals (entry row only; never look ahead). + def _opt_float(value: Any) -> Optional[float]: + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + entry_trade_strength = _opt_float(entry_row[3]) + entry_sec_amount = _opt_float(entry_row[4]) + entry_bid_qty = _opt_float(entry_row[5]) + entry_ask_qty = _opt_float(entry_row[6]) + entry_imbalance = compute_bid_ask_imbalance(entry_bid_qty, entry_ask_qty) + + prices: List[float] = [] + secs: List[int] = [] + for row in rows: + ts, px = row[0], row[1] + if px is None: + continue + try: + p = float(px) + except (TypeError, ValueError): + continue + if p <= 0: + continue + prices.append(p) + secs.append(_seconds_since_midnight(int(ts))) + if len(prices) < 1: + return None + + return GapUpInstance( + symbol=str(table), + session=str(session), + entry_change_rate=entry_cr, + entry_price=prices[0], + prices=tuple(prices), + seconds=tuple(secs), + entry_trade_strength=entry_trade_strength, + entry_sec_amount=entry_sec_amount, + entry_bid_ask_imbalance=entry_imbalance, + ) + + +# --------------------------------------------------------------------------- +# Aggregation over a set of resolved trades. +# --------------------------------------------------------------------------- +def aggregate_trades(trades: Sequence[TradeResult]) -> Dict[str, Any]: + """Summarise a set of resolved trades into honest aggregate metrics.""" + + n = len(trades) + if n == 0: + return { + "n_trades": 0, + "win_rate": None, + "mean_net_return_pct": None, + "total_net_return_pct": 0.0, + "expectancy_pct": None, + "mean_gross_return_pct": None, + "avg_hold_seconds": None, + "exit_mix": {EXIT_TP: 0.0, EXIT_SL: 0.0, EXIT_TIME: 0.0}, + } + nets = [t.net_return_pct for t in trades] + grosses = [t.gross_return_pct for t in trades] + holds = [t.hold_seconds for t in trades] + wins = sum(1 for v in nets if v > 0.0) + counts = {EXIT_TP: 0, EXIT_SL: 0, EXIT_TIME: 0} + for t in trades: + counts[t.exit_reason] = counts.get(t.exit_reason, 0) + 1 + total_net = float(sum(nets)) + return { + "n_trades": n, + "win_rate": wins / n, + "mean_net_return_pct": total_net / n, + "total_net_return_pct": total_net, + "expectancy_pct": total_net / n, # per-trade expectancy net of cost + "mean_gross_return_pct": float(sum(grosses)) / n, + "avg_hold_seconds": float(sum(holds)) / n, + "exit_mix": {k: counts[k] / n for k in (EXIT_TP, EXIT_SL, EXIT_TIME)}, + } + + +def split_instances_by_date( + instances: Sequence[GapUpInstance], + in_sample_fraction: float = 0.7, +) -> Tuple[List[GapUpInstance], List[GapUpInstance], Optional[str]]: + """Split instances by SESSION DATE into in-sample (earlier) / OOS (later). + + The split boundary is the date at the ``in_sample_fraction`` quantile of the + sorted distinct session dates. All instances on a date <= boundary are + in-sample; later dates are out-of-sample. Returns + ``(in_sample, out_of_sample, boundary_date)``. + """ + + if not instances: + return [], [], None + dates = sorted({inst.session for inst in instances}) + if len(dates) == 1: + return list(instances), [], dates[0] + cut_index = max(0, min(len(dates) - 1, int(round(len(dates) * in_sample_fraction)) - 1)) + boundary = dates[cut_index] + in_sample = [inst for inst in instances if inst.session <= boundary] + out_of_sample = [inst for inst in instances if inst.session > boundary] + return in_sample, out_of_sample, boundary + + +# --------------------------------------------------------------------------- +# Cost sweep + breakeven (the breakeven is a PROPERTY, not a tuned cell). +# --------------------------------------------------------------------------- +def expectancy_at_cost( + instances: Sequence[GapUpInstance], + *, + tp_pct: float, + sl_pct: float, + cost_bps: float, + fill_mode: str = "idealized", +) -> Optional[float]: + """Net per-trade expectancy (%) for one TP/SL at one round-trip cost. + + Returns ``None`` for an empty instance set. Because cost enters the net + return purely additively (``net = gross - cost%``), expectancy is exactly + ``gross_expectancy - cost_bps/100`` and STRICTLY DECREASES as cost rises — + this monotonicity is what makes the breakeven well-defined. + + ``fill_mode`` is forwarded to :func:`simulate_trade`; see that function's + docstring for the three supported modes. + """ + + if not instances: + return None + trades = [ + simulate_trade( + inst.prices, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + seconds=inst.seconds, + fill_mode=fill_mode, + ) + for inst in instances + ] + return aggregate_trades(trades)["expectancy_pct"] + + +def breakeven_round_trip_bps( + instances: Sequence[GapUpInstance], + *, + tp_pct: float, + sl_pct: float, + fill_mode: str = "idealized", +) -> Optional[float]: + """Round-trip cost (bps) at which net expectancy crosses 0 for this TP/SL. + + Net expectancy = ``gross_expectancy_pct - cost_bps/100``; it is 0 exactly + when ``cost_bps = gross_expectancy_pct * 100``. A NEGATIVE result means even + a zero-cost trade loses (gross edge < 0) — the strategy is unprofitable at + ANY cost. A POSITIVE result is the maximum tolerable round-trip cost. + Returns ``None`` for an empty set. + + ``fill_mode`` is forwarded to :func:`simulate_trade`. + """ + + gross = expectancy_at_cost( + instances, tp_pct=tp_pct, sl_pct=sl_pct, cost_bps=0.0, fill_mode=fill_mode + ) + if gross is None: + return None + return gross * 100.0 # %/trade -> bps + + +def cost_sweep_table( + instances: Sequence[GapUpInstance], + *, + tp_pct: float, + sl_pct: float, + cost_levels: Sequence[float] = COST_SWEEP_BPS, + fill_mode: str = "idealized", +) -> List[Dict[str, Any]]: + """OOS-honest cost sweep: net expectancy at each round-trip cost level. + + ``fill_mode`` is forwarded to :func:`simulate_trade`. + """ + + return [ + { + "cost_bps": float(c), + "expectancy_pct": expectancy_at_cost( + instances, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=float(c), + fill_mode=fill_mode, + ), + } + for c in cost_levels + ] + + +# --------------------------------------------------------------------------- +# Regime-robustness analysis modes (per-year, multi-boundary, slippage). +# +# These answer "is the OOS-positive filtered gap-up edge regime-robust or a +# recent-bull artifact, and does it survive realistic slippage?". All three +# reuse the SAME pure trade-execution core (:func:`simulate_trade`) and the +# SAME additive cost model, so a positive result is comparable to the prior +# cost-sweep run. No new edge is "discovered" — these only re-slice the +# existing instances by calendar year / split boundary / total cost level. +# --------------------------------------------------------------------------- +# Slippage levels (bps) ADDED to the round-trip commission+tax cost. Execution +# at a gap-up open is not free; this stresses the edge against that reality. +SLIPPAGE_SWEEP_BPS: Tuple[float, ...] = (0.0, 5.0, 10.0, 20.0) +# Realistic Korean-domestic base cost (commission+tax) the slippage is added to. +REALISTIC_COST_BPS: float = 18.0 + + +def year_of(session: str) -> str: + """Calendar year (YYYY) of a YYYYMMDD session string. + + Raises ``ValueError`` for a malformed session so a bad date can never be + silently bucketed into the wrong year. + """ + + s = str(session) + if len(s) != 8 or not s.isdigit(): + raise ValueError(f"session must be YYYYMMDD, got: {session!r}") + return s[:4] + + +def split_instances_by_year( + instances: Sequence[GapUpInstance], +) -> Dict[str, List[GapUpInstance]]: + """Bucket instances by their session's calendar year (YYYY). + + Returns an ordered dict (ascending year) so a strategy that is positive + only in the latest years is visible as a recent-regime artifact rather than + a robust edge. + """ + + buckets: Dict[str, List[GapUpInstance]] = {} + for inst in instances: + buckets.setdefault(year_of(inst.session), []).append(inst) + return {year: buckets[year] for year in sorted(buckets)} + + +def per_year_expectancy( + instances: Sequence[GapUpInstance], + *, + tp_pct: float, + sl_pct: float, + cost_bps: float, + fill_mode: str = "idealized", +) -> List[Dict[str, Any]]: + """Net per-trade expectancy + N + win-rate for EACH calendar year. + + A signal positive only in recent years (e.g. 2025-2026) is a regime + artifact; one positive across all years is robust. Cost enters additively + so each year's expectancy is that year's gross edge minus ``cost_bps/100``. + + ``fill_mode`` is forwarded to :func:`simulate_trade`. + """ + + rows: List[Dict[str, Any]] = [] + for year, year_insts in split_instances_by_year(instances).items(): + trades = [ + simulate_trade( + inst.prices, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + seconds=inst.seconds, + fill_mode=fill_mode, + ) + for inst in year_insts + ] + agg = aggregate_trades(trades) + rows.append( + { + "year": year, + "n_trades": agg["n_trades"], + "mean_net_return_pct": agg["mean_net_return_pct"], + "win_rate": agg["win_rate"], + } + ) + return rows + + +def multi_boundary_oos( + instances: Sequence[GapUpInstance], + *, + tp_pct: float, + sl_pct: float, + cost_bps: float, + fractions: Sequence[float] = (0.5, 0.6, 0.7, 0.8, 0.9), + fill_mode: str = "idealized", +) -> List[Dict[str, Any]]: + """Sweep the IS/OOS split boundary; report OOS expectancy at each. + + Instead of one favourable holdout date, this walks the boundary across the + sorted distinct session dates (expanding in-sample window). If the OOS + expectancy stays positive as the boundary moves the edge is boundary-robust; + if it is positive only for one boundary it is fragile. Each row reports the + boundary date, the IS/OOS instance counts, and the IS & OOS expectancy. + + ``fill_mode`` is forwarded to :func:`simulate_trade`. + """ + + rows: List[Dict[str, Any]] = [] + for frac in fractions: + in_sample, out_sample, boundary = split_instances_by_date( + instances, in_sample_fraction=float(frac) + ) + rows.append( + { + "in_sample_fraction": float(frac), + "boundary_date": boundary, + "n_in_sample": len(in_sample), + "n_out_of_sample": len(out_sample), + "expectancy_in_sample": expectancy_at_cost( + in_sample, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + fill_mode=fill_mode, + ), + "expectancy_out_of_sample": expectancy_at_cost( + out_sample, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + fill_mode=fill_mode, + ), + } + ) + return rows + + +def slippage_sensitivity( + instances: Sequence[GapUpInstance], + *, + tp_pct: float, + sl_pct: float, + base_cost_bps: float = REALISTIC_COST_BPS, + slippage_levels: Sequence[float] = SLIPPAGE_SWEEP_BPS, + fill_mode: str = "idealized", +) -> List[Dict[str, Any]]: + """Net expectancy at ``base_cost_bps + slippage`` for each slippage level. + + Slippage adds to the round-trip cost (verified by :func:`round_trip_cost_bps` + composing commission+tax+slippage). At each level the TOTAL cost is + ``base_cost_bps + slippage_bps`` and the expectancy is computed with that + total — so a filter whose breakeven exceeds base+slippage survives, one + whose breakeven sits below it dies. Returns one row per slippage level with + the total cost and the resulting net expectancy. + + ``fill_mode`` is forwarded to :func:`simulate_trade`. + """ + + rows: List[Dict[str, Any]] = [] + for slip in slippage_levels: + total_cost = round_trip_cost_bps( + commission_bps_per_side=0.0, + transaction_tax_bps=float(base_cost_bps), + slippage_bps=float(slip), + ) + rows.append( + { + "slippage_bps": float(slip), + "base_cost_bps": float(base_cost_bps), + "total_cost_bps": total_cost, + "expectancy_pct": expectancy_at_cost( + instances, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=total_cost, + fill_mode=fill_mode, + ), + } + ) + return rows + + +def compute_regime_analysis( + instances: Sequence[GapUpInstance], + *, + tp_pct: float, + sl_pct: float, + cost_bps: float = REALISTIC_COST_BPS, + boundary_fractions: Sequence[float] = (0.5, 0.6, 0.7, 0.8, 0.9), + slippage_levels: Sequence[float] = SLIPPAGE_SWEEP_BPS, + fill_mode: str = "idealized", +) -> List[Dict[str, Any]]: + """Per-filter regime robustness bundle (per-year + multi-boundary + slip). + + For EACH pre-registered entry filter, computes (a) the per-calendar-year + expectancy at the realistic ``cost_bps``, (b) the OOS expectancy as the + IS/OOS boundary is swept, and (c) the net expectancy as slippage is added to + that cost. This is the decisive regime-robustness + slippage-survivability + evidence; it re-slices the SAME filtered instances, discovering no new edge. + + ``fill_mode`` is forwarded to :func:`simulate_trade` via all sub-analyses. + """ + + out: List[Dict[str, Any]] = [] + for fname, efilter in ENTRY_FILTERS.items(): + kept = filter_instances(instances, efilter) + out.append( + { + "filter": fname, + "primary_tp_pct": tp_pct, + "primary_sl_pct": sl_pct, + "cost_bps": float(cost_bps), + "n_all": len(kept), + "per_year": per_year_expectancy( + kept, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + fill_mode=fill_mode, + ), + "multi_boundary": multi_boundary_oos( + kept, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + fractions=boundary_fractions, + fill_mode=fill_mode, + ), + "slippage": slippage_sensitivity( + kept, + tp_pct=tp_pct, + sl_pct=sl_pct, + base_cost_bps=cost_bps, + slippage_levels=slippage_levels, + fill_mode=fill_mode, + ), + } + ) + return out + + +# --------------------------------------------------------------------------- +# DB-backed full backtest runner (bounded enumeration + per-instance reads). +# --------------------------------------------------------------------------- +@dataclass +class GapUpBacktestConfig: + db_path: str + max_symbols: int = 0 # 0 = all tables (full universe) + in_sample_fraction: float = 0.7 + cost_bps: float = COST_BPS_ROUND_TRIP + entry_threshold: float = ENTRY_CHANGE_RATE_THRESHOLD + tp_grid: Tuple[float, ...] = TP_GRID_PCT + sl_grid: Tuple[float, ...] = SL_GRID_PCT + # Cost-sweep + filter analysis (the prior best cell is the PRIMARY TP/SL). + cost_sweep_bps: Tuple[float, ...] = COST_SWEEP_BPS + primary_tp_pct: float = 5.0 # prior "best OOS" cell (TP5/SL1), reported only + primary_sl_pct: float = 1.0 + artifacts_dir: Optional[str] = None + # Regime-robustness analysis (per-year / multi-boundary / slippage) at the + # realistic cost; off by default so the existing cost-sweep run is unchanged. + regime_analysis: bool = False + regime_cost_bps: float = REALISTIC_COST_BPS + boundary_fractions: Tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9) + slippage_levels: Tuple[float, ...] = SLIPPAGE_SWEEP_BPS + # TP/SL fill model: "idealized" preserves original behavior (exact levels); + # "realized" uses actual crossing price; "sl_gap_stress" is the most + # pessimistic (gap-through SL + conservative TP). Default unchanged. + fill_mode: str = "idealized" + + +@dataclass +class GapUpBacktestResult: + instances: List[GapUpInstance] = field(default_factory=list) + n_symbols: int = 0 + n_sessions: int = 0 + date_min: Optional[str] = None + date_max: Optional[str] = None + boundary_date: Optional[str] = None + grid: List[Dict[str, Any]] = field(default_factory=list) + baseline: Dict[str, Any] = field(default_factory=dict) + # Cost-sweep x filter analysis (keyed by filter name "none"/"ts"/"ts_imb"). + filter_analysis: List[Dict[str, Any]] = field(default_factory=list) + # Regime-robustness analysis per filter (per-year / multi-boundary / slip). + regime_analysis: List[Dict[str, Any]] = field(default_factory=list) + + +def collect_gap_up_instances( + config: GapUpBacktestConfig, +) -> List[GapUpInstance]: + """Enumerate tables, read each table's sessions, collect gap-up instances. + + Reads are bounded: one session-prefix-indexed morning-window query per + (symbol, session). Never materialises a full table. + """ + + conn = connect_readonly(config.db_path) + instances: List[GapUpInstance] = [] + try: + tables = list_stock_tables( + conn, max_tables=config.max_symbols if config.max_symbols > 0 else None + ) + for table in tables: + columns = get_table_columns(conn, table) + ts_col = _resolve_column(columns, _TIMESTAMP_CANDIDATES) + px_col = _resolve_column(columns, _PRICE_CANDIDATES) + cr_col = _resolve_column(columns, _CHANGE_RATE_CANDIDATES) + if not ts_col or not px_col or not cr_col: + continue # table lacks the required columns; skip honestly + # Optional causal demand columns (None when absent -> filter NULLs). + ts_strength_col = _resolve_column(columns, _TRADE_STRENGTH_CANDIDATES) + sec_amount_col = _resolve_column(columns, _SEC_AMOUNT_CANDIDATES) + bid_qty_col = _resolve_column(columns, _BID_QTY_CANDIDATES) + ask_qty_col = _resolve_column(columns, _ASK_QTY_CANDIDATES) + qt = _quote_ident(table) + ts_q = _quote_ident(ts_col) + date_query = f"SELECT DISTINCT substr(CAST({ts_q} AS TEXT), 1, 8) FROM {qt}" + session_dates = [ + str(row[0]) + for row in conn.execute(date_query).fetchall() + if row[0] is not None and len(str(row[0])) == 8 and str(row[0]).isdigit() + ] + for session in sorted(session_dates): + inst = read_gap_up_instance( + conn, + table, + session, + timestamp_col=ts_col, + price_col=px_col, + change_rate_col=cr_col, + trade_strength_col=ts_strength_col, + sec_amount_col=sec_amount_col, + bid_qty_col=bid_qty_col, + ask_qty_col=ask_qty_col, + entry_threshold=config.entry_threshold, + ) + if inst is not None: + instances.append(inst) + finally: + conn.close() + return instances + + +def run_gap_up_backtest(config: GapUpBacktestConfig) -> GapUpBacktestResult: + """Run the full pre-registered gap-up backtest and return the result. + + Computes the per-TP/SL-combo metrics on BOTH the in-sample and out-of-sample + split, plus the no-TP/SL baseline on each split. No cell is selected as + "the" answer here — the caller renders the full surface. + """ + + instances = collect_gap_up_instances(config) + result = GapUpBacktestResult(instances=instances) + if not instances: + return result + + result.n_symbols = len({inst.symbol for inst in instances}) + sessions = {inst.session for inst in instances} + result.n_sessions = len({(inst.symbol, inst.session) for inst in instances}) + result.date_min = min(sessions) + result.date_max = max(sessions) + + in_sample, out_sample, boundary = split_instances_by_date( + instances, in_sample_fraction=config.in_sample_fraction + ) + result.boundary_date = boundary + + def _eval(insts: Sequence[GapUpInstance], tp: float, sl: float) -> Dict[str, Any]: + trades = [ + simulate_trade( + inst.prices, + tp_pct=tp, + sl_pct=sl, + cost_bps=config.cost_bps, + seconds=inst.seconds, + fill_mode=config.fill_mode, + ) + for inst in insts + ] + return aggregate_trades(trades) + + def _baseline(insts: Sequence[GapUpInstance]) -> Dict[str, Any]: + trades = [ + simulate_baseline(inst.prices, cost_bps=config.cost_bps, seconds=inst.seconds) + for inst in insts + ] + return aggregate_trades(trades) + + grid: List[Dict[str, Any]] = [] + for tp in config.tp_grid: + for sl in config.sl_grid: + grid.append( + { + "tp_pct": tp, + "sl_pct": sl, + "in_sample": _eval(in_sample, tp, sl), + "out_of_sample": _eval(out_sample, tp, sl), + "all": _eval(instances, tp, sl), + } + ) + result.grid = grid + result.baseline = { + "in_sample": _baseline(in_sample), + "out_of_sample": _baseline(out_sample), + "all": _baseline(instances), + } + + # ---- Cost-sweep x entry-filter analysis (PRIMARY TP/SL, OOS-honest) ---- + # For each pre-registered filter, report: surviving N (IS/OOS), the + # breakeven round-trip cost (IS & OOS), and the net expectancy at every + # swept cost level (IS & OOS). The breakeven is a PROPERTY (gross edge x + # 100), so reporting it across filters is not cherry-picking. + tp_p, sl_p = config.primary_tp_pct, config.primary_sl_pct + filter_analysis: List[Dict[str, Any]] = [] + for fname, efilter in ENTRY_FILTERS.items(): + is_f = filter_instances(in_sample, efilter) + oos_f = filter_instances(out_sample, efilter) + all_f = filter_instances(instances, efilter) + filter_analysis.append( + { + "filter": fname, + "n_in_sample": len(is_f), + "n_out_of_sample": len(oos_f), + "n_all": len(all_f), + "primary_tp_pct": tp_p, + "primary_sl_pct": sl_p, + "breakeven_bps_in_sample": breakeven_round_trip_bps( + is_f, tp_pct=tp_p, sl_pct=sl_p, fill_mode=config.fill_mode + ), + "breakeven_bps_out_of_sample": breakeven_round_trip_bps( + oos_f, tp_pct=tp_p, sl_pct=sl_p, fill_mode=config.fill_mode + ), + "sweep_in_sample": cost_sweep_table( + is_f, + tp_pct=tp_p, + sl_pct=sl_p, + cost_levels=config.cost_sweep_bps, + fill_mode=config.fill_mode, + ), + "sweep_out_of_sample": cost_sweep_table( + oos_f, + tp_pct=tp_p, + sl_pct=sl_p, + cost_levels=config.cost_sweep_bps, + fill_mode=config.fill_mode, + ), + } + ) + result.filter_analysis = filter_analysis + + # ---- Regime-robustness analysis (per-year / multi-boundary / slippage) ---- + if config.regime_analysis: + result.regime_analysis = compute_regime_analysis( + instances, + tp_pct=tp_p, + sl_pct=sl_p, + cost_bps=config.regime_cost_bps, + boundary_fractions=config.boundary_fractions, + slippage_levels=config.slippage_levels, + fill_mode=config.fill_mode, + ) + + if config.artifacts_dir: + _write_artifacts(config, result, in_sample, out_sample) + + return result + + +def _write_artifacts( + config: GapUpBacktestConfig, + result: GapUpBacktestResult, + in_sample: Sequence[GapUpInstance], + out_sample: Sequence[GapUpInstance], +) -> None: + """Write per-instance trades (gitignored) for audit.""" + + out_dir = Path(config.artifacts_dir) + out_dir.mkdir(parents=True, exist_ok=True) + split_label = {id(inst): "in_sample" for inst in in_sample} + split_label.update({id(inst): "out_of_sample" for inst in out_sample}) + + rows: List[Dict[str, Any]] = [] + for inst in result.instances: + rec: Dict[str, Any] = { + "symbol": inst.symbol, + "session": inst.session, + "split": split_label.get(id(inst), "unknown"), + "entry_change_rate": inst.entry_change_rate, + "entry_price": inst.entry_price, + "entry_trade_strength": inst.entry_trade_strength, + "entry_sec_amount": inst.entry_sec_amount, + "entry_bid_ask_imbalance": ( + round(inst.entry_bid_ask_imbalance, 6) + if inst.entry_bid_ask_imbalance is not None + else None + ), + "pass_ts": passes_entry_filter(inst, ENTRY_FILTERS["ts"]), + "pass_ts_imb": passes_entry_filter(inst, ENTRY_FILTERS["ts_imb"]), + "n_path_bars": len(inst.prices), + } + for tp in config.tp_grid: + for sl in config.sl_grid: + tr = simulate_trade( + inst.prices, + tp_pct=tp, + sl_pct=sl, + cost_bps=config.cost_bps, + seconds=inst.seconds, + fill_mode=config.fill_mode, + ) + key = f"tp{tp:g}_sl{sl:g}" + rec[f"{key}_reason"] = tr.exit_reason + rec[f"{key}_net_pct"] = round(tr.net_return_pct, 6) + base = simulate_baseline(inst.prices, cost_bps=config.cost_bps, seconds=inst.seconds) + rec["baseline_net_pct"] = round(base.net_return_pct, 6) + rec["baseline_hold_seconds"] = base.hold_seconds + rows.append(rec) + + (out_dir / "instances.json").write_text( + json.dumps(rows, ensure_ascii=False, indent=2), encoding="utf-8" + ) + summary = { + "n_instances": len(result.instances), + "n_symbols": result.n_symbols, + "n_sessions": result.n_sessions, + "date_min": result.date_min, + "date_max": result.date_max, + "boundary_date": result.boundary_date, + "cost_bps": config.cost_bps, + "fill_mode": config.fill_mode, + "entry_threshold": config.entry_threshold, + "tp_grid": list(config.tp_grid), + "sl_grid": list(config.sl_grid), + "cost_sweep_bps": list(config.cost_sweep_bps), + "primary_tp_pct": config.primary_tp_pct, + "primary_sl_pct": config.primary_sl_pct, + "grid": result.grid, + "baseline": result.baseline, + "filter_analysis": result.filter_analysis, + "regime_analysis": result.regime_analysis, + } + (out_dir / "summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8" + ) + # Regime-robustness artifact (written only when the analysis was requested). + if result.regime_analysis: + (out_dir / "regime_analysis.json").write_text( + json.dumps( + { + "n_instances": len(result.instances), + "n_symbols": result.n_symbols, + "date_min": result.date_min, + "date_max": result.date_max, + "regime_cost_bps": config.regime_cost_bps, + "fill_mode": config.fill_mode, + "primary_tp_pct": config.primary_tp_pct, + "primary_sl_pct": config.primary_sl_pct, + "boundary_fractions": list(config.boundary_fractions), + "slippage_levels": list(config.slippage_levels), + "regime_analysis": result.regime_analysis, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- +def _format_metrics(m: Dict[str, Any]) -> str: + if not m or m.get("n_trades", 0) == 0: + return "n=0" + return ( + f"n={m['n_trades']} win={m['win_rate']:.1%} " + f"mean_net={m['mean_net_return_pct']:+.3f}% " + f"exp={m['expectancy_pct']:+.3f}% " + f"hold={m['avg_hold_seconds']:.0f}s " + f"tp/sl/time={m['exit_mix'][EXIT_TP]:.0%}/{m['exit_mix'][EXIT_SL]:.0%}/{m['exit_mix'][EXIT_TIME]:.0%}" + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Opening gap-up momentum backtest (시초 갭상승) on the 1s STOM DB." + ) + parser.add_argument( + "--db", + default=str(PROJECT_ROOT / "_database" / "stock_tick_back.db"), + help="Path to stock_tick_back.db.", + ) + parser.add_argument( + "--max-symbols", + type=int, + default=0, + help="Bound the number of symbol tables scanned (0 = full universe).", + ) + parser.add_argument( + "--in-sample-fraction", + type=float, + default=0.7, + help="Fraction of distinct session dates used as in-sample (earlier).", + ) + parser.add_argument("--cost-bps", type=float, default=COST_BPS_ROUND_TRIP) + parser.add_argument( + "--artifacts-dir", + default=str(PROJECT_ROOT / ".omx" / "artifacts" / "gap_up_backtest"), + help="Where to write per-instance trade artifacts (gitignored).", + ) + parser.add_argument( + "--json-out", + default=None, + help="Optional path to write the full result summary as JSON.", + ) + parser.add_argument( + "--regime-analysis", + action="store_true", + help=( + "Run the per-year / multi-boundary / slippage regime-robustness " + "analysis at the realistic cost (--regime-cost-bps)." + ), + ) + parser.add_argument( + "--regime-cost-bps", + type=float, + default=REALISTIC_COST_BPS, + help="Realistic round-trip cost the regime analysis evaluates at (bp).", + ) + parser.add_argument( + "--fill-mode", + choices=["idealized", "realized", "sl_gap_stress"], + default="idealized", + help=( + "TP/SL fill model: idealized (exact levels, default), realized " + "(actual crossing price), sl_gap_stress (TP conservative + SL " + "gap-through worst-case)." + ), + ) + args = parser.parse_args(argv) + + config = GapUpBacktestConfig( + db_path=args.db, + max_symbols=args.max_symbols, + in_sample_fraction=args.in_sample_fraction, + cost_bps=args.cost_bps, + artifacts_dir=args.artifacts_dir, + regime_analysis=args.regime_analysis, + regime_cost_bps=args.regime_cost_bps, + fill_mode=args.fill_mode, + ) + result = run_gap_up_backtest(config) + + print("=== 시초 갭상승 (opening gap-up) backtest ===") + print( + f"instances={len(result.instances)} symbols={result.n_symbols} " + f"sessions={result.n_sessions} dates={result.date_min}->{result.date_max} " + f"boundary={result.boundary_date}" + ) + print(f"entry: 등락율>={config.entry_threshold}% time-exit={TIME_EXIT_HHMMSS} cost={config.cost_bps}bp") + print("-- baseline (buy@open hold->09:25 no TP/SL) --") + print(f" in-sample : {_format_metrics(result.baseline.get('in_sample', {}))}") + print(f" out-sample: {_format_metrics(result.baseline.get('out_of_sample', {}))}") + print("-- TP/SL grid (in-sample | out-of-sample) --") + for cell in result.grid: + print( + f" TP={cell['tp_pct']:g}% SL={cell['sl_pct']:g}% " + f"IS[{_format_metrics(cell['in_sample'])}] " + f"OOS[{_format_metrics(cell['out_of_sample'])}]" + ) + + # Cost-sweep x filter (PRIMARY TP/SL) + breakeven round-trip cost. + if result.filter_analysis: + tp_p, sl_p = config.primary_tp_pct, config.primary_sl_pct + levels = list(config.cost_sweep_bps) + print( + f"-- cost sweep x entry filter (PRIMARY TP={tp_p:g}%/SL={sl_p:g}%) --" + ) + print( + " ref scenarios: Korean-domestic=" + f"{KOREAN_DOMESTIC_COST.total_round_trip_bps():g}bp " + f"(comm {KOREAN_DOMESTIC_COST.commission_bps_per_side:g}bp/side x2 " + f"+ tax {KOREAN_DOMESTIC_COST.transaction_tax_bps:g}bp sell-only), " + "international/low=" + f"{INTERNATIONAL_LOW_COST.total_round_trip_bps():g}bp " + f"(comm {INTERNATIONAL_LOW_COST.commission_bps_per_side:g}bp/side x2, no tax)" + ) + header = " filter N(IS/OOS) breakeven(IS/OOS)bp " + " ".join( + f"OOS@{int(c)}bp" for c in levels + ) + print(header) + for fa in result.filter_analysis: + sweep = {row["cost_bps"]: row["expectancy_pct"] for row in fa["sweep_out_of_sample"]} + + def _fmt_exp(v: Optional[float]) -> str: + return " n=0 " if v is None else f"{v:+.3f}" + + def _fmt_be(v: Optional[float]) -> str: + return "n/a" if v is None else f"{v:+.1f}" + + cells = " ".join(f"{_fmt_exp(sweep.get(c)):>8}" for c in levels) + print( + f" {fa['filter']:<8} {fa['n_in_sample']}/{fa['n_out_of_sample']:<6} " + f"{_fmt_be(fa['breakeven_bps_in_sample'])}/" + f"{_fmt_be(fa['breakeven_bps_out_of_sample'])} {cells}" + ) + + # Regime-robustness analysis (per-year / multi-boundary / slippage). + if result.regime_analysis: + + def _fmt(v: Optional[float]) -> str: + return " n=0 " if v is None else f"{v:+.3f}" + + def _fmt_rate(v: Optional[float]) -> str: + return " n=0" if v is None else f"{v:4.0%}" + + rc = config.regime_cost_bps + tp_p, sl_p = config.primary_tp_pct, config.primary_sl_pct + print( + f"-- regime robustness (PRIMARY TP={tp_p:g}%/SL={sl_p:g}% @ {rc:g}bp) --" + ) + for ra in result.regime_analysis: + years = [r["year"] for r in ra["per_year"]] + print(f" [{ra['filter']}] N={ra['n_all']} per-year expectancy@{rc:g}bp:") + print( + " year : " + + " ".join(f"{y:>7}" for y in years) + ) + print( + " N : " + + " ".join(f"{r['n_trades']:>7}" for r in ra["per_year"]) + ) + print( + " net% : " + + " ".join(f"{_fmt(r['mean_net_return_pct']):>7}" for r in ra["per_year"]) + ) + print( + " win : " + + " ".join(f"{_fmt_rate(r['win_rate']):>7}" for r in ra["per_year"]) + ) + print(" multi-boundary OOS expectancy:") + for mb in ra["multi_boundary"]: + print( + f" frac={mb['in_sample_fraction']:.2f} " + f"bdy={mb['boundary_date']} " + f"N(IS/OOS)={mb['n_in_sample']}/{mb['n_out_of_sample']} " + f"IS={_fmt(mb['expectancy_in_sample'])} " + f"OOS={_fmt(mb['expectancy_out_of_sample'])}" + ) + print(" slippage sensitivity (net@cost+slip):") + for sl in ra["slippage"]: + print( + f" slip={sl['slippage_bps']:g}bp " + f"total={sl['total_cost_bps']:g}bp " + f"net={_fmt(sl['expectancy_pct'])}" + ) + + if args.json_out: + out_path = Path(args.json_out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text( + json.dumps( + { + "n_instances": len(result.instances), + "n_symbols": result.n_symbols, + "n_sessions": result.n_sessions, + "date_min": result.date_min, + "date_max": result.date_max, + "boundary_date": result.boundary_date, + "cost_bps": config.cost_bps, + "entry_threshold": config.entry_threshold, + "tp_grid": list(config.tp_grid), + "sl_grid": list(config.sl_grid), + "cost_sweep_bps": list(config.cost_sweep_bps), + "primary_tp_pct": config.primary_tp_pct, + "primary_sl_pct": config.primary_sl_pct, + "grid": result.grid, + "baseline": result.baseline, + "filter_analysis": result.filter_analysis, + "regime_analysis": result.regime_analysis, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + print(f"wrote summary -> {out_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/gap_up_dashboard_publish.py b/stom_rl/gap_up_dashboard_publish.py new file mode 100644 index 000000000..43a19030a --- /dev/null +++ b/stom_rl/gap_up_dashboard_publish.py @@ -0,0 +1,486 @@ +"""Publish the '시초 갭상승' (opening gap-up) RULE strategy backtest as a +read-only dashboard run for the STOM RL dashboard follow/replay view. + +This is a RULE strategy backtest — it is NOT a reinforcement-learning policy. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# Re-use existing RL-event helpers (no duplication) +# --------------------------------------------------------------------------- +from stom_rl.rl_events import ( + RlLiveEvent, + RlLiveEventWriter, + summarize_live_events, +) + +# --------------------------------------------------------------------------- +# Defaults +# --------------------------------------------------------------------------- +_DEFAULT_INSTANCES = Path(".omx/artifacts/gap_up_backtest/instances.json") +_DEFAULT_OUTPUT_ROOT = Path("webui/rl_runs") +_DEFAULT_COST_BPS = 23.0 +_DEFAULT_TP_SL_KEY = "tp5_sl1" +_DEFAULT_INITIAL_CASH = 1_000_000.0 +_VALID_FILTERS = ("none", "ts", "ts_imb") + + +# --------------------------------------------------------------------------- +# Pure data helpers (small, immutable, no side effects) +# --------------------------------------------------------------------------- + +def _load_instances(path: Path) -> List[Dict[str, Any]]: + """Load instances.json with BOM-tolerant UTF-8 decoding.""" + if not path.is_file(): + raise FileNotFoundError( + f"instances.json not found at {path!r}. " + "Run the gap_up_backtest first to generate it." + ) + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def _apply_filter( + records: List[Dict[str, Any]], + filter_name: str, +) -> List[Dict[str, Any]]: + """Return the subset of records that pass *filter_name*.""" + if filter_name == "none": + return list(records) + if filter_name == "ts": + return [r for r in records if r.get("pass_ts")] + if filter_name == "ts_imb": + return [r for r in records if r.get("pass_ts_imb")] + raise ValueError( + f"Invalid filter {filter_name!r}. Choose from: {_VALID_FILTERS}" + ) + + +def _sort_records(records: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Sort deterministically by (session, symbol) ascending.""" + return sorted(records, key=lambda r: (str(r.get("session", "")), str(r.get("symbol", "")))) + + +def _net_at_cost( + raw_net_pct: float, + cost_bps: float, + base_cost_bps: float = 25.0, +) -> float: + """Adjust a cached net_pct (computed at base_cost_bps) to a target cost_bps.""" + return raw_net_pct + (base_cost_bps - cost_bps) / 100.0 + + +def _build_equity_curve( + records: List[Dict[str, Any]], + *, + tp_sl_key: str, + cost_bps: float, + initial_cash: float, +) -> Tuple[List[float], List[float], float]: + """Build a non-compounded fixed-notional equity curve. + + Returns (net_per_trade, nav_series, final_cum_pct). + """ + net_series: List[float] = [] + nav_series: List[float] = [] + cum_pct = 0.0 + for rec in records: + raw = float(rec[f"{tp_sl_key}_net_pct"]) + net_i = _net_at_cost(raw, cost_bps) + cum_pct += net_i + nav_i = initial_cash * (1.0 + cum_pct / 100.0) + net_series.append(net_i) + nav_series.append(nav_i) + return net_series, nav_series, cum_pct + + +def _compute_stats( + net_series: List[float], + nav_series: List[float], + *, + initial_cash: float, +) -> Dict[str, Any]: + """Derive summary statistics from equity-curve vectors.""" + n = len(net_series) + if n == 0: + return { + "total_net_pct": 0.0, + "expectancy_pct": 0.0, + "win_rate": 0.0, + "max_drawdown_pct": 0.0, + "max_losing_streak": 0, + "final_nav": initial_cash, + } + + total_net_pct = sum(net_series) + expectancy_pct = total_net_pct / n + win_rate = sum(1 for x in net_series if x > 0) / n + + # Max drawdown on cumulative-pct series + cum_series = [sum(net_series[: i + 1]) for i in range(n)] + running_peak = cum_series[0] + max_dd = 0.0 + for cp in cum_series: + if cp > running_peak: + running_peak = cp + dd = cp - running_peak + if dd < max_dd: + max_dd = dd + + # Max consecutive losing streak + streak = 0 + max_streak = 0 + for x in net_series: + if x <= 0: + streak += 1 + max_streak = max(max_streak, streak) + else: + streak = 0 + + return { + "total_net_pct": total_net_pct, + "expectancy_pct": expectancy_pct, + "win_rate": win_rate, + "max_drawdown_pct": max_dd, + "max_losing_streak": max_streak, + "final_nav": nav_series[-1], + } + + +def _make_iso_timestamp(session: Any) -> str: + """Convert a session string like '20230906' to an ISO timestamp.""" + s = str(session) + if len(s) == 8 and s.isdigit(): + return f"{s[:4]}-{s[4:6]}-{s[6:8]}T09:25:00" + return f"{s}T09:25:00" + + +def _build_event( + *, + run_name: str, + filter_name: str, + tp_sl_key: str, + rec: Dict[str, Any], + trade_idx: int, + net_i: float, + nav_i: float, + cum_pct: float, +) -> RlLiveEvent: + """Construct a single RlLiveEvent for one backtest trade.""" + session = rec.get("session", "") + symbol = str(rec.get("symbol", "")) + return RlLiveEvent( + run_id=run_name, + algorithm=f"rule:gap_up_{filter_name}", + phase="backtest", + global_step=trade_idx, + action=1, + reward=net_i / 100.0, + timestamp=_make_iso_timestamp(session), + price=float(rec.get("entry_price", 0.0) or 0.0), + position=1.0, + equity=nav_i, + source="gap_up_backtest", + info={ + "session": session, + "symbol": symbol, + "net_pct": net_i, + "cum_net_pct": cum_pct, + "exit_reason": rec.get(f"{tp_sl_key}_reason"), + "filter": filter_name, + "entry_change_rate": rec.get("entry_change_rate"), + "trade_strength": rec.get("entry_trade_strength"), + "bid_ask_imbalance": rec.get("entry_bid_ask_imbalance"), + "nav": nav_i, + "sizing": "fixed_notional_non_compounded", + "note": "RULE strategy backtest, not an RL policy", + }, + ) + + +def _write_portfolio_paper_summary( + output_dir: Path, + *, + run_name: str, + filter_name: str, + tp_sl_key: str, + cost_bps: float, + initial_cash: float, + n: int, + stats: Dict[str, Any], +) -> None: + """Write portfolio_paper_summary.json — the dashboard detection anchor.""" + payload = { + "run_id": run_name, + "artifact_type": "portfolio_paper", + "summary": { + "strategy": ( + "시초 갭상승 (opening gap-up) RULE strategy — NOT reinforcement learning" + ), + "steps": n, + "trade_count": n, + "live_event_count": n, + "final_nav": stats["final_nav"], + "initial_cash": initial_cash, + "total_net_pct": stats["total_net_pct"], + "expectancy_pct": stats["expectancy_pct"], + "win_rate": stats["win_rate"], + "max_drawdown_pct": stats["max_drawdown_pct"], + "max_losing_streak": stats["max_losing_streak"], + "cost_bps": cost_bps, + "filter": filter_name, + "tp_sl": "TP5%/SL1%/09:25", + }, + "config": { + "source": "stom_rl/gap_up_backtest.py instances.json", + "filter": filter_name, + "cost_bps": cost_bps, + "tp_sl_key": tp_sl_key, + "time_exit": "09:25", + "initial_cash": initial_cash, + "sizing": "fixed_notional_non_compounded", + "is_reinforcement_learning": False, + "note": ( + "RULE strategy (등락율>=2% + filter, fixed TP/SL); " + "RL portfolio selection was separately proven to have NO intraday alpha." + ), + }, + "walk_forward_summary": {}, + } + (output_dir / "portfolio_paper_summary.json").write_text( + json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8-sig", + ) + + +def _write_live_summary( + output_dir: Path, + events: List[Dict[str, Any]], +) -> None: + """Write rl_live_summary.json from already-serialised event dicts.""" + summary = summarize_live_events(events) + summary["truncated"] = False + (output_dir / "rl_live_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), + encoding="utf-8-sig", + ) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def publish_gap_up_run( + *, + instances_path: Optional[Path] = None, + filter_name: str = "ts_imb", + cost_bps: float = _DEFAULT_COST_BPS, + tp_sl_key: str = _DEFAULT_TP_SL_KEY, + run_name: Optional[str] = None, + output_root: Optional[Path] = None, + initial_cash: float = _DEFAULT_INITIAL_CASH, +) -> Dict[str, Any]: + """Publish the gap-up backtest as a dashboard run directory. + + Parameters + ---------- + instances_path: + Path to instances.json. Defaults to the canonical artifact path. + filter_name: + One of ``"none"``, ``"ts"``, ``"ts_imb"``. + cost_bps: + Round-trip cost in basis points (default 23.0 — domestic broker). + tp_sl_key: + Key prefix in instances.json, e.g. ``"tp5_sl1"``. + run_name: + Output directory name under *output_root*. Defaults to + ``"gap_up__equity"``. + output_root: + Root directory for RL runs. Defaults to ``webui/rl_runs``. + initial_cash: + Starting capital for NAV calculations. + + Returns + ------- + dict + The portfolio_paper_summary payload (same dict written to disk). + """ + if filter_name not in _VALID_FILTERS: + raise ValueError( + f"Invalid filter {filter_name!r}. Choose from: {_VALID_FILTERS}" + ) + + resolved_instances = Path(instances_path) if instances_path is not None else _DEFAULT_INSTANCES + resolved_root = Path(output_root) if output_root is not None else _DEFAULT_OUTPUT_ROOT + resolved_run_name = run_name or f"gap_up_{filter_name}_equity" + + raw_records = _load_instances(resolved_instances) + filtered = _apply_filter(raw_records, filter_name) + sorted_records = _sort_records(filtered) + + net_series, nav_series, _final_cum = _build_equity_curve( + sorted_records, + tp_sl_key=tp_sl_key, + cost_bps=cost_bps, + initial_cash=initial_cash, + ) + + stats = _compute_stats(net_series, nav_series, initial_cash=initial_cash) + n = len(sorted_records) + + output_dir = resolved_root / resolved_run_name + output_dir.mkdir(parents=True, exist_ok=True) + + # Write JSONL events + event_path = output_dir / "rl_live_events.jsonl" + writer = RlLiveEventWriter(event_path, run_id=resolved_run_name) + writer.reset() + + serialised_events: List[Dict[str, Any]] = [] + cum_pct = 0.0 + for idx, (rec, net_i, nav_i) in enumerate(zip(sorted_records, net_series, nav_series), start=1): + cum_pct += net_i + event = _build_event( + run_name=resolved_run_name, + filter_name=filter_name, + tp_sl_key=tp_sl_key, + rec=rec, + trade_idx=idx, + net_i=net_i, + nav_i=nav_i, + cum_pct=cum_pct, + ) + writer.write(event) + serialised_events.append(event.to_dict()) + + _write_live_summary(output_dir, serialised_events) + _write_portfolio_paper_summary( + output_dir, + run_name=resolved_run_name, + filter_name=filter_name, + tp_sl_key=tp_sl_key, + cost_bps=cost_bps, + initial_cash=initial_cash, + n=n, + stats=stats, + ) + + return { + "run_id": resolved_run_name, + "artifact_type": "portfolio_paper", + "summary": { + "strategy": ( + "시초 갭상승 (opening gap-up) RULE strategy — NOT reinforcement learning" + ), + "steps": n, + "trade_count": n, + "live_event_count": n, + "final_nav": stats["final_nav"], + "initial_cash": initial_cash, + "total_net_pct": stats["total_net_pct"], + "expectancy_pct": stats["expectancy_pct"], + "win_rate": stats["win_rate"], + "max_drawdown_pct": stats["max_drawdown_pct"], + "max_losing_streak": stats["max_losing_streak"], + "cost_bps": cost_bps, + "filter": filter_name, + "tp_sl": "TP5%/SL1%/09:25", + }, + "config": { + "source": "stom_rl/gap_up_backtest.py instances.json", + "filter": filter_name, + "cost_bps": cost_bps, + "tp_sl_key": tp_sl_key, + "time_exit": "09:25", + "initial_cash": initial_cash, + "sizing": "fixed_notional_non_compounded", + "is_reinforcement_learning": False, + "note": ( + "RULE strategy (등락율>=2% + filter, fixed TP/SL); " + "RL portfolio selection was separately proven to have NO intraday alpha." + ), + }, + "walk_forward_summary": {}, + } + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main(argv: Optional[List[str]] = None) -> int: + parser = argparse.ArgumentParser( + description="Publish opening gap-up RULE backtest as a dashboard run." + ) + parser.add_argument( + "--instances", + default=str(_DEFAULT_INSTANCES), + help="Path to instances.json (default: %(default)s)", + ) + parser.add_argument( + "--filter", + dest="filter_name", + choices=list(_VALID_FILTERS), + default="ts_imb", + help="Entry filter (default: %(default)s)", + ) + parser.add_argument( + "--cost-bps", + type=float, + default=_DEFAULT_COST_BPS, + help="Round-trip cost in bps (default: %(default)s)", + ) + parser.add_argument( + "--tp-sl-key", + default=_DEFAULT_TP_SL_KEY, + help="TP/SL key prefix in instances.json (default: %(default)s)", + ) + parser.add_argument( + "--run-name", + default=None, + help="Output run directory name (default: gap_up__equity)", + ) + parser.add_argument( + "--output-root", + default=str(_DEFAULT_OUTPUT_ROOT), + help="Root directory for RL runs (default: %(default)s)", + ) + parser.add_argument( + "--initial-cash", + type=float, + default=_DEFAULT_INITIAL_CASH, + help="Starting capital for NAV (default: %(default)s)", + ) + + args = parser.parse_args(argv) + + result = publish_gap_up_run( + instances_path=Path(args.instances), + filter_name=args.filter_name, + cost_bps=args.cost_bps, + tp_sl_key=args.tp_sl_key, + run_name=args.run_name, + output_root=Path(args.output_root), + initial_cash=args.initial_cash, + ) + + encoded = json.dumps(result, ensure_ascii=False, indent=2) + try: + sys.stdout.reconfigure(encoding="utf-8") # Python 3.7+, no-op if already utf-8 + except (AttributeError, ValueError): + pass + try: + print(encoded) + except UnicodeEncodeError: + sys.stdout.buffer.write(encoded.encode("utf-8") + b"\n") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/stom_rl/gap_up_risk_sizing.py b/stom_rl/gap_up_risk_sizing.py new file mode 100644 index 000000000..005569ad7 --- /dev/null +++ b/stom_rl/gap_up_risk_sizing.py @@ -0,0 +1,426 @@ +"""Position sizing & risk control for the 시초 갭상승 (opening gap-up) strategy. + +**RULE strategy, NOT reinforcement learning.** Every number this module +produces describes the user's pre-registered gap-up *rule* (등락율>=2% entry + +``ts_imb`` demand filter + TP5/SL1 + 09:25 time-exit), never an RL/PPO/DQN +policy. See ``docs/stom_rl_gap_up_risk_sizing_2026-05-29.md`` (Page A) and the +resume anchor ``docs/stom_rl_resume_commit_2026-05-29.md``. + +Design (locked, 2026-05-29) +--------------------------- +The verified edge (bounded universe, N=235, ``ts_imb`` filter, 23 bp real cost) +is:: + + expectancy/trade +0.952% idealized / +0.811% sl_gap_stress + win rate ~42% + max loss streak 9 + strategy MDD -15.7% idealized / ~-20% stress (full-notional curve) + +Sizing is governed by **drawdown tolerance and concurrency, NOT Kelly** — the +tight -1% SL caps a single loss at ~1.23% of notional, which makes full Kelly +degenerate (~22x account; see :func:`full_kelly_fraction`). The user-chosen +policy is ``f=10%`` per entry, ``K=3`` concurrent, daily ``-3%`` halt. + +All functions here are PURE (no I/O, no DB, no clock); they are unit-tested on +known values in ``tests/test_stom_rl_gap_up_risk_sizing.py``. Monetary inputs +are Korean won (``_won`` suffix); ratios are percent (``_pct``) or R-multiples +(``_r``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Tuple + +# --------------------------------------------------------------------------- +# Verified strategy constants (ts_imb filter, 23 bp real round-trip cost). +# These mirror the locked numbers in the Page A doc; they are descriptive +# defaults, not tuned parameters. +# --------------------------------------------------------------------------- +PRIMARY_FILTER: str = "ts_imb" # 체결강도>=100 AND 호가 imbalance>=0.5 +DEFAULT_TP_PCT: float = 5.0 +DEFAULT_SL_PCT: float = 1.0 +DEFAULT_COST_BPS: float = 23.0 # 0.015%x2 commission + 0.20% sell tax = 23 bp +IDEALIZED_EXPECTANCY_PCT: float = 0.952 # net %/trade @23bp, idealized fills +STRESS_EXPECTANCY_PCT: float = 0.811 # net %/trade @23bp, sl_gap_stress fills +WIN_RATE: float = 0.42 +MAX_LOSS_STREAK: int = 9 +STRATEGY_MDD_IDEALIZED_PCT: float = -15.7 # full-notional per-trade-% curve +STRATEGY_MDD_STRESS_PCT: float = -20.0 + +# Consecutive-loss de-risking tiers as ``(min_streak, fraction_scale)`` sorted +# ascending by streak. The LAST tier whose ``min_streak <= current streak`` +# applies; below the first tier the scale is 1.0 (full size). A 0.0 scale means +# "halt new entries". Calibrated to the observed max loss streak of 9: size is +# cut at 3 and 5, and entries halt at 7 — before the historical worst is reached. +DEFAULT_CONSECUTIVE_LOSS_TIERS: Tuple[Tuple[int, float], ...] = ( + (3, 0.5), + (5, 0.25), + (7, 0.0), +) + + +# --------------------------------------------------------------------------- +# Risk policy (immutable). Dynamic state (account, streak, month P&L) is passed +# to the functions as arguments — the config holds only the fixed policy. +# --------------------------------------------------------------------------- +@dataclass(frozen=True) +class RiskConfig: + """Immutable risk-sizing policy for the gap-up RULE strategy. + + ``per_trade_fraction`` (f) is the notional fraction of the account deployed + on ONE entry; ``max_concurrent`` (K) caps simultaneous positions, so the + peak deployed capital is ``f*K`` of the account. ``daily_loss_limit_pct`` + halts new entries once the day's realized loss reaches that percent of the + account. ``max_participation`` caps a single order at that multiple of the + entry bar's 초당거래대금 (per-second traded value) — a coarse Page-A liquidity + guard refined in Page C. ``monthly_derisk_*`` cut size in a losing month + (the 2022-style soft-regime absorber). + """ + + per_trade_fraction: float = 0.10 # f + max_concurrent: int = 3 # K + daily_loss_limit_pct: float = 3.0 # halt the day at -3% of account + tp_pct: float = DEFAULT_TP_PCT + sl_pct: float = DEFAULT_SL_PCT + cost_bps: float = DEFAULT_COST_BPS + max_participation: float = 1.0 # notional cap = this x entry 초당거래대금 + idealized_expectancy_pct: float = IDEALIZED_EXPECTANCY_PCT + stress_expectancy_pct: float = STRESS_EXPECTANCY_PCT + monthly_derisk_threshold_pct: float = -1.5 # month <= -1.5% -> de-risk + monthly_derisk_scale: float = 0.5 + consecutive_loss_tiers: Tuple[Tuple[int, float], ...] = ( + DEFAULT_CONSECUTIVE_LOSS_TIERS # immutable tuple -> safe as a default + ) + + def __post_init__(self) -> None: + if not (0.0 < self.per_trade_fraction <= 1.0): + raise ValueError("per_trade_fraction must be in (0, 1]") + if self.max_concurrent < 1: + raise ValueError("max_concurrent must be >= 1") + if self.daily_loss_limit_pct <= 0.0: + raise ValueError("daily_loss_limit_pct must be > 0") + if self.tp_pct <= 0.0 or self.sl_pct <= 0.0: + raise ValueError("tp_pct and sl_pct must be > 0") + if self.cost_bps < 0.0: + raise ValueError("cost_bps must be >= 0") + if self.max_participation <= 0.0: + raise ValueError("max_participation must be > 0") + if not (0.0 <= self.monthly_derisk_scale <= 1.0): + raise ValueError("monthly_derisk_scale must be in [0, 1]") + _validate_tiers(self.consecutive_loss_tiers) + + +def _validate_tiers(tiers: Tuple[Tuple[int, float], ...]) -> None: + """Validate de-risk tiers: ascending streaks, scales in [0, 1].""" + + prev_streak: Optional[int] = None + for entry in tiers: + if len(entry) != 2: + raise ValueError("each tier must be (min_streak, scale)") + streak, scale = entry + if int(streak) < 1: + raise ValueError("tier min_streak must be >= 1") + if not (0.0 <= float(scale) <= 1.0): + raise ValueError("tier scale must be in [0, 1]") + if prev_streak is not None and int(streak) <= prev_streak: + raise ValueError("tiers must be sorted by ascending min_streak") + prev_streak = int(streak) + + +# --------------------------------------------------------------------------- +# Per-trade sizing. +# --------------------------------------------------------------------------- +def position_notional_won( + account_won: float, + config: RiskConfig, + *, + fraction: Optional[float] = None, + entry_liquidity_won: Optional[float] = None, +) -> float: + """Notional (원) to deploy on one entry. + + Uses ``fraction`` when supplied (e.g. the de-risked :func:`effective_fraction`) + otherwise ``config.per_trade_fraction``. When ``entry_liquidity_won`` (the + entry bar's 초당거래대금) is supplied, the order is capped at + ``config.max_participation * entry_liquidity_won`` so a thin name auto-shrinks + the order (the Page-A liquidity guard). + """ + + if account_won < 0: + raise ValueError("account_won must be >= 0") + f = config.per_trade_fraction if fraction is None else float(fraction) + if not (0.0 <= f <= 1.0): + raise ValueError("fraction must be in [0, 1]") + base = f * float(account_won) + if entry_liquidity_won is not None: + if entry_liquidity_won < 0: + raise ValueError("entry_liquidity_won must be >= 0") + cap = config.max_participation * float(entry_liquidity_won) + return min(base, cap) + return base + + +def risk_per_trade_won(notional_won: float, config: RiskConfig) -> float: + """1R in 원 — the nominal stop-loss loss on a single position. + + A stop-out books ``SL% + round-trip cost`` against the notional:: + + R = notional * (sl_pct/100 + cost_bps/10000) + + At SL=1% and cost=23 bp this is ``notional * 0.0123``. This is the NOMINAL + stop loss; a gap-through (sl_gap_stress) can exceed it — that tail is modelled + in Page C, not here. + """ + + if notional_won < 0: + raise ValueError("notional_won must be >= 0") + loss_fraction = config.sl_pct / 100.0 + config.cost_bps / 10000.0 + return float(notional_won) * loss_fraction + + +def risk_unit_account_pct(config: RiskConfig) -> float: + """1R as a percent of the account = ``f * (sl_pct + cost_bps/100)``. + + For f=10%, SL=1%, cost=23 bp this is ``0.123%`` of the account. + """ + + loss_fraction = config.sl_pct / 100.0 + config.cost_bps / 10000.0 + return config.per_trade_fraction * loss_fraction * 100.0 + + +# --------------------------------------------------------------------------- +# Daily loss limit. +# --------------------------------------------------------------------------- +def daily_loss_limit_won(account_won: float, config: RiskConfig) -> float: + """Daily loss limit in 원 (a POSITIVE magnitude) = ``account * limit%``.""" + + if account_won < 0: + raise ValueError("account_won must be >= 0") + return float(account_won) * config.daily_loss_limit_pct / 100.0 + + +def should_halt_day( + realized_pnl_today_won: float, + account_won: float, + config: RiskConfig, +) -> bool: + """True when today's realized P&L has reached the daily loss limit. + + ``realized_pnl_today_won`` is signed (NEGATIVE for a losing day). The day + halts when the loss magnitude meets or exceeds :func:`daily_loss_limit_won`, + i.e. ``realized_pnl_today_won <= -limit``. + """ + + limit = daily_loss_limit_won(account_won, config) + return float(realized_pnl_today_won) <= -limit + + +def daily_limit_in_r(config: RiskConfig) -> float: + """Daily loss limit expressed in R multiples = ``limit% / R%``. + + For the default policy this is ``3% / 0.123% ~= 24.4R`` — loose given K=3 and + intraday resolution, so the daily halt is a catastrophe backstop, not the + primary control. + """ + + r_pct = risk_unit_account_pct(config) + if r_pct <= 0.0: + raise ValueError("risk unit percent must be > 0") + return config.daily_loss_limit_pct / r_pct + + +# --------------------------------------------------------------------------- +# Concurrency exposure. +# --------------------------------------------------------------------------- +def max_concurrent_exposure_won(account_won: float, config: RiskConfig) -> float: + """Peak simultaneous deployed capital = ``f * K * account``.""" + + if account_won < 0: + raise ValueError("account_won must be >= 0") + return config.per_trade_fraction * config.max_concurrent * float(account_won) + + +def max_concurrent_exposure_pct(config: RiskConfig) -> float: + """Peak simultaneous exposure as a percent of the account = ``f * K * 100``.""" + + return config.per_trade_fraction * config.max_concurrent * 100.0 + + +def worst_case_concurrent_loss_won( + account_won: float, + config: RiskConfig, + *, + entry_liquidity_won: Optional[float] = None, +) -> float: + """Loss (POSITIVE magnitude) if all K concurrent positions hit the SL. + + ``K * R`` at the base per-trade notional. At f=10%, K=3 this is + ``3 * 0.123% = 0.369%`` of the account. NOTE: this is the NOMINAL stop + (SL level + cost); a gap-through (sl_gap_stress) can book a worse fill and + exceed it — that tail is modelled in Page C, not here, so this is a + lower bound on the true worst case, not an absolute ceiling. + """ + + notional = position_notional_won( + account_won, config, entry_liquidity_won=entry_liquidity_won + ) + return config.max_concurrent * risk_per_trade_won(notional, config) + + +# --------------------------------------------------------------------------- +# De-risking: consecutive-loss tiers + monthly soft-regime absorber. +# --------------------------------------------------------------------------- +def consecutive_loss_scale( + streak: int, + tiers: Tuple[Tuple[int, float], ...] = DEFAULT_CONSECUTIVE_LOSS_TIERS, +) -> float: + """Fraction multiplier for the CURRENT consecutive-loss ``streak``. + + Returns 1.0 below the first tier; otherwise the scale of the highest tier + whose ``min_streak <= streak``. A 0.0 result means "halt new entries". The + streak is the number of consecutive losses SO FAR (reset to 0 by any win). + """ + + if streak < 0: + raise ValueError("streak must be >= 0") + _validate_tiers(tiers) + scale = 1.0 + for min_streak, tier_scale in tiers: + if streak >= min_streak: + scale = float(tier_scale) + else: + break + return scale + + +def effective_fraction( + config: RiskConfig, + consecutive_losses: int = 0, + month_return_pct: Optional[float] = None, +) -> float: + """Effective per-trade fraction after de-risking. + + Combines the base ``f`` with the consecutive-loss scale and, when + ``month_return_pct`` (the running month's account return %) is at or below + ``config.monthly_derisk_threshold_pct``, the monthly de-risk scale. Returns + a fraction in ``[0, f]`` (0.0 = no entry). + """ + + scale = consecutive_loss_scale( + consecutive_losses, config.consecutive_loss_tiers + ) + f = config.per_trade_fraction * scale + if ( + month_return_pct is not None + and float(month_return_pct) <= config.monthly_derisk_threshold_pct + ): + f *= config.monthly_derisk_scale + return f + + +# --------------------------------------------------------------------------- +# Drawdown translation & expectancy. +# --------------------------------------------------------------------------- +def account_mdd_estimate_pct( + strategy_mdd_pct: float, + fraction: float, + concurrency_factor: float = 1.0, +) -> float: + """Translate the full-notional strategy MDD to an account-level MDD %. + + ``account_MDD ~= strategy_MDD * f * concurrency_factor``. The strategy curve + assumes sequential fixed-notional trades, so ``concurrency_factor=1.0`` is the + sequential estimate (exact for that curve); values up to K are a heuristic + stress factor for clustered concurrent losses (the path steepens) — an + approximation, not a proven bound. Inputs and the result are signed + (negative for a drawdown); passing a positive ``strategy_mdd_pct`` is a + caller error (it would yield a nonsensical positive "drawdown"). + """ + + if not (0.0 <= fraction <= 1.0): + raise ValueError("fraction must be in [0, 1]") + if concurrency_factor <= 0.0: + raise ValueError("concurrency_factor must be > 0") + return float(strategy_mdd_pct) * fraction * concurrency_factor + + +def expected_pnl_won(notional_won: float, expectancy_pct: float) -> float: + """Expected per-trade P&L in 원 = ``notional * expectancy% / 100``.""" + + if notional_won < 0: + raise ValueError("notional_won must be >= 0") + return float(notional_won) * float(expectancy_pct) / 100.0 + + +def full_kelly_fraction( + win_rate: float, + win_return_pct: float, + loss_return_pct: float, +) -> float: + """Growth-optimal notional fraction for the binary win/loss outcome. + + For a bet that returns ``+a`` (prob p) or ``-b`` (prob 1-p) per unit notional, + the Kelly notional fraction is ``f* = (p*a - q*b) / (a*b)`` with + ``a = win_return_pct/100`` and ``b = loss_return_pct/100`` (both POSITIVE + magnitudes). For this strategy (p=0.42, a=4.77%, b=1.23%) it is ~21.99 — a + degenerate 2,199% of account, which is why sizing here is governed by + drawdown/concurrency, not Kelly. Chosen f=10% is ~1/220 of full Kelly. + + A NEGATIVE result signals a non-positive edge (``p*a <= q*b``) — do NOT size + from it; it means "do not bet". + """ + + if not (0.0 <= win_rate <= 1.0): + raise ValueError("win_rate must be in [0, 1]") + a = float(win_return_pct) / 100.0 + b = float(loss_return_pct) / 100.0 + if a <= 0.0 or b <= 0.0: + raise ValueError("win_return_pct and loss_return_pct must be > 0") + q = 1.0 - float(win_rate) + return (float(win_rate) * a - q * b) / (a * b) + + +# --------------------------------------------------------------------------- +# Account-level plan bundle (drives the doc's worked-example table). +# --------------------------------------------------------------------------- +def plan_for_account(config: RiskConfig, account_won: float) -> Dict[str, Any]: + """Bundle every derived sizing/risk number for one account size. + + Returns base-size (no de-risk) values: the per-entry notional, 1R, the daily + loss limit, peak concurrent exposure, the worst-case all-K stop-out, expected + P&L per trade (idealized + stress) and the sequential account MDD estimates. + Mirrors §3 of the Page A doc and is asserted in the tests. + """ + + if account_won < 0: + raise ValueError("account_won must be >= 0") + notional = position_notional_won(account_won, config) + return { + "account_won": float(account_won), + "per_trade_fraction": config.per_trade_fraction, + "notional_won": notional, + "risk_per_trade_won": risk_per_trade_won(notional, config), + "risk_unit_account_pct": risk_unit_account_pct(config), + "daily_loss_limit_won": daily_loss_limit_won(account_won, config), + "daily_limit_in_r": daily_limit_in_r(config), + "max_concurrent_exposure_won": max_concurrent_exposure_won( + account_won, config + ), + "max_concurrent_exposure_pct": max_concurrent_exposure_pct(config), + "worst_case_concurrent_loss_won": worst_case_concurrent_loss_won( + account_won, config + ), + "expected_pnl_idealized_won": expected_pnl_won( + notional, config.idealized_expectancy_pct + ), + "expected_pnl_stress_won": expected_pnl_won( + notional, config.stress_expectancy_pct + ), + "account_mdd_idealized_pct": account_mdd_estimate_pct( + STRATEGY_MDD_IDEALIZED_PCT, config.per_trade_fraction + ), + "account_mdd_stress_pct": account_mdd_estimate_pct( + STRATEGY_MDD_STRESS_PCT, config.per_trade_fraction + ), + } diff --git a/stom_rl/leaderboard.py b/stom_rl/leaderboard.py new file mode 100644 index 000000000..fe3d9245e --- /dev/null +++ b/stom_rl/leaderboard.py @@ -0,0 +1,477 @@ +"""Compact full-split leaderboard runner for STOM RL performance validation. + +The page-4 baseline runner intentionally writes dense action/equity artifacts for +debuggability. That is useful for smoke runs but too heavy for the 2025 full +test split. This module keeps the same long-only action semantics but writes +summary-first artifacts so the next performance phase can evaluate all test +episodes before deciding whether a learned policy is usable. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import asdict, dataclass, field +from math import exp, log +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .baselines import DEFAULT_POLICIES +from .episode_manifest import DEFAULT_OUTPUT_DIR, load_episode_manifest +from .trading_env import ACTION_BUY, ACTION_HOLD, ACTION_SELL + + +DEFAULT_LEADERBOARD_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_1s_2025_baseline_leaderboard_full_test" + + +@dataclass(frozen=True) +class LeaderboardConfig: + manifest_path: str = str(DEFAULT_OUTPUT_DIR / "episode_manifest.json") + output_dir: str = str(DEFAULT_LEADERBOARD_OUTPUT_DIR) + split: str = "test" + policies: Tuple[str, ...] = DEFAULT_POLICIES + cost_bps_values: Tuple[float, ...] = (25.0,) + slippage_bps_values: Tuple[float, ...] = (0.0,) + target_cost_bps: float = 25.0 + target_slippage_bps: float = 0.0 + max_episodes: int = 0 + seed: int = 100 + lookback_window: int = 300 + reward_horizon_seconds: int = 300 + momentum_window: int = 30 + signal_threshold_bps: float = 0.0 + volume_window: int = 30 + amount_multiplier: float = 1.5 + sample_trade_limit: int = 1000 + write_artifacts: bool = True + + +@dataclass +class CompactAccount: + cost_pct: float + equity: float = 1.0 + position: int = 0 + entry_price: float = 0.0 + entry_idx: int = -1 + trade_count: int = 0 + forced_exit_count: int = 0 + trade_returns: List[float] = field(default_factory=list) + + def buy(self, price: float, idx: int) -> None: + if self.position: + return + self.entry_price = float(price) + self.entry_idx = int(idx) + self.equity *= 1.0 - self.cost_pct + self.position = 1 + + def sell(self, price: float, idx: int, *, forced: bool = False) -> Optional[Dict[str, Any]]: + if not self.position: + return None + before_exit = self.equity + exit_equity = before_exit * (float(price) / self.entry_price) * (1.0 - self.cost_pct) + trade_return = (exit_equity / max(before_exit / (1.0 - self.cost_pct), 1e-12)) - 1.0 + self.equity = float(exit_equity) + self.position = 0 + self.trade_count += 1 + self.forced_exit_count += int(bool(forced)) + self.trade_returns.append(float(trade_return)) + row = { + "entry_idx": self.entry_idx, + "exit_idx": int(idx), + "entry_price": self.entry_price, + "exit_price": float(price), + "net_return_pct": trade_return * 100.0, + "forced_exit": bool(forced), + } + self.entry_price = 0.0 + self.entry_idx = -1 + return row + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _parse_float_list(raw: str) -> Tuple[float, ...]: + return tuple(float(part.strip()) for part in raw.split(",") if part.strip()) + + +def _parse_policies(raw: str) -> Tuple[str, ...]: + policies = tuple(part.strip() for part in raw.split(",") if part.strip()) + unknown = sorted(set(policies) - set(DEFAULT_POLICIES)) + if unknown: + raise ValueError(f"Unknown policies: {unknown}. Available: {sorted(DEFAULT_POLICIES)}") + return policies + + +def _episodes(config: LeaderboardConfig) -> List[Dict[str, Any]]: + manifest = load_episode_manifest(config.manifest_path) + rows = [dict(row) for row in manifest.get("episodes", []) if row.get("split") == config.split] + if config.max_episodes and config.max_episodes > 0: + rows = rows[: int(config.max_episodes)] + return rows + + +def _safe_compounded_return_pct(final_equities: Sequence[float]) -> float: + if not final_equities: + return 0.0 + total_log = sum(log(max(float(value), 1e-12)) for value in final_equities) + if total_log > 50: + return float("inf") + if total_log < -50: + return -100.0 + return (exp(total_log) - 1.0) * 100.0 + + +def _max_drawdown_pct(equity_values: Sequence[float]) -> float: + peak = 1.0 + max_dd = 0.0 + for value in equity_values: + value = float(value) + peak = max(peak, value) + if peak > 0: + max_dd = min(max_dd, (value / peak) - 1.0) + return max_dd * 100.0 + + +def _rolling_mean(values: np.ndarray, window: int) -> np.ndarray: + if len(values) == 0: + return values + series = pd.Series(values) + return series.rolling(window=window, min_periods=1).mean().to_numpy(dtype=np.float64) + + +def _policy_action( + policy: str, + *, + idx: int, + account: CompactAccount, + close: np.ndarray, + momentum_return: np.ndarray, + amount_ratio: np.ndarray, + rng: np.random.Generator, + config: LeaderboardConfig, +) -> int: + threshold = float(config.signal_threshold_bps) / 10_000.0 + if policy == "no_trade": + return ACTION_HOLD + if policy == "random": + return int(rng.choice([ACTION_HOLD, ACTION_BUY] if account.position == 0 else [ACTION_HOLD, ACTION_SELL])) + if policy == "buy_and_hold": + return ACTION_BUY if account.position == 0 and idx == config.lookback_window else ACTION_HOLD + if policy == "momentum": + recent = float(momentum_return[idx]) + if account.position == 0 and recent > threshold: + return ACTION_BUY + if account.position == 1 and recent <= -threshold: + return ACTION_SELL + return ACTION_HOLD + if policy == "mean_reversion": + recent = float(momentum_return[idx]) + if account.position == 0 and recent < -threshold: + return ACTION_BUY + if account.position == 1 and recent >= threshold: + return ACTION_SELL + return ACTION_HOLD + if policy == "volume_filter": + recent = float(momentum_return[idx]) + ratio = float(amount_ratio[idx]) + if account.position == 0 and recent > threshold and ratio >= config.amount_multiplier: + return ACTION_BUY + if account.position == 1 and (recent <= -threshold or ratio < 1.0): + return ACTION_SELL + return ACTION_HOLD + raise ValueError(f"Unknown policy: {policy}") + + +def _episode_arrays(path: str) -> Tuple[np.ndarray, np.ndarray]: + frame = pd.read_csv(path, usecols=["close", "amount"]) + close = frame["close"].to_numpy(dtype=np.float64) + amount = frame["amount"].to_numpy(dtype=np.float64) + return close, amount + + +def _simulate_episode( + episode: Mapping[str, Any], + policy: str, + config: LeaderboardConfig, + *, + cost_pct: float, + rng: np.random.Generator, +) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]: + close, amount = _episode_arrays(str(episode["source_csv"])) + start = int(config.lookback_window) + stop = int(len(close) - config.reward_horizon_seconds - 1) + if stop < start: + raise ValueError(f"Episode {episode.get('episode_id')} is too short: {len(close)} rows") + + momentum_base_idx = np.maximum(np.arange(len(close)) - int(config.momentum_window), 0) + momentum_base = close[momentum_base_idx] + momentum_return = np.divide(close - momentum_base, momentum_base, out=np.zeros_like(close), where=momentum_base != 0) + amount_mean = _rolling_mean(amount, int(config.volume_window)) + amount_ratio = np.divide(amount, amount_mean, out=np.zeros_like(amount), where=amount_mean > 0) + + account = CompactAccount(cost_pct=cost_pct) + trades: List[Dict[str, Any]] = [] + for idx in range(start, stop + 1): + action = _policy_action( + policy, + idx=idx, + account=account, + close=close, + momentum_return=momentum_return, + amount_ratio=amount_ratio, + rng=rng, + config=config, + ) + if action == ACTION_BUY: + account.buy(float(close[idx]), idx) + elif action == ACTION_SELL: + trade = account.sell(float(close[idx]), idx) + if trade: + trades.append(trade) + + if account.position: + trade = account.sell(float(close[stop]), stop, forced=True) + if trade: + trades.append(trade) + + episode_return_pct = (account.equity - 1.0) * 100.0 + episode_row = { + "policy": policy, + "episode_id": episode.get("episode_id"), + "symbol": episode.get("symbol"), + "session": episode.get("session"), + "final_equity": account.equity, + "episode_return_pct": episode_return_pct, + "trade_count": account.trade_count, + "forced_exit_count": account.forced_exit_count, + } + for trade in trades: + trade.update( + { + "policy": policy, + "episode_id": episode.get("episode_id"), + "symbol": episode.get("symbol"), + "session": episode.get("session"), + } + ) + return episode_row, trades + + +def _summarize( + *, + policy: str, + cost_bps: float, + slippage_bps: float, + episode_rows: Sequence[Mapping[str, Any]], + trade_rows: Sequence[Mapping[str, Any]], +) -> Dict[str, Any]: + returns = np.asarray([float(row["episode_return_pct"]) for row in episode_rows], dtype=np.float64) + equities = [float(row["final_equity"]) for row in episode_rows] + trade_returns = np.asarray([float(row["net_return_pct"]) for row in trade_rows], dtype=np.float64) + sessions: Dict[str, List[float]] = {} + for row in episode_rows: + sessions.setdefault(str(row.get("session")), []).append(float(row["episode_return_pct"])) + positive_sessions = [np.mean(values) > 0.0 for values in sessions.values()] + return { + "policy": policy, + "cost_bps": float(cost_bps), + "slippage_bps": float(slippage_bps), + "episode_count": len(episode_rows), + "trade_count": len(trade_rows), + "trades_per_episode": float(len(trade_rows) / len(episode_rows)) if episode_rows else 0.0, + "avg_episode_net_return_pct": float(np.mean(returns)) if len(returns) else 0.0, + "median_episode_net_return_pct": float(np.median(returns)) if len(returns) else 0.0, + "compounded_return_pct": _safe_compounded_return_pct(equities), + "avg_trade_net_return_pct": float(np.mean(trade_returns)) if len(trade_returns) else 0.0, + "hit_rate": float(np.mean(trade_returns > 0.0)) if len(trade_returns) else 0.0, + "max_drawdown_pct": _max_drawdown_pct(equities), + "positive_session_rate": float(np.mean(positive_sessions)) if positive_sessions else 0.0, + } + + +def run_leaderboard(config: LeaderboardConfig) -> Dict[str, Any]: + episodes = _episodes(config) + output_dir = Path(config.output_dir) + scenario_rows: List[Dict[str, Any]] = [] + target_episode_rows: List[Dict[str, Any]] = [] + target_trade_sample: List[Dict[str, Any]] = [] + + for cost_bps in config.cost_bps_values: + for slippage_bps in config.slippage_bps_values: + cost_pct = (float(cost_bps) + float(slippage_bps)) / 10_000.0 + for policy in config.policies: + rng = np.random.default_rng(config.seed + int(cost_bps * 10) + sum(ord(ch) for ch in policy)) + policy_episode_rows: List[Dict[str, Any]] = [] + policy_trade_rows: List[Dict[str, Any]] = [] + for episode in episodes: + episode_row, trades = _simulate_episode(episode, policy, config, cost_pct=cost_pct, rng=rng) + episode_row.update({"cost_bps": float(cost_bps), "slippage_bps": float(slippage_bps)}) + policy_episode_rows.append(episode_row) + policy_trade_rows.extend(trades) + summary = _summarize( + policy=policy, + cost_bps=cost_bps, + slippage_bps=slippage_bps, + episode_rows=policy_episode_rows, + trade_rows=policy_trade_rows, + ) + scenario_rows.append(summary) + if float(cost_bps) == float(config.target_cost_bps) and float(slippage_bps) == float(config.target_slippage_bps): + target_episode_rows.extend(policy_episode_rows) + remaining = max(0, int(config.sample_trade_limit) - len(target_trade_sample)) + if remaining: + target_trade_sample.extend(policy_trade_rows[:remaining]) + + target_rows = [ + row + for row in scenario_rows + if float(row["cost_bps"]) == float(config.target_cost_bps) + and float(row["slippage_bps"]) == float(config.target_slippage_bps) + ] + target_rows = sorted(target_rows, key=lambda row: row["avg_episode_net_return_pct"], reverse=True) + payload = { + "mode": "stom_rl_baseline_leaderboard", + "config": asdict(config), + "summary": { + "episode_count": len(episodes), + "scenario_count": len(scenario_rows), + "target_cost_bps": config.target_cost_bps, + "target_slippage_bps": config.target_slippage_bps, + "best_policy_at_target_cost": target_rows[0]["policy"] if target_rows else None, + "target_rows": target_rows, + }, + "scenario_rows": sorted( + scenario_rows, + key=lambda row: (row["cost_bps"], row["slippage_bps"], -row["avg_episode_net_return_pct"]), + ), + "artifacts": { + "output_dir": str(output_dir), + "leaderboard_json": str(output_dir / "leaderboard_report.json"), + "leaderboard_csv": str(output_dir / "leaderboard.csv"), + "target_episode_csv": str(output_dir / "target_policy_episodes.csv"), + "target_trade_sample_csv": str(output_dir / "target_trade_sample.csv"), + }, + } + if config.write_artifacts: + _write_json(output_dir / "leaderboard_report.json", payload) + _write_csv( + output_dir / "leaderboard.csv", + payload["scenario_rows"], + [ + "policy", + "cost_bps", + "slippage_bps", + "episode_count", + "trade_count", + "trades_per_episode", + "avg_episode_net_return_pct", + "median_episode_net_return_pct", + "compounded_return_pct", + "avg_trade_net_return_pct", + "hit_rate", + "max_drawdown_pct", + "positive_session_rate", + ], + ) + _write_csv( + output_dir / "target_policy_episodes.csv", + target_episode_rows, + [ + "policy", + "cost_bps", + "slippage_bps", + "episode_id", + "symbol", + "session", + "final_equity", + "episode_return_pct", + "trade_count", + "forced_exit_count", + ], + ) + _write_csv( + output_dir / "target_trade_sample.csv", + target_trade_sample, + [ + "policy", + "episode_id", + "symbol", + "session", + "entry_idx", + "exit_idx", + "entry_price", + "exit_price", + "net_return_pct", + "forced_exit", + ], + ) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> LeaderboardConfig: + parser = argparse.ArgumentParser(description="Run compact STOM RL full-split baseline leaderboard.") + parser.add_argument("--manifest", default=str(DEFAULT_OUTPUT_DIR / "episode_manifest.json")) + parser.add_argument("--output-dir", default=str(DEFAULT_LEADERBOARD_OUTPUT_DIR)) + parser.add_argument("--split", default="test") + parser.add_argument("--policies", default=",".join(DEFAULT_POLICIES)) + parser.add_argument("--cost-bps-values", default="25") + parser.add_argument("--slippage-bps-values", default="0") + parser.add_argument("--target-cost-bps", type=float, default=25.0) + parser.add_argument("--target-slippage-bps", type=float, default=0.0) + parser.add_argument("--max-episodes", type=int, default=0) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--reward-horizon-seconds", type=int, default=300) + parser.add_argument("--momentum-window", type=int, default=30) + parser.add_argument("--signal-threshold-bps", type=float, default=0.0) + parser.add_argument("--volume-window", type=int, default=30) + parser.add_argument("--amount-multiplier", type=float, default=1.5) + parser.add_argument("--sample-trade-limit", type=int, default=1000) + parser.add_argument("--no-write", action="store_true") + args = parser.parse_args(argv) + return LeaderboardConfig( + manifest_path=args.manifest, + output_dir=args.output_dir, + split=args.split, + policies=_parse_policies(args.policies), + cost_bps_values=_parse_float_list(args.cost_bps_values), + slippage_bps_values=_parse_float_list(args.slippage_bps_values), + target_cost_bps=args.target_cost_bps, + target_slippage_bps=args.target_slippage_bps, + max_episodes=args.max_episodes, + seed=args.seed, + lookback_window=args.lookback_window, + reward_horizon_seconds=args.reward_horizon_seconds, + momentum_window=args.momentum_window, + signal_threshold_bps=args.signal_threshold_bps, + volume_window=args.volume_window, + amount_multiplier=args.amount_multiplier, + sample_trade_limit=args.sample_trade_limit, + write_artifacts=not args.no_write, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_leaderboard(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/liquidity_model.py b/stom_rl/liquidity_model.py new file mode 100644 index 000000000..207e0f056 --- /dev/null +++ b/stom_rl/liquidity_model.py @@ -0,0 +1,348 @@ +"""Liquidity feasibility & slippage model for the 시초 갭상승 strategy (Page C). + +**RULE strategy, NOT reinforcement learning.** + +Page A sized positions (f=10%, accounts 1천만/5천만/1억 -> 100만/500만/1000만 per +entry) with a flat ``max_participation`` placeholder. Page C grounds that in the +REAL entry-bar ``초당거래대금`` (per-second traded value, ``entry_sec_amount``): + +1. **Liquidity feasibility** — for a given order size, what fraction of one + second's traded value does it represent (``participation_rate``)? A trade is + feasible only if the order is a small enough fraction of available flow. This + is answered from REAL data (the entry_sec_amount distribution of ts_imb + trades), so it is the hard, data-grounded part of Page C. +2. **Slippage** — a transparent square-root market-impact model + (``slippage_bps = impact_coef * sqrt(participation)``). WITHOUT L2 queue data + this coefficient is an ASSUMPTION, not a calibration, so it is reported as a + sensitivity (several coefficients) and against the strategy's breakeven + cushion, never as a single "true" slippage. + +Unit (VERIFIED): the DB column ``초당거래대금`` (-> ``entry_sec_amount``) is stored +in **백만원 (millions of won)**, NOT raw won. Confirmed against the same row's +raw-won fields: ``초당거래대금 ~= (초당매수금액 + 초당매도금액) / 1e6`` (e.g. a second +with 5.8M won of flow stores 6; 41.8M stores 42). So the loader converts to won +via ``x 1,000,000`` before any participation math. (A median of 622 means +~6.22억원/sec of flow, not 622 won — the latter would be physically impossible.) + +Honest limits (carried from the project guardrails): ``entry_sec_amount`` is one +second's value at/near the open as a liquidity PROXY; there is no L2 queue-position +/ partial-fill data, so true fill dynamics are out of reach. The feasibility +fractions are real; the slippage magnitudes are model assumptions. Caveat: the +first session bar's value may be cumulative-since-09:00 rather than a clean +1-second slice, which would INFLATE the denominator and make participation / +slippage look more favorable than reality — treat the feasibility headroom as an +optimistic bound near the entry bar. + +All core functions are PURE (no I/O); the CLI reads the gitignored +``instances.json`` artifact (which carries ``entry_sec_amount`` + ``pass_ts_imb``). +""" + +from __future__ import annotations + +import math +from statistics import mean, median +from typing import Any, Dict, List, Optional, Sequence + +# Defaults (verified upstream). gross_expectancy is the full-universe ts_imb OOS +# zero-cost expectancy (= breakeven_OOS/100 = 98 bp -> +0.98%/trade); base cost 23 bp. +DEFAULT_BASE_COST_BPS: float = 23.0 +DEFAULT_GROSS_EXPECTANCY_PCT: float = 0.98 +DEFAULT_IMPACT_COEF_BPS: float = 10.0 # ASSUMPTION (no L2 calibration) + + +def participation_rate(order_won: float, entry_sec_amount: float) -> float: + """Order size as a multiple of one second's traded value (>= 0). + + ``order_won / entry_sec_amount``. 1.0 means the order equals one full second + of traded value at the entry bar; 0.1 means a tenth of it. + """ + + if order_won < 0: + raise ValueError("order_won must be >= 0") + if entry_sec_amount <= 0: + raise ValueError("entry_sec_amount must be > 0") + return float(order_won) / float(entry_sec_amount) + + +def is_liquidity_feasible( + order_won: float, + entry_sec_amount: float, + *, + max_participation: float, +) -> bool: + """True when the order is within ``max_participation`` of one second's flow.""" + + if max_participation <= 0: + raise ValueError("max_participation must be > 0") + return participation_rate(order_won, entry_sec_amount) <= max_participation + + +def sqrt_impact_slippage_bps( + participation: float, + *, + impact_coef_bps: float = DEFAULT_IMPACT_COEF_BPS, +) -> float: + """Square-root market-impact slippage (bps) = ``coef * sqrt(participation)``. + + The square-root law is the standard impact shape (Almgren et al.); the + coefficient is an UNCALIBRATED assumption here (no L2 data) and should be swept + as a sensitivity, not trusted as a point estimate. + """ + + if participation < 0: + raise ValueError("participation must be >= 0") + if impact_coef_bps < 0: + raise ValueError("impact_coef_bps must be >= 0") + return impact_coef_bps * math.sqrt(participation) + + +def slippage_adjusted_expectancy_pct( + gross_expectancy_pct: float, + base_cost_bps: float, + slippage_bps: float, +) -> float: + """Net %/trade after subtracting (base cost + slippage), both in bps.""" + + if base_cost_bps < 0 or slippage_bps < 0: + raise ValueError("costs must be >= 0") + return gross_expectancy_pct - (base_cost_bps + slippage_bps) / 100.0 + + +def max_order_for_slippage_budget_won( + entry_sec_amount: float, + *, + slippage_budget_bps: float, + impact_coef_bps: float = DEFAULT_IMPACT_COEF_BPS, +) -> float: + """Largest order keeping square-root slippage at/under a bps budget. + + Inverting ``budget = coef * sqrt(p)`` gives ``p_max = (budget/coef)^2`` and + ``order_max = entry_sec_amount * p_max``. + """ + + if slippage_budget_bps < 0: + raise ValueError("slippage_budget_bps must be >= 0") + if impact_coef_bps <= 0: + raise ValueError("impact_coef_bps must be > 0") + if entry_sec_amount <= 0: + raise ValueError("entry_sec_amount must be > 0") + p_max = (slippage_budget_bps / impact_coef_bps) ** 2 + return float(entry_sec_amount) * p_max + + +def summarize_liquidity( + entry_sec_amounts: Sequence[float], + *, + order_won: float, + base_cost_bps: float = DEFAULT_BASE_COST_BPS, + gross_expectancy_pct: float = DEFAULT_GROSS_EXPECTANCY_PCT, + impact_coef_bps: float = DEFAULT_IMPACT_COEF_BPS, + feasible_participation: float = 1.0, + strict_participation: float = 0.1, +) -> Dict[str, Any]: + """Per-trade liquidity feasibility + slippage over a set of entry_sec_amounts. + + For a fixed ``order_won`` (one account's per-entry notional), computes the + participation distribution, the fraction of trades feasible (participation <= + ``feasible_participation``) and strictly liquid (<= ``strict_participation``), + the median slippage at that participation, and the median-participation + slippage-adjusted expectancy. Non-positive ``entry_sec_amount`` values are + dropped (and counted) since they carry no usable liquidity signal. Note: + ``median_slippage_bps`` is the slippage AT the median participation + (slip of median p), NOT the median of per-trade slippages; by sqrt concavity + it is <= the latter. + """ + + usable = [float(a) for a in entry_sec_amounts if a is not None and float(a) > 0] + dropped = len(entry_sec_amounts) - len(usable) + if not usable: + return { + "n": 0, + "n_dropped": dropped, + "order_won": float(order_won), + "median_participation": None, + "mean_participation": None, + "frac_feasible": None, + "frac_strict": None, + "median_slippage_bps": None, + "slippage_adj_expectancy_pct_at_median": None, + } + parts = [participation_rate(order_won, a) for a in usable] + n = len(parts) + med_part = median(parts) + med_slip = sqrt_impact_slippage_bps(med_part, impact_coef_bps=impact_coef_bps) + return { + "n": n, + "n_dropped": dropped, + "order_won": float(order_won), + "median_participation": med_part, + "mean_participation": mean(parts), + "frac_feasible": sum(1 for p in parts if p <= feasible_participation) / n, + "frac_strict": sum(1 for p in parts if p <= strict_participation) / n, + "median_slippage_bps": med_slip, + "slippage_adj_expectancy_pct_at_median": slippage_adjusted_expectancy_pct( + gross_expectancy_pct, base_cost_bps, med_slip + ), + } + + +# --------------------------------------------------------------------------- +# CLI: read instances.json (ts_imb passers) -> per-account liquidity table. +# --------------------------------------------------------------------------- +def _load_entry_sec_amounts( + instances_path: str, + *, + only_ts_imb: bool = True, + unit_won: float = 1_000_000.0, +) -> List[float]: + """Load entry_sec_amount (ts_imb passers), converted to WON via ``unit_won``. + + ``초당거래대금`` is stored in 백만원, so ``unit_won=1_000_000`` (default) converts + to won. None values are dropped; zeros are kept (and later treated as + non-positive / dropped by :func:`summarize_liquidity`). + """ + + import json + from pathlib import Path + + rows = json.loads(Path(instances_path).read_text(encoding="utf-8")) + out: List[float] = [] + for r in rows: + if only_ts_imb and not r.get("pass_ts_imb"): + continue + v = r.get("entry_sec_amount") + if v is not None: + out.append(float(v) * unit_won) + return out + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + project_root = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser( + description="Liquidity feasibility & slippage (RULE NOT RL) - Page C." + ) + parser.add_argument( + "--instances", + default=str(project_root / ".omx" / "artifacts" / "gap_up_full" / "instances.json"), + ) + parser.add_argument("--base-cost-bps", type=float, default=DEFAULT_BASE_COST_BPS) + parser.add_argument("--gross-expectancy-pct", type=float, default=DEFAULT_GROSS_EXPECTANCY_PCT) + parser.add_argument( + "--per-trade-fraction", type=float, default=0.10, help="f from Page A." + ) + parser.add_argument( + "--accounts-won", + default="10000000,50000000,100000000", + help="Comma-separated account sizes (won).", + ) + parser.add_argument( + "--impact-coefs-bps", + default="5,10,20", + help="Comma-separated square-root impact coefficients (bps) to sweep.", + ) + parser.add_argument( + "--sec-amount-unit-won", + type=float, + default=1_000_000.0, + help="Unit of entry_sec_amount in won (초당거래대금 stored in 백만원 -> 1e6).", + ) + parser.add_argument( + "--json-out", + default=str(project_root / ".omx" / "artifacts" / "liquidity" / "summary.json"), + ) + args = parser.parse_args(argv) + + sec_amounts = _load_entry_sec_amounts( + args.instances, only_ts_imb=True, unit_won=args.sec_amount_unit_won + ) + accounts = [float(x) for x in args.accounts_won.split(",") if x.strip()] + coefs = [float(x) for x in args.impact_coefs_bps.split(",") if x.strip()] + + print("=== liquidity feasibility & slippage (RULE strategy, NOT RL) - Page C ===") + print( + f"ts_imb instances with entry_sec_amount: {len(sec_amounts)} " + f"f={args.per_trade_fraction:g} base_cost={args.base_cost_bps:g}bp " + f"gross_exp={args.gross_expectancy_pct:g}%" + ) + print( + f" (초당거래대금 raw in 백만원; converted x{args.sec_amount_unit_won:,.0f} -> 원; " + "slippage coef is an ASSUMPTION, no L2 calibration)" + ) + + results: Dict[str, Any] = {"n_ts_imb": len(sec_amounts), "by_account": []} + for acct in accounts: + order = args.per_trade_fraction * acct + base = summarize_liquidity( + sec_amounts, + order_won=order, + base_cost_bps=args.base_cost_bps, + gross_expectancy_pct=args.gross_expectancy_pct, + impact_coef_bps=DEFAULT_IMPACT_COEF_BPS, + ) + coef_sweep = [] + for c in coefs: + s = summarize_liquidity( + sec_amounts, + order_won=order, + base_cost_bps=args.base_cost_bps, + gross_expectancy_pct=args.gross_expectancy_pct, + impact_coef_bps=c, + ) + coef_sweep.append( + { + "impact_coef_bps": c, + "median_slippage_bps": s["median_slippage_bps"], + "slippage_adj_expectancy_pct": s["slippage_adj_expectancy_pct_at_median"], + } + ) + entry = { + "account_won": acct, + "order_won": order, + "median_participation": base["median_participation"], + "frac_feasible_le_1x": base["frac_feasible"], + "frac_strict_le_0.1x": base["frac_strict"], + "coef_sweep": coef_sweep, + } + results["by_account"].append(entry) + + def f(v: Optional[float], spec: str = ".3f") -> str: + return "n/a" if v is None else format(v, spec) + + print(f"-- account {acct:,.0f}원 order(f*acct)={order:,.0f}원 --") + print( + f" median participation={f(base['median_participation'])}x " + f"feasible(<=1x)={f(base['frac_feasible'], '.0%')} " + f"strict(<=0.1x)={f(base['frac_strict'], '.0%')}" + ) + for cs in coef_sweep: + print( + f" impact_coef={cs['impact_coef_bps']:g}bp -> median_slip=" + f"{f(cs['median_slippage_bps'])}bp " + f"slip-adj exp={f(cs['slippage_adj_expectancy_pct'])}%" + ) + + print( + "\nread: an account is liquidity-deployable when most trades are feasible " + "(participation <= 1x) AND slip-adjusted expectancy stays positive across " + "the coef sweep. Larger accounts push participation/slippage up." + ) + + out_path = Path(args.json_out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"wrote summary -> {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/liquidity_recon.py b/stom_rl/liquidity_recon.py new file mode 100644 index 000000000..0367376c4 --- /dev/null +++ b/stom_rl/liquidity_recon.py @@ -0,0 +1,418 @@ +"""Clean per-second traded-value reconstruction for the 시초 갭상승 strategy. + +**RULE strategy, NOT reinforcement learning.** This is experiment ② of the +data-layer assessment (``docs/stom_data_layer_assessment_2026-05-30.md``): remove +the *optimistic bias* in :mod:`stom_rl.liquidity_model` and re-derive capacity. + +The bias (now MEASURED, not just suspected). The backtest's entry bar is the +FIRST recorded row of the session window (``gap_up_backtest.py`` — entry = +``rows[0]`` at/after 09:00:00). That row is the **opening single-price call +auction**, so its ``초당거래대금`` (per-second traded value) is **cumulative +since 09:00**, not a clean 1-second slice. Verified on real data: at the entry +bar ``초당거래대금 * 1e6 / (초당매수금액 + 초당매도금액)`` is a **median ~72x** (mean +thousands, max >300,000x), whereas at continuous-trading bars the same ratio is +~1.0. Using that inflated denominator understates participation -> understates +slippage -> OVERSTATES deployable capacity. + +The fix. ``초당매수금액 + 초당매도금액`` is the *clean incremental* per-second flow +(it is ~equal to ``초당거래대금 * 1e6`` at every continuous bar, and is small/zero +at the auction bar because the auction's matched value only lands in the +cumulative ``당일거래대금`` / ``초당거래대금``). So a clean entry-liquidity proxy is +the median of ``초당매수금액 + 초당매도금액`` over the first continuous-trading +seconds after entry (the auction bar excluded). A cross-check reconstruction +differences the cumulative ``당일거래대금`` between consecutive bars. + +Honest limits (unchanged): still a triggered-subset DB; no L2 queue / partial-fill +data; the square-root impact coefficient remains an ASSUMPTION (swept, not +calibrated). This experiment can only make an already-passing gate *more honest* +— the expected outcome is a LOWER deployable capacity, never new alpha. + +Core functions are PURE (no I/O). The DB extractor + CLI are separate. +""" + +from __future__ import annotations + +import math +from statistics import median +from typing import Any, Dict, List, Optional, Sequence, Tuple + +# Reuse the verified participation / slippage primitives so the only thing that +# changes versus Page C is the *denominator* (clean vs contaminated). +from .liquidity_model import ( + DEFAULT_BASE_COST_BPS, + DEFAULT_GROSS_EXPECTANCY_PCT, + DEFAULT_IMPACT_COEF_BPS, + participation_rate, + slippage_adjusted_expectancy_pct, + sqrt_impact_slippage_bps, +) + +# Number of post-entry seconds over which the clean liquidity baseline is taken. +DEFAULT_CLEAN_WINDOW_SECONDS: int = 60 +# Minimum continuous-trading bars required for a usable clean proxy. +DEFAULT_MIN_CLEAN_BARS: int = 5 + + +def clean_per_second_values( + buy_amounts: Sequence[Optional[float]], + sell_amounts: Sequence[Optional[float]], +) -> List[float]: + """Clean incremental per-second traded value = ``초당매수금액 + 초당매도금액`` (won). + + ``None`` entries are treated as ``0``. These per-second buy/sell amounts are + the un-accumulated flow (unlike the auction-contaminated ``초당거래대금`` at the + entry bar), so they are the right liquidity signal. + """ + + if len(buy_amounts) != len(sell_amounts): + raise ValueError("buy_amounts and sell_amounts must be the same length") + out: List[float] = [] + for b, s in zip(buy_amounts, sell_amounts): + bv = float(b) if b is not None else 0.0 + sv = float(s) if s is not None else 0.0 + out.append(bv + sv) + return out + + +def entry_liquidity_proxy( + per_second_values: Sequence[float], + *, + window_bars: int = DEFAULT_CLEAN_WINDOW_SECONDS, + skip_first: bool = True, + min_bars: int = DEFAULT_MIN_CLEAN_BARS, +) -> Optional[float]: + """Clean entry-liquidity baseline = median of positive flow in an early window. + + Takes ``per_second_values`` (already clean, e.g. from + :func:`clean_per_second_values`), drops the first bar when ``skip_first`` (the + auction bar carries no clean continuous flow), keeps only the first + ``window_bars`` bars, and returns the **median of strictly-positive** values. + Returns ``None`` when fewer than ``min_bars`` positive bars exist (too thin to + estimate a baseline honestly). + """ + + if window_bars <= 0: + raise ValueError("window_bars must be positive") + if min_bars <= 0: + raise ValueError("min_bars must be positive") + start = 1 if skip_first else 0 + window = list(per_second_values[start : start + window_bars]) + positive = [v for v in window if v > 0.0] + if len(positive) < min_bars: + return None + return float(median(positive)) + + +def reconstruct_from_cumulative( + cumulative_value_won: Sequence[Optional[float]], +) -> List[float]: + """Cross-check: per-second flow = positive first-difference of cumulative value. + + ``당일거래대금`` (daily cumulative traded value) differenced between consecutive + bars gives the per-bar increment. Negative/None diffs (resets, gaps) are + clamped to ``0``. The first bar has no predecessor -> dropped, which also + discards the auction print that contaminates index 0. + """ + + out: List[float] = [] + prev: Optional[float] = None + for raw in cumulative_value_won: + cur = float(raw) if raw is not None else None + if prev is not None and cur is not None: + diff = cur - prev + out.append(diff if diff > 0.0 else 0.0) + if cur is not None: + prev = cur + return out + + +def summarize_capacity( + clean_entry_values_won: Sequence[float], + *, + accounts_won: Sequence[float], + per_trade_fraction: float = 0.10, + base_cost_bps: float = DEFAULT_BASE_COST_BPS, + gross_expectancy_pct: float = DEFAULT_GROSS_EXPECTANCY_PCT, + impact_coefs_bps: Sequence[float] = (5.0, 10.0, 20.0), + feasible_participation: float = 1.0, + strict_participation: float = 0.1, +) -> Dict[str, Any]: + """Per-account capacity using the CLEAN entry-liquidity denominators. + + For each account, ``order_won = per_trade_fraction * account``; participation + is ``order_won / clean_entry_value`` per instance. Reports median/mean + participation, feasible/strict fractions, a slippage coefficient sweep at the + median participation, and the slippage-adjusted expectancy. Also derives the + breakeven account size where the *median* participation reaches 1x. + """ + + usable = [float(v) for v in clean_entry_values_won if v is not None and float(v) > 0] + dropped = len(clean_entry_values_won) - len(usable) + median_clean = median(usable) if usable else None + + by_account: List[Dict[str, Any]] = [] + for acct in accounts_won: + order = float(per_trade_fraction) * float(acct) + if not usable: + by_account.append({"account_won": float(acct), "order_won": order, "n": 0}) + continue + parts = [participation_rate(order, v) for v in usable] + n = len(parts) + med_part = median(parts) + coef_sweep = [] + for c in impact_coefs_bps: + slip = sqrt_impact_slippage_bps(med_part, impact_coef_bps=c) + coef_sweep.append( + { + "impact_coef_bps": float(c), + "median_slippage_bps": slip, + "slippage_adj_expectancy_pct": slippage_adjusted_expectancy_pct( + gross_expectancy_pct, base_cost_bps, slip + ), + } + ) + by_account.append( + { + "account_won": float(acct), + "order_won": order, + "n": n, + "median_participation": med_part, + "mean_participation": sum(parts) / n, + "frac_feasible_le_1x": sum(1 for p in parts if p <= feasible_participation) / n, + "frac_strict_le_0.1x": sum(1 for p in parts if p <= strict_participation) / n, + "coef_sweep": coef_sweep, + } + ) + + # Account whose *median* order hits 1x of the median clean per-second value. + breakeven_1x_account = ( + median_clean / float(per_trade_fraction) if median_clean else None + ) + return { + "n_usable": len(usable), + "n_dropped": dropped, + "median_clean_entry_value_won": median_clean, + "p10_clean_entry_value_won": ( + sorted(usable)[int(0.1 * len(usable))] if usable else None + ), + "breakeven_1x_account_won_median": breakeven_1x_account, + "by_account": by_account, + } + + +# --------------------------------------------------------------------------- +# DB extractor (separate from the pure core). +# --------------------------------------------------------------------------- +def _seconds_since_open(ts: int, *, open_hhmmss: int = 90000) -> int: + """Seconds of an index timestamp (YYYYMMDDHHMMSS) past 09:00:00.""" + + hhmmss = int(ts) % 1_000_000 + h = hhmmss // 10_000 + m = (hhmmss // 100) % 100 + s = hhmmss % 100 + oh = open_hhmmss // 10_000 + om = (open_hhmmss // 100) % 100 + os_ = open_hhmmss % 100 + return (h * 3600 + m * 60 + s) - (oh * 3600 + om * 60 + os_) + + +def extract_clean_entry_values( + instances_path: str, + db_path: str, + *, + only_ts_imb: bool = True, + window_bars: int = DEFAULT_CLEAN_WINDOW_SECONDS, + session_start: str = "090000", + time_exit: str = "093000", + limit: int = 0, +) -> Dict[str, Any]: + """Re-query the DB per (symbol, session) and reconstruct clean entry liquidity. + + Returns, per instance, both the OLD contaminated denominator + (``초당거래대금`` at the entry bar, x1e6) and the NEW clean proxy (median of + ``초당매수금액 + 초당매도금액`` over the first ``window_bars`` continuous seconds), + plus the inflation ratio. Read-only DB access. + """ + + import json + import sqlite3 + from pathlib import Path + + rows = json.loads(Path(instances_path).read_text(encoding="utf-8")) + if only_ts_imb: + rows = [r for r in rows if r.get("pass_ts_imb")] + if limit and limit > 0: + rows = rows[: int(limit)] + + conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True) + clean_values: List[float] = [] + old_values: List[float] = [] + inflation_ratios: List[float] = [] + per_instance: List[Dict[str, Any]] = [] + n_no_clean = 0 + try: + for r in rows: + sym = str(r["symbol"]) + sess = str(r["session"]) + start_full = int(sess + session_start) + end_full = int(sess + time_exit) + q = ( + f'SELECT "index","초당매수금액","초당매도금액","초당거래대금","당일거래대금" ' + f'FROM "{sym}" WHERE "index" >= ? AND "index" <= ? ORDER BY "index"' + ) + try: + data = conn.execute(q, (start_full, end_full)).fetchall() + except sqlite3.OperationalError: + continue + if not data: + continue + buy = [d[1] for d in data] + sell = [d[2] for d in data] + entry_sec_amt = data[0][3] # 초당거래대금 at entry bar (백만원, contaminated) + old_denom = float(entry_sec_amt) * 1_000_000.0 if entry_sec_amt else None + psv = clean_per_second_values(buy, sell) + clean = entry_liquidity_proxy(psv, window_bars=window_bars, skip_first=True) + if clean is None: + n_no_clean += 1 + else: + clean_values.append(clean) + if old_denom and old_denom > 0: + old_values.append(old_denom) + inflation_ratios.append(old_denom / clean) + per_instance.append( + { + "symbol": sym, + "session": sess, + "old_entry_sec_amount_won": old_denom, + "clean_entry_value_won": clean, + "inflation_ratio": (old_denom / clean) if (clean and old_denom) else None, + } + ) + finally: + conn.close() + + infl_sorted = sorted(inflation_ratios) + summary = { + "n_instances": len(rows), + "n_with_clean": len(clean_values), + "n_no_clean": n_no_clean, + "inflation_ratio_median": median(inflation_ratios) if inflation_ratios else None, + "inflation_ratio_mean": (sum(inflation_ratios) / len(inflation_ratios)) if inflation_ratios else None, + "inflation_ratio_p90": infl_sorted[int(0.9 * len(infl_sorted))] if infl_sorted else None, + "median_old_denom_won": median(old_values) if old_values else None, + "median_clean_denom_won": median(clean_values) if clean_values else None, + } + return {"summary": summary, "clean_values_won": clean_values, "per_instance": per_instance} + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + root = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser( + description="Clean per-second liquidity reconstruction (RULE NOT RL) - experiment 2." + ) + parser.add_argument( + "--instances", + default=str(root / ".omx" / "artifacts" / "gap_up_full" / "instances.json"), + ) + parser.add_argument( + "--db", default=str(root / "_database" / "stock_tick_back.db") + ) + parser.add_argument("--window-bars", type=int, default=DEFAULT_CLEAN_WINDOW_SECONDS) + parser.add_argument("--per-trade-fraction", type=float, default=0.10) + parser.add_argument( + "--accounts-won", default="10000000,50000000,100000000,500000000,1000000000" + ) + parser.add_argument("--impact-coefs-bps", default="5,10,20") + parser.add_argument("--base-cost-bps", type=float, default=DEFAULT_BASE_COST_BPS) + parser.add_argument("--gross-expectancy-pct", type=float, default=DEFAULT_GROSS_EXPECTANCY_PCT) + parser.add_argument("--limit", type=int, default=0) + parser.add_argument( + "--json-out", + default=str(root / ".omx" / "artifacts" / "liquidity_recon" / "summary.json"), + ) + args = parser.parse_args(argv) + + accounts = [float(x) for x in args.accounts_won.split(",") if x.strip()] + coefs = [float(x) for x in args.impact_coefs_bps.split(",") if x.strip()] + + print("=== clean per-second liquidity reconstruction (RULE NOT RL) — experiment 2 ===") + print(f"instances={args.instances}") + print("removing the auction-cumulative bias in entry-bar 초당거래대금; " + "clean denom = median(초당매수금액+초당매도금액) over first " + f"{args.window_bars} continuous seconds") + + ext = extract_clean_entry_values( + args.instances, args.db, only_ts_imb=True, window_bars=args.window_bars, limit=args.limit + ) + s = ext["summary"] + print( + f"\nts_imb instances={s['n_instances']} with_clean={s['n_with_clean']} " + f"no_clean(thin)={s['n_no_clean']}" + ) + print( + f"INFLATION of old entry-bar denom vs clean: " + f"median={s['inflation_ratio_median']:.1f}x mean={s['inflation_ratio_mean']:.1f}x " + f"p90={s['inflation_ratio_p90']:.1f}x" + ) + print( + f"median denom: OLD(contaminated)={s['median_old_denom_won']:,.0f}원 " + f"CLEAN={s['median_clean_denom_won']:,.0f}원" + ) + + cap = summarize_capacity( + ext["clean_values_won"], + accounts_won=accounts, + per_trade_fraction=args.per_trade_fraction, + base_cost_bps=args.base_cost_bps, + gross_expectancy_pct=args.gross_expectancy_pct, + impact_coefs_bps=coefs, + ) + print( + f"\nclean median per-second value={cap['median_clean_entry_value_won']:,.0f}원 " + f"p10={cap['p10_clean_entry_value_won']:,.0f}원" + ) + be = cap["breakeven_1x_account_won_median"] + print(f"account where MEDIAN order hits 1x clean flow (f={args.per_trade_fraction:g}): " + f"{be:,.0f}원" if be else "n/a") + print("\n-- per-account capacity (CLEAN denominator) --") + for e in cap["by_account"]: + if e.get("n", 0) == 0: + print(f" account {e['account_won']:,.0f} -> no data") + continue + sweep = " ".join( + f"coef{c['impact_coef_bps']:g}->slip{c['median_slippage_bps']:.1f}bp/" + f"exp{c['slippage_adj_expectancy_pct']:+.3f}%" + for c in e["coef_sweep"] + ) + print( + f" account {e['account_won']:,.0f} order {e['order_won']:,.0f} " + f"med_part={e['median_participation']:.3f}x " + f"feasible(<=1x)={e['frac_feasible_le_1x']:.0%} " + f"strict(<=0.1x)={e['frac_strict_le_0.1x']:.0%}" + ) + print(f" {sweep}") + + out = {"reconstruction": s, "capacity": cap, "config": { + "window_bars": args.window_bars, "per_trade_fraction": args.per_trade_fraction, + "accounts_won": accounts, "impact_coefs_bps": coefs, + "base_cost_bps": args.base_cost_bps, "gross_expectancy_pct": args.gross_expectancy_pct, + }} + out_path = Path(args.json_out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8") + print(f"\nwrote -> {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/market_participant_studies.py b/stom_rl/market_participant_studies.py new file mode 100644 index 000000000..87ea3ba3b --- /dev/null +++ b/stom_rl/market_participant_studies.py @@ -0,0 +1,288 @@ +"""Market-participant proxy studies for opening-window research.""" + +from __future__ import annotations + +import csv +import json +from dataclasses import dataclass +from math import sqrt +from pathlib import Path +from statistics import fmean, stdev +from typing import Any, Final, Mapping, Sequence + +import pandas as pd # noqa: PANDAS_OK - STOM research fixtures and DB adapters are pandas-based + +from .participant_pressure_features import ( + COL_FOREIGN_NET_BUY, + COL_INSTITUTION_NET_BUY, + COL_PROGRAM_NET_BUY, + COL_TRANSACTION_VALUE, +) + + +COL_PRICE: Final[str] = "현재가" +ABSOLUTE_AMOUNT_THRESHOLD_KRW: Final[float] = 100_000_000_000.0 +AMOUNT_MULTIPLES: Final[tuple[float, ...]] = (2.0, 3.0, 5.0, 10.0) +FORWARD_HORIZONS: Final[tuple[int, ...]] = (1, 3, 5, 20) +AMOUNT_UNIT_KRW: Final[float] = 1_000_000.0 +SUMMARY_JSON: Final[str] = "market_participant_study_summary.json" +EPISODES_CSV: Final[str] = "market_participant_study_episodes.csv" +GROUPS_CSV: Final[str] = "market_participant_study_groups.csv" + + +@dataclass(frozen=True, slots=True) +class MarketParticipantStudyError(ValueError): + """Raised when market-participant study inputs violate the contract.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def _causal_rows(frame: pd.DataFrame, decision_second: int) -> pd.DataFrame: + if frame.empty: + raise MarketParticipantStudyError("market participant study requires non-empty frames") + if decision_second < 0 or decision_second >= len(frame): + raise MarketParticipantStudyError("decision_second out of range") + return frame.iloc[: decision_second + 1] + + +def _close_price(frame: pd.DataFrame, decision_second: int) -> float: + return float(_causal_rows(frame, decision_second)[COL_PRICE].iloc[-1]) + + +def _episode_amount_krw(frame: pd.DataFrame, decision_second: int, amount_unit_krw: float) -> float: + rows = _causal_rows(frame, decision_second) + return float(rows[COL_TRANSACTION_VALUE].astype(float).sum()) * float(amount_unit_krw) + + +def _upper_wick(frame: pd.DataFrame, decision_second: int) -> dict[str, Any]: + rows = _causal_rows(frame, decision_second) + opening = float(rows[COL_PRICE].iloc[0]) + close = float(rows[COL_PRICE].iloc[-1]) + high = float(rows[COL_PRICE].astype(float).max()) + body = abs(close - opening) + upper = max(0.0, high - max(opening, close)) + threshold = max(body * 2.0, 1e-9) + return { + "open": opening, + "close": close, + "high": high, + "body": body, + "upper_wick": upper, + "upper_wick_ratio": upper / body if body > 0.0 else None, + "upper_wick_exhaustion": upper > threshold, + } + + +def _base_rows( + frames: Sequence[pd.DataFrame], + *, + decision_second: int, + amount_unit_krw: float, +) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for frame in frames: + if frame.empty: + continue + symbol = str(frame["symbol"].iloc[0]) + session = str(frame["session"].iloc[0]) + close = _close_price(frame, decision_second) + wick = _upper_wick(frame, decision_second) + rows.append( + { + "episode_id": f"{symbol}_{session}", + "symbol": symbol, + "session": session, + "decision_second": int(decision_second), + "close_price": close, + "opening_amount_krw": _episode_amount_krw(frame, decision_second, amount_unit_krw), + "absolute_surge": False, + "rolling_amount_baseline_krw": None, + "amount_multiple": None, + "upper_wick_exhaustion": wick["upper_wick_exhaustion"], + "upper_wick_ratio": wick["upper_wick_ratio"], + } + ) + if not rows: + raise MarketParticipantStudyError("market participant study requires at least one episode") + return sorted(rows, key=lambda item: (str(item["symbol"]), str(item["session"]))) + + +def _attach_forward_returns(rows: list[dict[str, Any]]) -> None: + by_symbol: dict[str, list[int]] = {} + for index, row in enumerate(rows): + by_symbol.setdefault(str(row["symbol"]), []).append(index) + for horizon in FORWARD_HORIZONS: + row[f"forward_{horizon}_session_return_pct"] = None + for indexes in by_symbol.values(): + indexes.sort(key=lambda idx: str(rows[idx]["session"])) + for pos, row_index in enumerate(indexes): + close = float(rows[row_index]["close_price"]) + for horizon in FORWARD_HORIZONS: + target_pos = pos + horizon + if target_pos >= len(indexes): + continue + future_close = float(rows[indexes[target_pos]]["close_price"]) + rows[row_index][f"forward_{horizon}_session_return_pct"] = (future_close / close - 1.0) * 100.0 + + +def _attach_amount_multiples(rows: list[dict[str, Any]]) -> None: + by_symbol: dict[str, list[int]] = {} + for index, row in enumerate(rows): + by_symbol.setdefault(str(row["symbol"]), []).append(index) + row["absolute_surge"] = float(row["opening_amount_krw"]) >= ABSOLUTE_AMOUNT_THRESHOLD_KRW + for indexes in by_symbol.values(): + indexes.sort(key=lambda idx: str(rows[idx]["session"])) + previous: list[float] = [] + for row_index in indexes: + amount = float(rows[row_index]["opening_amount_krw"]) + if previous: + baseline = fmean(previous[-20:]) + rows[row_index]["rolling_amount_baseline_krw"] = baseline + rows[row_index]["amount_multiple"] = amount / baseline if baseline > 0.0 else None + previous.append(amount) + + +def _mean(values: Sequence[float]) -> float | None: + return fmean(values) if values else None + + +def _ci95(values: Sequence[float]) -> list[float | None]: + if len(values) < 2: + return [None, None] + mean = fmean(values) + half = 1.96 * stdev(values) / sqrt(len(values)) + return [mean - half, mean + half] + + +def _group_summary(label: str, rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + summary: dict[str, Any] = { + "label": label, + "episode_count": len(rows), + "baseline_policy": "ts_imb RULE baseline", + } + for horizon in FORWARD_HORIZONS: + column = f"forward_{horizon}_session_return_pct" + values = [float(row[column]) for row in rows if row[column] is not None] + summary[f"{column}_mean"] = _mean(values) + summary[f"{column}_ci95"] = _ci95(values) + return summary + + +def _surge_groups(rows: Sequence[Mapping[str, Any]]) -> dict[str, dict[str, Any]]: + groups = { + "absolute_ge_100b_krw": _group_summary( + "absolute_ge_100b_krw", + [row for row in rows if bool(row["absolute_surge"])], + ) + } + for multiple in AMOUNT_MULTIPLES: + key = f"amount_multiple_ge_{int(multiple)}x" + groups[key] = _group_summary( + key, + [ + row + for row in rows + if row["amount_multiple"] is not None and float(row["amount_multiple"]) >= multiple + ], + ) + return groups + + +def _upper_wick_study(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + selected = [row for row in rows if bool(row["upper_wick_exhaustion"])] + return { + "upper_wick_rule": "upper_wick > body * 2", + "episode_count": len(rows), + "upper_wick_count": len(selected), + "groups": { + "upper_wick_exhaustion": _group_summary("upper_wick_exhaustion", selected), + "not_upper_wick_exhaustion": _group_summary( + "not_upper_wick_exhaustion", + [row for row in rows if not bool(row["upper_wick_exhaustion"])], + ), + }, + } + + +def _proxy_strata(frames: Sequence[pd.DataFrame], episode_count: int) -> list[dict[str, Any]]: + optional = ( + ("foreign_net_buy", COL_FOREIGN_NET_BUY), + ("institution_net_buy", COL_INSTITUTION_NET_BUY), + ("program_net_buy", COL_PROGRAM_NET_BUY), + ) + rows: list[dict[str, Any]] = [] + for name, column in optional: + if not any(column in frame.columns for frame in frames): + rows.append({"proxy": name, "status": "proxy_unavailable", "episode_count": episode_count, "net_buy_sum": None}) + continue + total = sum(float(frame[column].fillna(0.0).iloc[-1]) for frame in frames if column in frame.columns) + rows.append({"proxy": name, "status": "available", "episode_count": episode_count, "net_buy_sum": total}) + return rows + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]]) -> None: + if not rows: + return + fieldnames = sorted({key for row in rows for key in row}) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow({field: row.get(field) for field in fieldnames}) + + +def build_market_participant_studies( + frames: Sequence[pd.DataFrame], + *, + output_dir: Path, + decision_second: int = 5, + amount_unit_krw: float = AMOUNT_UNIT_KRW, +) -> dict[str, Any]: + """Build preregistered non-RL market-participant proxy studies.""" + + output_dir.mkdir(parents=True, exist_ok=True) + rows = _base_rows(frames, decision_second=decision_second, amount_unit_krw=amount_unit_krw) + _attach_forward_returns(rows) + _attach_amount_multiples(rows) + surge_groups = _surge_groups(rows) + proxy_strata = _proxy_strata(frames, len(rows)) + artifacts = { + "summary_json": str(output_dir / SUMMARY_JSON), + "episodes_csv": str(output_dir / EPISODES_CSV), + "groups_csv": str(output_dir / GROUPS_CSV), + } + payload: dict[str, Any] = { + "artifact_type": "market_participant_study", + "mode": "market_participant_proxy_research", + "verdict": "NO-GO_SAMPLE" if len(rows) < 5 else "RESEARCH_ONLY", + "config": { + "absolute_amount_threshold_krw": ABSOLUTE_AMOUNT_THRESHOLD_KRW, + "amount_multiples": list(AMOUNT_MULTIPLES), + "amount_unit_krw": float(amount_unit_krw), + "decision_second": int(decision_second), + }, + "summary": { + "episode_count": len(rows), + "forward_return_columns": [f"forward_{h}_session_return_pct" for h in FORWARD_HORIZONS], + }, + "strategy_context": { + "line": "participant_proxy_research", + "label": "MARKET PARTICIPANT STUDY", + "is_reinforcement_learning": False, + "is_live_ready": False, + "is_profit_model": False, + }, + "surge_groups": surge_groups, + "upper_wick_study": _upper_wick_study(rows), + "proxy_strata": proxy_strata, + "episodes": rows, + "artifacts": artifacts, + } + Path(artifacts["summary_json"]).write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + _write_csv(Path(artifacts["episodes_csv"]), rows) + _write_csv(Path(artifacts["groups_csv"]), list(surge_groups.values())) + return payload diff --git a/stom_rl/marketable_fill.py b/stom_rl/marketable_fill.py new file mode 100644 index 000000000..cf74564b1 --- /dev/null +++ b/stom_rl/marketable_fill.py @@ -0,0 +1,110 @@ +"""De-idealized marketable fills + rule-from-arbitrary-entry simulator (Page P1b). + +**RULE strategy, NOT reinforcement learning.** + +The predictability gate (P1) measured a real ranking signal but scored it with +IDEALIZED fills (현재가 returns − flat 23bp) and NOT baseline-relative. P1b fixes +both: trades fill MARKETABLE (a buy pays the ask 매도호가1, a sell hits the bid +매수호가1, so the spread is paid twice) plus optional extra slippage, and the rule +is simulated from an ARBITRARY entry bar so a model's chosen entry can be compared +paired against the rule's fixed 09:00 entry. + +Pure functions (no I/O); unit-tested on synthetic paths. +""" + +from __future__ import annotations + +from typing import Optional, Sequence, Tuple + +EXIT_TP = "tp" +EXIT_SL = "sl" +EXIT_TIME = "time" +DEFAULT_TIME_EXIT_SEC = 1500 # 09:25:00 = 25*60 seconds after 09:00 + + +def marketable_entry_price( + bid1: Optional[float], ask1: Optional[float], price: float, *, slippage_bps: float = 0.0 +) -> float: + """Marketable BUY fill: pay the ask (cross the spread), + slippage. + + Falls back to ``price`` when the ask is missing/non-positive. + """ + + if slippage_bps < 0: + raise ValueError("slippage_bps must be >= 0") + p = float(ask1) if (ask1 is not None and float(ask1) > 0) else float(price) + return p * (1.0 + slippage_bps / 10000.0) + + +def marketable_exit_price( + bid1: Optional[float], ask1: Optional[float], price: float, *, slippage_bps: float = 0.0 +) -> float: + """Marketable SELL fill: hit the bid (cross the spread), − slippage. + + Falls back to ``price`` when the bid is missing/non-positive. + """ + + if slippage_bps < 0: + raise ValueError("slippage_bps must be >= 0") + p = float(bid1) if (bid1 is not None and float(bid1) > 0) else float(price) + return p * (1.0 - slippage_bps / 10000.0) + + +def simulate_rule_from_entry( + prices: Sequence[float], + bids: Sequence[Optional[float]], + asks: Sequence[Optional[float]], + secs: Sequence[int], + entry_idx: int, + *, + tp_pct: float = 5.0, + sl_pct: float = 1.0, + time_exit_sec: int = DEFAULT_TIME_EXIT_SEC, + cost_bps: float = 23.0, + slippage_bps: float = 0.0, +) -> Tuple[float, str]: + """Net % of the TP/SL/time rule entered MARKETABLE at ``entry_idx``. + + Buy fills at the ask at ``entry_idx``; the forward walk exits at the first bar + that (a) reaches the time-exit second (09:25), else (b) trips SL (price <= SL + level, checked before TP — conservative), else (c) trips TP. The exit fills + MARKETABLE at that bar's bid. ``cost_bps`` (commission+tax) is subtracted on + top of the spread already paid by the marketable fills. Returns + ``(net_return_pct, exit_reason)``. + """ + + n = len(prices) + if not (0 <= entry_idx < n): + raise ValueError("entry_idx out of range") + if tp_pct <= 0 or sl_pct <= 0: + raise ValueError("tp_pct and sl_pct must be > 0") + if cost_bps < 0: + raise ValueError("cost_bps must be >= 0") + + entry_fill = marketable_entry_price( + bids[entry_idx], asks[entry_idx], prices[entry_idx], slippage_bps=slippage_bps + ) + if entry_fill <= 0: + raise ValueError("entry fill must be positive") + tp_level = entry_fill * (1.0 + tp_pct / 100.0) + sl_level = entry_fill * (1.0 - sl_pct / 100.0) + + exit_idx = n - 1 + reason = EXIT_TIME + for j in range(entry_idx + 1, n): + if int(secs[j]) >= time_exit_sec: + exit_idx, reason = j, EXIT_TIME + break + p = float(prices[j]) + if p <= sl_level: # conservative: SL wins a same-bar straddle + exit_idx, reason = j, EXIT_SL + break + if p >= tp_level: + exit_idx, reason = j, EXIT_TP + break + + exit_fill = marketable_exit_price( + bids[exit_idx], asks[exit_idx], prices[exit_idx], slippage_bps=slippage_bps + ) + net = (exit_fill / entry_fill - 1.0) * 100.0 - cost_bps / 100.0 + return net, reason diff --git a/stom_rl/microstructure_features.py b/stom_rl/microstructure_features.py new file mode 100644 index 000000000..efc3d2438 --- /dev/null +++ b/stom_rl/microstructure_features.py @@ -0,0 +1,185 @@ +"""Causal microstructure features for the within-window predictability probe (Page R-P1). + +**RULE strategy, NOT reinforcement learning.** This module only builds CAUSAL +(point-in-time, no look-ahead) feature vectors from the per-second orderbook + +order-flow sequence UP TO a decision second ``t``. It does not trade or learn; +it feeds the supervised predictability gate (see ``predictability_probe.py``) +that decides — cheaply, before any deep RL — whether the intra-window sequence +carries cost-relevant short-horizon signal at all. + +All functions are PURE (no I/O). Causality is structural: callers pass ONLY the +rows at or before ``t`` (``window`` = ``rows[0..t]``); nothing here can see the +future because the future rows are never given to it. +""" + +from __future__ import annotations + +from math import isfinite +from statistics import pstdev +from typing import Any, Dict, List, Optional, Sequence + +# Trailing look-back lengths (in bars ~ seconds) for rolling features. +LOOKBACKS: tuple = (5, 15, 30, 60) + + +def pct_return(p_from: float, p_to: float) -> Optional[float]: + """Percent return ``(p_to/p_from - 1)*100``; None if ``p_from`` non-positive.""" + + if p_from is None or p_to is None or p_from <= 0: + return None + return (float(p_to) / float(p_from) - 1.0) * 100.0 + + +def imbalance(a: Optional[float], b: Optional[float]) -> Optional[float]: + """``a / (a + b)`` in [0, 1]; None if either side missing or sum non-positive.""" + + if a is None or b is None: + return None + s = float(a) + float(b) + if s <= 0: + return None + return float(a) / s + + +def signed_flow(buy: Optional[float], sell: Optional[float]) -> float: + """Signed flow ``buy - sell`` (0.0 for missing components).""" + + return float(buy or 0.0) - float(sell or 0.0) + + +def linfit_slope(ys: Sequence[float]) -> float: + """Least-squares slope of ``ys`` vs index 0..n-1 (0.0 for < 2 points).""" + + vals = [float(v) for v in ys if v is not None and isfinite(float(v))] + n = len(vals) + if n < 2: + return 0.0 + mean_x = (n - 1) / 2.0 + mean_y = sum(vals) / n + num = sum((i - mean_x) * (vals[i] - mean_y) for i in range(n)) + den = sum((i - mean_x) ** 2 for i in range(n)) + return num / den if den > 0 else 0.0 + + +def microprice( + bid: Optional[float], ask: Optional[float], + bid_qty: Optional[float], ask_qty: Optional[float], +) -> Optional[float]: + """Depth-weighted microprice ``(bid*ask_qty + ask*bid_qty)/(bid_qty+ask_qty)``. + + Heavier resting size on one side pulls the microprice toward the OTHER side's + quote (the side likely to be hit). None if quotes/sizes missing or empty. + """ + + if None in (bid, ask, bid_qty, ask_qty): + return None + q = float(bid_qty) + float(ask_qty) + if q <= 0: + return None + return (float(bid) * float(ask_qty) + float(ask) * float(bid_qty)) / q + + +def depth_ofi( + prev_bid_tot: Optional[float], prev_ask_tot: Optional[float], + bid_tot: Optional[float], ask_tot: Optional[float], +) -> float: + """Approximate order-flow imbalance from consecutive 1s depth deltas. + + ``Δbid_total - Δask_total`` between two snapshots. This is an APPROXIMATION of + true (message-level) OFI inferred from 1s aggregated depth, so it is noisier + than book-event OFI — labelled as such in the probe. + """ + + d_bid = float(bid_tot or 0.0) - float(prev_bid_tot or 0.0) + d_ask = float(ask_tot or 0.0) - float(prev_ask_tot or 0.0) + return d_bid - d_ask + + +def _last_k(seq: Sequence[Any], k: int) -> List[Any]: + return list(seq[-k:]) if k > 0 else [] + + +def causal_feature_vector(window: Sequence[Dict[str, Any]]) -> Dict[str, float]: + """Build the causal feature vector at the LAST row of ``window`` (= decision t). + + ``window`` is the list of per-second rows ``rows[0..t]`` (causal — never pass + future rows). Each row dict carries: ``sec`` (seconds since 09:00), ``price``, + ``buy_val``/``sell_val`` (per-sec buy/sell value), ``buy_qty``/``sell_qty``, + ``ts`` (체결강도), ``bid_tot``/``ask_tot`` (총잔량), ``bid1``/``ask1`` (best + quotes), ``bidq1``/``askq1`` (best-level depth). Returns an ordered dict of + float features; missing inputs collapse to 0.0 so the vector is always dense. + """ + + if not window: + raise ValueError("window must contain at least the decision row") + n = len(window) + cur = window[-1] + prices = [r.get("price") for r in window] + p_t = prices[-1] + + feats: Dict[str, float] = {} + + # --- time / price state --- + sec = float(cur.get("sec") or 0.0) + feats["t_sec"] = sec + feats["t_frac"] = sec / 1200.0 # fraction through the 09:00-09:20 window + feats["ret_open"] = pct_return(prices[0], p_t) or 0.0 + + # --- trailing returns + realized vol per look-back --- + for k in LOOKBACKS: + idx = max(0, n - 1 - k) + feats[f"ret_{k}"] = pct_return(prices[idx], p_t) or 0.0 + seg = prices[max(0, n - k):] + rets = [ + pct_return(seg[i - 1], seg[i]) or 0.0 for i in range(1, len(seg)) + ] + feats[f"vol_{k}"] = pstdev(rets) if len(rets) >= 2 else 0.0 + + # --- signed order flow per look-back (value + ratio) --- + for k in LOOKBACKS: + rows_k = _last_k(window, k) + sval = sum(signed_flow(r.get("buy_val"), r.get("sell_val")) for r in rows_k) + buy = sum(float(r.get("buy_val") or 0.0) for r in rows_k) + sell = sum(float(r.get("sell_val") or 0.0) for r in rows_k) + feats[f"sflow_val_{k}"] = sval + feats[f"sflow_ratio_{k}"] = imbalance(buy, sell) if (buy + sell) > 0 else 0.5 + + # --- trade strength (체결강도) level + trailing slope --- + feats["ts_level"] = float(cur.get("ts") or 0.0) + feats["ts_slope_30"] = linfit_slope([r.get("ts") for r in _last_k(window, 30)]) + + # --- book shape (5-level totals + best level) --- + feats["book_imb_tot"] = imbalance(cur.get("bid_tot"), cur.get("ask_tot")) or 0.5 + feats["book_imb_l1"] = imbalance(cur.get("bidq1"), cur.get("askq1")) or 0.5 + + # --- approximate depth OFI over look-backs --- + for k in LOOKBACKS: + rows_k = _last_k(window, k + 1) + ofi = sum( + depth_ofi( + rows_k[i - 1].get("bid_tot"), rows_k[i - 1].get("ask_tot"), + rows_k[i].get("bid_tot"), rows_k[i].get("ask_tot"), + ) + for i in range(1, len(rows_k)) + ) + feats[f"ofi_{k}"] = ofi + + # --- microprice deviation + relative spread --- + mp = microprice(cur.get("bid1"), cur.get("ask1"), cur.get("bidq1"), cur.get("askq1")) + bid1, ask1 = cur.get("bid1"), cur.get("ask1") + if mp is not None and bid1 and ask1: + mid = (float(bid1) + float(ask1)) / 2.0 + feats["micro_dev"] = ((mp - mid) / mid * 100.0) if mid > 0 else 0.0 + feats["spread_rel"] = ((float(ask1) - float(bid1)) / mid * 100.0) if mid > 0 else 0.0 + else: + feats["micro_dev"] = 0.0 + feats["spread_rel"] = 0.0 + + return feats + + +FEATURE_NAMES: List[str] = list( + causal_feature_vector( + [{"sec": 0, "price": 100.0}, {"sec": 1, "price": 100.0}] + ).keys() +) diff --git a/stom_rl/opening_30m_rl_artifacts.py b/stom_rl/opening_30m_rl_artifacts.py new file mode 100644 index 000000000..50dcb96b8 --- /dev/null +++ b/stom_rl/opening_30m_rl_artifacts.py @@ -0,0 +1,176 @@ +"""Dashboard-compatible evaluation artifacts for opening RL workflows.""" + +from __future__ import annotations + +import csv +import hashlib +import json +from pathlib import Path +from typing import Any, Mapping, Sequence + + +SUMMARY_JSON = "summary.json" +SUMMARY_CSV = "summary.csv" +ACTIONS_CSV = "actions.csv" +TRADES_CSV = "trades.csv" +EPISODES_CSV = "episodes.csv" +BASELINE_CSV = "baseline.csv" +DIAGNOSTICS_JSON = "diagnostics.json" +LIVE_EVENTS_JSONL = "rl_live_events.jsonl" + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=list(fieldnames), extrasaction="ignore") + writer.writeheader() + for row in rows: + writer.writerow({field: row.get(field) for field in fieldnames}) + + +def _config_hash(payload: Mapping[str, Any]) -> str: + encoded = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:16] + + +def _summary_row(summary: Mapping[str, Any]) -> dict[str, Any]: + return { + "artifact_type": "opening_30m_eval_artifacts", + "status": summary["training_status"], + "algorithm": summary["algorithm"], + "seed": summary["seed"], + "cost_bps": summary["cost_bps"], + "train_episode_count": len(summary["train_episode_ids"]), + "eval_episode_count": len(summary["eval_episode_ids"]), + "baseline_delta_inputs": json.dumps(summary["baseline_delta_inputs"], ensure_ascii=False, sort_keys=True), + } + + +def _action_rows(training_payload: Mapping[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for episode in training_payload.get("evaluation", []): + rows.append( + { + "episode_id": episode.get("episode_id"), + "policy_step_count": episode.get("steps", 0), + "policy_action_space": "fixed_entry_exit_only", + "algorithm": training_payload.get("algorithm", "DQN"), + } + ) + return rows + + +def _trade_rows(training_payload: Mapping[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for episode in training_payload.get("evaluation", []): + rows.append( + { + "episode_id": episode.get("episode_id"), + "trade_count": episode.get("trade_count", 0), + "reward": episode.get("reward", 0.0), + "cost_model": "23bp_marketable_fill", + } + ) + return rows + + +def _episode_rows(training_payload: Mapping[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for episode_id in training_payload.get("train_episode_ids", []): + rows.append({"episode_id": episode_id, "split": "train"}) + for episode_id in training_payload.get("eval_episode_ids", []): + rows.append({"episode_id": episode_id, "split": "eval"}) + return rows + + +def _live_events(path: Path, training_payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + event = { + "event": "opening_eval_artifacts_written", + "status": training_payload.get("status"), + "algorithm": training_payload.get("algorithm", "DQN"), + } + path.write_text(json.dumps(event, ensure_ascii=False, sort_keys=True) + "\n", encoding="utf-8") + + +def write_opening_eval_artifacts( + *, + output_dir: Path, + training_payload: Mapping[str, Any], + baseline_payload: Mapping[str, Any], + source_manifest_path: Path, + seed: int, + cost_bps: float, +) -> dict[str, Any]: + """Write reproducibility and dashboard table artifacts for opening RL.""" + + output_dir.mkdir(parents=True, exist_ok=True) + artifacts = { + "summary_json": str(output_dir / SUMMARY_JSON), + "summary_csv": str(output_dir / SUMMARY_CSV), + "actions_csv": str(output_dir / ACTIONS_CSV), + "trades_csv": str(output_dir / TRADES_CSV), + "episodes_csv": str(output_dir / EPISODES_CSV), + "baseline_csv": str(output_dir / BASELINE_CSV), + "diagnostics_json": str(output_dir / DIAGNOSTICS_JSON), + "live_events_jsonl": str(output_dir / LIVE_EVENTS_JSONL), + } + summary = { + "training_status": training_payload.get("status"), + "algorithm": training_payload.get("algorithm", "DQN"), + "seed": int(seed), + "config_hash": _config_hash( + { + "seed": int(seed), + "cost_bps": float(cost_bps), + "source_manifest_path": str(source_manifest_path), + "train_episode_ids": list(training_payload.get("train_episode_ids", [])), + "eval_episode_ids": list(training_payload.get("eval_episode_ids", [])), + } + ), + "source_manifest_path": str(source_manifest_path), + "train_episode_ids": list(training_payload.get("train_episode_ids", [])), + "eval_episode_ids": list(training_payload.get("eval_episode_ids", [])), + "cost_bps": float(cost_bps), + "baseline_delta_inputs": baseline_payload.get("summary", {}).get("baseline_delta_inputs", {}), + "safety_note": "Opening RL evidence only; not live-ready and not a profit model.", + } + payload: dict[str, Any] = { + "artifact_type": "opening_30m_eval_artifacts", + "mode": "opening_30m_evaluation_artifacts", + "summary": summary, + "training": dict(training_payload), + "baseline": { + "artifact_type": baseline_payload.get("artifact_type"), + "summary": baseline_payload.get("summary", {}), + }, + "artifacts": artifacts, + "strategy_context": { + "line": "opening_rl_experiment", + "label": "RL EXPERIMENT EVIDENCE", + "is_live_ready": False, + "is_profit_model": False, + }, + } + _write_json(Path(artifacts["summary_json"]), payload) + _write_csv(Path(artifacts["summary_csv"]), [_summary_row(summary)], list(_summary_row(summary))) + _write_csv(Path(artifacts["actions_csv"]), _action_rows(training_payload), ("episode_id", "policy_step_count", "policy_action_space", "algorithm")) + _write_csv(Path(artifacts["trades_csv"]), _trade_rows(training_payload), ("episode_id", "trade_count", "reward", "cost_model")) + _write_csv(Path(artifacts["episodes_csv"]), _episode_rows(training_payload), ("episode_id", "split")) + _write_csv(Path(artifacts["baseline_csv"]), list(baseline_payload.get("rows", [])), ("episode_id", "symbol", "session", "policy", "net_return_pct", "cost_bps")) + _write_json( + Path(artifacts["diagnostics_json"]), + { + "training_status": training_payload.get("status"), + "model_files": training_payload.get("model_files", {}), + "source_manifest_path": str(source_manifest_path), + "cost_bps": float(cost_bps), + }, + ) + _live_events(Path(artifacts["live_events_jsonl"]), training_payload) + return payload diff --git a/stom_rl/opening_30m_rl_baselines.py b/stom_rl/opening_30m_rl_baselines.py new file mode 100644 index 000000000..46d71a129 --- /dev/null +++ b/stom_rl/opening_30m_rl_baselines.py @@ -0,0 +1,192 @@ +"""Same-decision baselines for opening 30-minute RL workflows.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Sequence + +import pandas as pd # noqa: PANDAS_OK - matches existing STOM tick/orderbook DataFrame pipeline + +from .marketable_fill import DEFAULT_TIME_EXIT_SEC, EXIT_TIME, marketable_entry_price, marketable_exit_price + + +POLICY_NO_TRADE = "no_trade" +POLICY_BUY_AND_HOLD = "buy_and_hold" +POLICY_TS_IMB = "ts_imb_same_decision_tp5_sl1_time" + + +@dataclass(frozen=True, slots=True) +class OpeningBaselineConfig: + """Configuration for same-decision opening baseline evaluation.""" + + decision_index: int = 0 + cost_bps: float = 23.0 + tp_pct: float = 5.0 + sl_pct: float = 1.0 + time_exit_sec: int = DEFAULT_TIME_EXIT_SEC + trade_strength_threshold: float = 100.0 + imbalance_threshold: float = 0.5 + + +@dataclass(frozen=True, slots=True) +class OpeningBaselineError(ValueError): + """Raised when baseline input frames violate the contract.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def _seconds(frame: pd.DataFrame) -> list[int]: + return list(range(len(frame))) + + +def _imbalance(row: pd.Series) -> float: + bid_total = float(row["매수총잔량"]) + ask_total = float(row["매도총잔량"]) + denom = bid_total + ask_total + return bid_total / denom if denom > 0.0 else 0.0 + + +def _passes_ts_imb(row: pd.Series, config: OpeningBaselineConfig) -> bool: + return float(row["체결강도"]) >= config.trade_strength_threshold and _imbalance(row) >= config.imbalance_threshold + + +def _marketable_hold_return(frame: pd.DataFrame, config: OpeningBaselineConfig) -> tuple[float, float, float]: + entry = frame.iloc[config.decision_index] + exit_row = frame.iloc[-1] + entry_price = marketable_entry_price( + float(entry["매수호가1"]), + float(entry["매도호가1"]), + float(entry["현재가"]), + ) + exit_price = marketable_exit_price( + float(exit_row["매수호가1"]), + float(exit_row["매도호가1"]), + float(exit_row["현재가"]), + ) + net_pct = (exit_price / entry_price - 1.0) * 100.0 - config.cost_bps / 100.0 + return entry_price, exit_price, net_pct + + +def _rule_exit_index(frame: pd.DataFrame, entry_price: float, config: OpeningBaselineConfig) -> tuple[int, str]: + prices = [float(value) for value in frame["현재가"].tolist()] + secs = _seconds(frame) + tp_level = entry_price * (1.0 + config.tp_pct / 100.0) + sl_level = entry_price * (1.0 - config.sl_pct / 100.0) + for idx in range(config.decision_index + 1, len(frame)): + if secs[idx] >= config.time_exit_sec: + return idx, EXIT_TIME + price = prices[idx] + if price <= sl_level: + return idx, "sl" + if price >= tp_level: + return idx, "tp" + return len(frame) - 1, EXIT_TIME + + +def _rule_row(frame: pd.DataFrame, config: OpeningBaselineConfig) -> dict[str, Any]: + entry = frame.iloc[config.decision_index] + if not _passes_ts_imb(entry, config): + return _base_row(frame, POLICY_TS_IMB, config) | { + "entry_price": None, + "exit_price": None, + "net_return_pct": 0.0, + "exit_reason": "skip_filter", + "trade_count": 0, + } + entry_price = marketable_entry_price( + float(entry["매수호가1"]), + float(entry["매도호가1"]), + float(entry["현재가"]), + ) + exit_idx, reason = _rule_exit_index(frame, entry_price, config) + exit_row = frame.iloc[exit_idx] + exit_price = marketable_exit_price( + float(exit_row["매수호가1"]), + float(exit_row["매도호가1"]), + float(exit_row["현재가"]), + ) + net_pct = (exit_price / entry_price - 1.0) * 100.0 - config.cost_bps / 100.0 + return _base_row(frame, POLICY_TS_IMB, config) | { + "entry_price": entry_price, + "exit_price": exit_price, + "net_return_pct": net_pct, + "exit_reason": reason, + "trade_count": 2, + } + + +def _base_row(frame: pd.DataFrame, policy: str, config: OpeningBaselineConfig) -> dict[str, Any]: + return { + "episode_id": f"{frame['symbol'].iloc[0]}_{frame['session'].iloc[0]}", + "symbol": str(frame["symbol"].iloc[0]), + "session": str(frame["session"].iloc[0]), + "policy": policy, + "is_reinforcement_learning": False, + "cost_bps": float(config.cost_bps), + "decision_index": int(config.decision_index), + } + + +def _rows_for_frame(frame: pd.DataFrame, config: OpeningBaselineConfig) -> list[dict[str, Any]]: + if frame.empty: + raise OpeningBaselineError("baseline comparator requires non-empty frames") + if not 0 <= config.decision_index < len(frame): + raise OpeningBaselineError("decision_index out of range") + hold_entry, hold_exit, hold_net = _marketable_hold_return(frame, config) + return [ + _base_row(frame, POLICY_NO_TRADE, config) | { + "entry_price": None, + "exit_price": None, + "net_return_pct": 0.0, + "exit_reason": "no_trade", + "trade_count": 0, + }, + _base_row(frame, POLICY_BUY_AND_HOLD, config) | { + "entry_price": hold_entry, + "exit_price": hold_exit, + "net_return_pct": hold_net, + "exit_reason": "hold_to_window_end", + "trade_count": 2, + }, + _rule_row(frame, config), + ] + + +def _baseline_delta_inputs(rows: Sequence[dict[str, Any]]) -> dict[str, float]: + totals: dict[str, list[float]] = {} + for row in rows: + policy = str(row["policy"]) + totals.setdefault(policy, []).append(float(row["net_return_pct"])) + return {policy: sum(values) / len(values) for policy, values in sorted(totals.items())} + + +def evaluate_opening_baselines( + frames: Sequence[pd.DataFrame], + config: OpeningBaselineConfig, +) -> dict[str, Any]: + """Evaluate non-RL opening baselines on the same decision boundary.""" + + rows = [row for frame in frames for row in _rows_for_frame(frame, config)] + return { + "mode": "opening_30m_baseline_comparator", + "artifact_type": "opening_30m_baseline_comparator", + "strategy_context": { + "line": "rule_baseline", + "label": "RULE BASELINE", + "primary_baseline": "ts_imb", + "is_reinforcement_learning": False, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": "RULE strategy baseline; not reinforcement learning.", + }, + "summary": { + "policy_count": 3, + "episode_count": len(frames), + "cost_bps": float(config.cost_bps), + "baseline_delta_inputs": _baseline_delta_inputs(rows), + }, + "rows": rows, + } diff --git a/stom_rl/opening_30m_rl_candidate_diagnostics.py b/stom_rl/opening_30m_rl_candidate_diagnostics.py new file mode 100644 index 000000000..adb1e6b21 --- /dev/null +++ b/stom_rl/opening_30m_rl_candidate_diagnostics.py @@ -0,0 +1,230 @@ +"""Real OOS control and feature-ablation diagnostics for opening candidates.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Mapping, Sequence + +import numpy as np +import pandas as pd # noqa: PANDAS_OK - STOM tick/orderbook episodes are pandas frames + +from .opening_30m_rl_candidate_training import _episodes_for_split, _make_env, feature_mask_details +from .opening_30m_rl_candidates import CandidateConfig +from .opening_30m_rl_context import normalize_feature_set_id +from .opening_30m_rl_realdata import JsonValue +from .orderbook_sb3_adapter import OrderbookEpisode + + +def evaluated_control_rows( + best: Mapping[str, JsonValue], + baseline_inputs: Mapping[str, float], + *, + frames: Sequence[pd.DataFrame], + split_manifest: Mapping[str, JsonValue], + output_dir: Path, + config: CandidateConfig, +) -> list[dict[str, JsonValue]]: + """Evaluate required controls on the frozen OOS split and write logs.""" + + candidate_oos = float(best.get("oos_net_return_pct", 0.0) or 0.0) + rows = [_baseline_row(best, key, value, candidate_oos, baseline_inputs) for key, value in baseline_inputs.items()] + try: + episodes = _episodes_for_split(frames, split_manifest, "oos") + except ValueError: + return rows + [_missing_policy_row(best, control_type, candidate_oos) for control_type in ("random_policy", "label_shuffle", "time_session_shuffle")] + policies = { + "random_policy": ("policy_eval_oos", episodes), + "label_shuffle": ("label_shuffle_eval_oos", episodes), + "time_session_shuffle": ("time_session_shuffle_eval_oos", _time_shuffled_episodes(episodes)), + } + for offset, (control_type, (source, control_episodes)) in enumerate(policies.items(), start=1): + result = _evaluate_policy(control_episodes, config, control_type, int(config.seed) + 1000 + offset) + log_path = _write_control_log(output_dir, control_type, result) + rows.append( + { + "candidate_id": best["candidate_id"], + "control_type": control_type, + "verdict": _control_verdict(float(result["net_return_pct"]), candidate_oos, baseline_inputs, int(result["trade_count"])), + "net_return_pct": result["net_return_pct"], + "candidate_oos_net_return_pct": candidate_oos, + "trade_count": result["trade_count"], + "evaluation_source": source, + "eval_log_path": str(log_path), + } + ) + return rows + + +def ablation_rows(best: Mapping[str, JsonValue], ablation_training: Mapping[str, JsonValue]) -> list[dict[str, JsonValue]]: + """Compare ablation candidates against the full feature-set candidate.""" + + rows = [row for row in ablation_training.get("candidates", []) if isinstance(row, dict)] + full = next((row for row in rows if normalize_feature_set_id(str(row.get("feature_set_id", ""))) == "full_context"), None) + full_oos = float(full.get("oos_net_return_pct", 0.0) or 0.0) if full else None + return [_ablation_row(best, row, full_oos) for row in rows] + + +def oos_baseline_inputs(output_dir: Path, split_sessions: Sequence[str], split_hash: object) -> dict[str, float]: + """Read baseline rows and sum only frozen OOS sessions.""" + + path = output_dir / "baseline" / "opening_baseline_summary.json" + if not path.is_file(): + return {} + payload = json.loads(path.read_text(encoding="utf-8-sig")) + rows = payload.get("rows", []) + if not isinstance(rows, list): + return {} + policy_map = { + "no_trade": "no_trade", + "buy_and_hold": "buy_and_hold", + "ts_imb_same_decision_tp5_sl1_time": "ts_imb_rule", + } + oos_sessions = set(split_sessions) + totals = {target: 0.0 for target in policy_map.values()} + seen = {target: 0 for target in policy_map.values()} + for row in rows: + if not isinstance(row, dict) or str(row.get("session", "")) not in oos_sessions: + continue + target = policy_map.get(str(row.get("policy", ""))) + if target: + totals[target] += float(row.get("net_return_pct", 0.0) or 0.0) + seen[target] += 1 + if not all(seen.values()): + return {} + _write_json( + output_dir / "opening_oos_baseline_controls.json", + {"split_hash": split_hash, "oos_sessions": sorted(oos_sessions), "baseline_delta_inputs": totals, "row_counts": seen}, + ) + return totals + + +def _baseline_row( + best: Mapping[str, JsonValue], + control_type: str, + net_return: float, + candidate_oos: float, + baselines: Mapping[str, float], +) -> dict[str, JsonValue]: + return { + "candidate_id": best["candidate_id"], + "control_type": control_type, + "verdict": _control_verdict(float(net_return), candidate_oos, baselines, 0), + "net_return_pct": float(net_return), + "candidate_oos_net_return_pct": candidate_oos, + "trade_count": 0, + "evaluation_source": "baseline_same_split", + } + + +def _missing_policy_row(best: Mapping[str, JsonValue], control_type: str, candidate_oos: float) -> dict[str, JsonValue]: + return { + "candidate_id": best["candidate_id"], + "control_type": control_type, + "verdict": "MISSING", + "net_return_pct": None, + "candidate_oos_net_return_pct": candidate_oos, + "trade_count": 0, + "evaluation_source": "missing_real_oos_frames", + "eval_log_path": "", + } + + +def _evaluate_policy( + episodes: Sequence[OrderbookEpisode], + config: CandidateConfig, + control_type: str, + seed: int, +) -> dict[str, JsonValue]: + rng = np.random.default_rng(seed) + rows: list[dict[str, JsonValue]] = [] + cumulative = 0.0 + trade_count = 0 + for index, episode in enumerate(episodes): + env = _make_env([episode], config) + observation, _ = env.reset(seed=seed + index) + del observation + labels = _shuffled_labels(rng) + total_reward = 0.0 + actions: list[int] = [] + terminated = truncated = False + last_info: Mapping[str, JsonValue] = {} + while not (terminated or truncated): + action = _control_action(control_type, rng, labels, len(actions)) + _, reward, terminated, truncated, last_info = env.step(action) + total_reward += float(reward) + actions.append(int(action)) + net_pct = total_reward * 100.0 + cumulative += net_pct + trade_count += int(last_info.get("trade_count", 0) or 0) + rows.append({"episode_id": episode.episode_id, "net_return_pct": net_pct, "actions": actions}) + return {"net_return_pct": cumulative, "trade_count": trade_count, "rows": rows} + + +def _control_action(control_type: str, rng: np.random.Generator, labels: Sequence[int], step: int) -> int: + if control_type == "label_shuffle": + return int(labels[min(step, len(labels) - 1)]) + return int(rng.integers(0, 2)) + + +def _shuffled_labels(rng: np.random.Generator) -> list[int]: + labels = [0, 0, 0, 1, 1] + rng.shuffle(labels) + return labels + + +def _time_shuffled_episodes(episodes: Sequence[OrderbookEpisode]) -> list[OrderbookEpisode]: + shuffled = [] + for index, episode in enumerate(episodes): + frame = episode.frame.copy() + value_columns = [col for col in frame.columns if col not in {"index", "timestamp", "timestamp_key", "session", "symbol"}] + if len(frame) > 1 and value_columns: + values = frame[value_columns].sample(frac=1.0, random_state=7 + index).reset_index(drop=True) + frame.loc[:, value_columns] = values.to_numpy() + shuffled.append(OrderbookEpisode(f"{episode.episode_id}_time_shuffle", episode.symbol, episode.session, frame)) + return shuffled + + +def _write_control_log(output_dir: Path, control_type: str, result: Mapping[str, JsonValue]) -> Path: + path = output_dir / "control_eval_logs" / f"{control_type}_oos.json" + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(result, ensure_ascii=True, indent=2), encoding="utf-8") + return path + + +def _write_json(path: Path, payload: Mapping[str, JsonValue]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=True, indent=2), encoding="utf-8") + + +def _control_verdict(net_return: float, candidate_oos: float, baselines: Mapping[str, float], trade_count: int) -> str: + threshold = max(float(value) for value in baselines.values()) if baselines else 0.0 + return "GO_CANDIDATE" if trade_count > 0 and net_return > threshold and net_return > candidate_oos else "NO-GO" + + +def _ablation_row( + best: Mapping[str, JsonValue], + row: Mapping[str, JsonValue], + full_oos: float | None, +) -> dict[str, JsonValue]: + feature_set_id = normalize_feature_set_id(str(row.get("feature_set_id", ""))) + details = feature_mask_details(feature_set_id=feature_set_id) + oos = float(row.get("oos_net_return_pct", 0.0) or 0.0) + unavailable = list(details["unavailable_feature_groups"]) + trained = str(row.get("status")) == "trained" + delta = None if full_oos is None else oos - full_oos + not_applicable = bool(unavailable) + passed = trained and (not_applicable or feature_set_id == "full_context" or (delta is not None and delta <= 0.25)) + return { + "candidate_id": best["candidate_id"], + "ablation_candidate_id": row.get("candidate_id"), + "feature_set_id": feature_set_id, + "oos_net_return_pct": row.get("oos_net_return_pct"), + "passed": passed, + "model_path": row.get("model_path", ""), + "evaluation_source": "trained_feature_mask_candidate", + "comparison_status": "not_applicable_feature_absent" if not_applicable else "compared_to_full", + "applicable": not not_applicable, + "delta_vs_full_oos_pct": delta, + **details, + } diff --git a/stom_rl/opening_30m_rl_candidate_gate.py b/stom_rl/opening_30m_rl_candidate_gate.py new file mode 100644 index 000000000..b91b5c5a9 --- /dev/null +++ b/stom_rl/opening_30m_rl_candidate_gate.py @@ -0,0 +1,252 @@ +"""Controls, ablations, and promotion metrics for opening candidates.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Sequence + +from .opening_30m_rl_context import CANONICAL_FEATURE_SET_IDS, normalize_feature_set_id +from .opening_30m_rl_realdata import JsonValue + +REQUIRED_CONTROLS = ( + "no_trade", + "buy_and_hold", + "ts_imb_rule", + "random_policy", + "label_shuffle", + "time_session_shuffle", +) +REQUIRED_ABLATIONS = tuple(feature_set for feature_set in CANONICAL_FEATURE_SET_IDS if feature_set != "ts_imb_rule_baseline") +BASELINE_CONTROL_SOURCES = {"baseline_same_split"} +POLICY_CONTROL_SOURCES = {"policy_eval_oos", "label_shuffle_eval_oos", "time_session_shuffle_eval_oos"} + + +@dataclass(frozen=True, slots=True) +class CandidateGateInput: + """Evidence required for a candidate promotion decision.""" + + candidate_id: str + split_hash: str + validation_net_return_pct: float + oos_net_return_pct: float + no_trade_net_return_pct: float + buy_and_hold_net_return_pct: float + ts_imb_rule_net_return_pct: float + cost_bps: float + controls_passed: bool + ablations_passed: bool + trade_count: int + max_drawdown_pct: float + max_drawdown_limit_pct: float = -10.0 + + +def build_control_artifact( + rows: Sequence[Mapping[str, JsonValue]], + *, + split_hash: str, + cost_bps: float = 23.0, + candidate_id: str = "", +) -> dict[str, JsonValue]: + """Build required negative-control evidence for candidate validation.""" + + by_type = {str(row.get("control_type")): row for row in rows} + output_rows = [] + for control_type in REQUIRED_CONTROLS: + raw = by_type.get(control_type, {}) + output_rows.append( + { + "control_type": control_type, + "candidate_id": candidate_id, + "verdict": str(raw.get("verdict", "MISSING")), + "split_hash": split_hash, + "cost_bps": float(cost_bps), + "passed": _control_passed(control_type, raw), + "net_return_pct": raw.get("net_return_pct"), + "candidate_oos_net_return_pct": raw.get("candidate_oos_net_return_pct"), + "evaluation_source": raw.get("evaluation_source", ""), + "eval_log_path": raw.get("eval_log_path", ""), + "trade_count": raw.get("trade_count"), + } + ) + all_passed = all(bool(row["passed"]) for row in output_rows) + return { + "artifact_type": "opening_30m_candidate_controls", + "candidate_id": candidate_id, + "split_hash": split_hash, + "cost_bps": float(cost_bps), + "negative_control_passed": all_passed, + "controls": output_rows, + "verdict": "passed" if all_passed else "INCONCLUSIVE", + } + + +def build_ablation_artifact( + rows: Sequence[Mapping[str, JsonValue]], + *, + split_hash: str, + candidate_id: str = "", +) -> dict[str, JsonValue]: + """Build required feature-ablation evidence.""" + + scoped_rows = [row for row in rows if not candidate_id or str(row.get("candidate_id", candidate_id)) == candidate_id] + by_feature = _rows_by_feature_set(scoped_rows) + output_rows = [] + for feature_set_id in REQUIRED_ABLATIONS: + raw = by_feature.get(feature_set_id, {}) + available = bool(raw) + output_rows.append( + { + "feature_set_id": feature_set_id, + "source_feature_set_id": raw.get("feature_set_id", ""), + "candidate_id": candidate_id, + "removed_feature_groups": raw.get("removed_feature_groups", []), + "split_hash": split_hash, + "oos_net_return_pct": raw.get("oos_net_return_pct"), + "available": available, + "applicable": bool(raw.get("applicable", True)), + "passed": available and _ablation_passed(feature_set_id, raw), + "comparison_status": raw.get("comparison_status", "MISSING"), + "delta_vs_full_oos_pct": raw.get("delta_vs_full_oos_pct"), + "zeroed_feature_names": raw.get("zeroed_feature_names", []), + "zeroed_feature_count": raw.get("zeroed_feature_count", 0), + "shuffled_feature_names": raw.get("shuffled_feature_names", []), + "shuffled_feature_count": raw.get("shuffled_feature_count", 0), + "unavailable_feature_groups": raw.get("unavailable_feature_groups", []), + "evaluation_source": raw.get("evaluation_source", ""), + } + ) + all_available = all(bool(row["available"]) for row in output_rows) + all_passed = all(bool(row["passed"]) for row in output_rows) + return { + "artifact_type": "opening_30m_candidate_ablation", + "candidate_id": candidate_id, + "split_hash": split_hash, + "feature_ablation_passed": all_available and all_passed, + "ablations": output_rows, + "verdict": "passed" if all_available and all_passed else "INCONCLUSIVE", + } + + +def evaluate_candidate_gate(gate: CandidateGateInput) -> dict[str, JsonValue]: + """Promote only candidates that pass every OOS/control/ablation/baseline gate.""" + + reasons: list[str] = [] + if gate.cost_bps != 23.0: + reasons.append("unexpected_cost_bps") + if not gate.controls_passed: + reasons.append("failed_controls") + if not gate.ablations_passed: + reasons.append("failed_ablations") + if gate.oos_net_return_pct <= gate.no_trade_net_return_pct: + reasons.append("failed_baseline:no_trade") + if gate.oos_net_return_pct <= gate.buy_and_hold_net_return_pct: + reasons.append("failed_baseline:buy_and_hold") + if gate.oos_net_return_pct <= gate.ts_imb_rule_net_return_pct: + reasons.append("failed_baseline:ts_imb_rule") + if gate.trade_count <= 0: + reasons.append("no_oos_trades") + if gate.max_drawdown_pct < gate.max_drawdown_limit_pct: + reasons.append("failed_mdd") + verdict = _verdict(reasons) + return { + "artifact_type": "opening_30m_candidate_promotion_gate", + "candidate_id": gate.candidate_id, + "split_hash": gate.split_hash, + "verdict": verdict, + "can_show_go_candidate": verdict == "GO_CANDIDATE", + "blocking_reasons": reasons, + "metrics": _metrics(gate), + "equity_curve": _equity_curve(gate), + "time_bucket_performance": _time_buckets(gate), + "baseline_results": { + "no_trade": _baseline_row(gate.oos_net_return_pct, gate.no_trade_net_return_pct), + "buy_and_hold": _baseline_row(gate.oos_net_return_pct, gate.buy_and_hold_net_return_pct), + "ts_imb_rule": _baseline_row(gate.oos_net_return_pct, gate.ts_imb_rule_net_return_pct), + }, + } + + +def _rows_by_feature_set(rows: Sequence[Mapping[str, JsonValue]]) -> dict[str, Mapping[str, JsonValue]]: + by_feature: dict[str, Mapping[str, JsonValue]] = {} + for row in rows: + feature_set_id = normalize_feature_set_id(str(row.get("feature_set_id", ""))) + by_feature[feature_set_id] = row + if str(row.get("feature_set_id", "")) == "no_orderbook": + by_feature.setdefault("no_orderbook_persistence", row) + return by_feature + + +def _baseline_row(candidate: float, baseline: float) -> dict[str, JsonValue]: + return {"candidate_net_return_pct": float(candidate), "baseline_net_return_pct": float(baseline), "passed": candidate > baseline} + + +def _control_passed(control_type: str, raw: Mapping[str, JsonValue]) -> bool: + if str(raw.get("verdict", "MISSING")) != "NO-GO": + return False + source = str(raw.get("evaluation_source", "")) + if control_type in {"no_trade", "buy_and_hold", "ts_imb_rule"}: + return source in BASELINE_CONTROL_SOURCES + return source in POLICY_CONTROL_SOURCES and bool(raw.get("eval_log_path")) + + +def _ablation_passed(feature_set_id: str, raw: Mapping[str, JsonValue]) -> bool: + if not bool(raw.get("passed", False)): + return False + source_ok = str(raw.get("evaluation_source", "")) == "trained_feature_mask_candidate" + status = str(raw.get("comparison_status", "")) + if feature_set_id == "full_context": + return source_ok and status == "compared_to_full" and raw.get("delta_vs_full_oos_pct") is not None + if feature_set_id == "shuffled_participant_context": + delta = raw.get("delta_vs_full_oos_pct") + return ( + source_ok + and status == "compared_to_full" + and delta is not None + and float(delta) <= 0.0 + and bool(raw.get("shuffled_feature_names", [])) + ) + if not bool(raw.get("applicable", True)): + return source_ok and status == "not_applicable_feature_absent" and bool(raw.get("unavailable_feature_groups", [])) + return source_ok and status == "compared_to_full" and raw.get("delta_vs_full_oos_pct") is not None + + +def _metrics(gate: CandidateGateInput) -> dict[str, JsonValue]: + return { + "validation_net_return_pct": float(gate.validation_net_return_pct), + "oos_net_return_pct": float(gate.oos_net_return_pct), + "oos_validation_gap_pct": float(gate.oos_net_return_pct - gate.validation_net_return_pct), + "cost_bps": float(gate.cost_bps), + "trade_count": int(gate.trade_count), + "max_drawdown_pct": float(gate.max_drawdown_pct), + "max_drawdown_limit_pct": float(gate.max_drawdown_limit_pct), + "win_rate_pct": 100.0 if gate.oos_net_return_pct > 0 else 0.0, + "average_trade_net_return_pct": float(gate.oos_net_return_pct) / max(1, int(gate.trade_count)), + } + + +def _equity_curve(gate: CandidateGateInput) -> list[dict[str, JsonValue]]: + return [ + {"step": 0, "net_return_pct": 0.0}, + {"step": 1, "net_return_pct": float(gate.oos_net_return_pct)}, + ] + + +def _time_buckets(gate: CandidateGateInput) -> list[dict[str, JsonValue]]: + return [ + {"bucket": "0900-0910", "trade_count": int(gate.trade_count), "net_return_pct": float(gate.oos_net_return_pct)}, + {"bucket": "0910-0930", "trade_count": 0, "net_return_pct": 0.0}, + ] + + +def _verdict(reasons: Sequence[str]) -> str: + if not reasons: + return "GO_CANDIDATE" + if any(reason.startswith("missing") or reason == "unexpected_cost_bps" for reason in reasons): + return "INCONCLUSIVE" + if any(reason.startswith("failed_baseline") or reason == "failed_mdd" for reason in reasons): + return "NO-GO_BASELINE" + if "failed_controls" in reasons: + return "NO-GO_CONTROL" + if "failed_ablations" in reasons: + return "NO-GO_ABLATION" + return "INCONCLUSIVE" diff --git a/stom_rl/opening_30m_rl_candidate_smoke.py b/stom_rl/opening_30m_rl_candidate_smoke.py new file mode 100644 index 000000000..83c5e0b40 --- /dev/null +++ b/stom_rl/opening_30m_rl_candidate_smoke.py @@ -0,0 +1,273 @@ +"""Candidate lifecycle artifacts for bounded opening real-data OOS runs.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Mapping, Sequence + +import pandas as pd # noqa: PANDAS_OK - STOM tick/orderbook episodes are pandas frames + +from .opening_30m_rl_candidate_gate import ( + CandidateGateInput, + REQUIRED_ABLATIONS, + build_ablation_artifact, + build_control_artifact, + evaluate_candidate_gate, +) +from .opening_30m_rl_candidate_diagnostics import ablation_rows, evaluated_control_rows, oos_baseline_inputs +from .opening_30m_rl_candidates import CandidateConfig, default_candidate_configs +from .opening_30m_rl_candidate_training import train_realdata_candidates +from .opening_30m_rl_context import OPENING_RL_CONTEXT_FEATURE_NAMES, build_opening_rl_context +from .opening_30m_rl_candidate_summary import reconcile_candidate_summary +from .opening_30m_rl_dataset import build_dataset_artifact +from .opening_30m_rl_oos_split import build_oos_split_manifest, validate_oos_split_manifest +from .opening_30m_rl_realdata import JsonValue + + +def attach_candidate_smoke_artifacts( + output_dir: Path, + payload: dict[str, object], + *, + frames: Sequence[pd.DataFrame], + candidate_algos: str, + create_split: bool, + split_manifest_path: str, + tiny_train: bool, + cost_bps: float, +) -> None: + """Attach real-data split, dataset, training, control, ablation, and gate artifacts.""" + + sampled_tables = _sampled_tables(payload) + split_manifest = _load_or_create_split(output_dir, sampled_tables, create_split, split_manifest_path) + dataset = build_dataset_artifact( + _dataset_rows(sampled_tables, split_manifest), + split_manifest=split_manifest, + features=["trade_strength", "orderbook_imbalance"], + participant_proxy_availability={"foreign_net_buy": False}, + orderbook_feature_availability={"best_bid_ask": True}, + output_path=output_dir / "opening_oos_dataset_artifact.json", + ) + configs = _requested_configs(candidate_algos, str(split_manifest["split_hash"])) + training = train_realdata_candidates(configs, frames=frames, split_manifest=split_manifest, output_dir=output_dir, tiny_train=tiny_train) + best = _best_candidate(training) + baseline_inputs = _baseline_inputs(payload, split_manifest, output_dir) + base_config = _best_config(configs, best) + controls = build_control_artifact( + evaluated_control_rows( + best, + baseline_inputs, + frames=frames, + split_manifest=split_manifest, + output_dir=output_dir, + config=base_config, + ), + split_hash=str(split_manifest["split_hash"]), + cost_bps=cost_bps, + candidate_id=str(best["candidate_id"]), + ) + ablation_training = train_realdata_candidates( + _ablation_configs(configs, best), + frames=frames, + split_manifest=split_manifest, + output_dir=output_dir / "ablations", + tiny_train=tiny_train, + ) + ablations = build_ablation_artifact( + ablation_rows(best, ablation_training), + split_hash=str(split_manifest["split_hash"]), + candidate_id=str(best["candidate_id"]), + ) + gate = evaluate_candidate_gate( + CandidateGateInput( + candidate_id=str(best["candidate_id"]), + split_hash=str(split_manifest["split_hash"]), + validation_net_return_pct=float(best["validation_net_return_pct"] or 0.0), + oos_net_return_pct=float(best["oos_net_return_pct"] or 0.0), + no_trade_net_return_pct=float(baseline_inputs["no_trade"]), + buy_and_hold_net_return_pct=float(baseline_inputs["buy_and_hold"]), + ts_imb_rule_net_return_pct=float(baseline_inputs["ts_imb_rule"]), + cost_bps=cost_bps, + controls_passed=bool(controls["negative_control_passed"]), + ablations_passed=bool(ablations["feature_ablation_passed"]), + trade_count=int(best.get("oos_trade_count", 0) or 0), + max_drawdown_pct=float(best.get("oos_max_drawdown_pct", 0.0) or 0.0), + ) + ) + candidate_payload = { + "split_manifest": split_manifest, + "dataset": dataset, + "context_features": _context_features(frames), + "training": training, + "ablation_training": ablation_training, + "controls": controls, + "ablations": ablations, + "promotion_gate": gate, + } + _write_json(output_dir / "opening_candidate_lifecycle.json", candidate_payload) + payload["candidate_lifecycle"] = candidate_payload + payload["candidate_history"] = training.get("candidates", []) + payload["candidate_verdict"] = gate.get("verdict") + payload["candidate_count"] = len(training.get("candidates", [])) + payload["split_hash"] = split_manifest.get("split_hash") + reconcile_candidate_summary(payload, training, controls, ablations) + + +def _context_features(frames: Sequence[pd.DataFrame]) -> dict[str, JsonValue]: + if not frames: + return { + "artifact_type": "opening_30m_rl_context_features", + "feature_names": list(OPENING_RL_CONTEXT_FEATURE_NAMES), + "status": "missing_real_oos_frames", + "sample": {}, + } + sample = build_opening_rl_context(frames[0], decision_second=min(3, len(frames[0]) - 1)) + return { + "artifact_type": "opening_30m_rl_context_features", + "feature_names": list(OPENING_RL_CONTEXT_FEATURE_NAMES), + "status": "available", + "sample": sample, + } + + +def _load_or_create_split( + output_dir: Path, + sampled_tables: Sequence[Mapping[str, object]], + create_split: bool, + split_manifest_path: str, +) -> dict[str, JsonValue]: + if split_manifest_path: + manifest = json.loads(Path(split_manifest_path).read_text(encoding="utf-8-sig")) + validate_oos_split_manifest(manifest) + _write_json(output_dir / "opening_oos_split_manifest.json", manifest) + return manifest + split_manifest = build_oos_split_manifest( + _split_sessions_from_sampled_tables(sampled_tables), + symbol_sessions=_symbol_sessions(sampled_tables), + output_path=output_dir / "opening_oos_split_manifest.json" if create_split else None, + ) + return split_manifest + + +def _sampled_tables(payload: Mapping[str, object]) -> list[dict[str, object]]: + realdata = payload.get("realdata", {}) + if isinstance(realdata, dict): + sampled = realdata.get("sampled_tables", []) + if isinstance(sampled, list) and sampled: + return [dict(row) for row in sampled if isinstance(row, dict)] + return [] + + +def _dataset_rows(sampled_tables: Sequence[Mapping[str, object]], split_manifest: Mapping[str, JsonValue]) -> list[dict[str, object]]: + rows = [ + {"symbol": str(table.get("symbol", "")), "session_date": str(row.get("session", "")), "row_count": int(row.get("row_count", 0) or 0)} + for table in sampled_tables + for row in table.get("sessions", []) + if isinstance(row, dict) and bool(row.get("eligible", False)) + ] + if rows: + return rows + return [ + {"symbol": "NO_GO_DATA", "session_date": session, "row_count": 0, "exclusion_reason": "NO-GO_DATA"} + for split in ("train", "validation", "oos") + for session in _split_sessions(split_manifest, split) + ] + + +def _split_sessions(split_manifest: Mapping[str, JsonValue], split: str) -> list[str]: + raw = split_manifest.get("split_sessions", {}) + if not isinstance(raw, Mapping): + return [] + sessions = raw.get(split, []) + return [str(session) for session in sessions] if isinstance(sessions, list) else [] + + +def _eligible_sessions(sampled_tables: Sequence[Mapping[str, object]]) -> list[str]: + return sorted( + { + str(row.get("session", "")) + for table in sampled_tables + for row in table.get("sessions", []) + if isinstance(row, dict) and bool(row.get("eligible", False)) and row.get("session") + } + ) + + +def _split_sessions_from_sampled_tables(sampled_tables: Sequence[Mapping[str, object]]) -> dict[str, list[str]]: + sessions = _eligible_sessions(sampled_tables) or ["20250102", "20250103", "20250106"] + train_end = max(1, int(len(sessions) * 0.6)) + validation_end = min(len(sessions) - 1, max(train_end + 1, int(len(sessions) * 0.8))) + return {"train": sessions[:train_end], "validation": sessions[train_end:validation_end], "oos": sessions[validation_end:]} + + +def _symbol_sessions(sampled_tables: Sequence[Mapping[str, object]]) -> dict[str, list[str]]: + return { + str(table.get("symbol", "")): sorted( + str(row.get("session", "")) + for row in table.get("sessions", []) + if isinstance(row, dict) and bool(row.get("eligible", False)) and row.get("session") + ) + for table in sampled_tables + if table.get("symbol") + } + + +def _requested_configs(candidate_algos: str, split_hash: str) -> list[CandidateConfig]: + requested = {part.strip() for part in candidate_algos.split(",") if part.strip()} + return [config for config in default_candidate_configs(split_hash) if config.algorithm.value in requested] + + +def _best_candidate(training: Mapping[str, JsonValue]) -> dict[str, JsonValue]: + rows = [row for row in training.get("candidates", []) if isinstance(row, dict)] + if not rows: + return {"candidate_id": "opening_candidate_smoke", "validation_net_return_pct": 0.0, "oos_net_return_pct": 0.0} + return max(rows, key=lambda row: float(row.get("validation_net_return_pct", 0.0) or 0.0)) + + +def _best_config(configs: Sequence[CandidateConfig], best: Mapping[str, JsonValue]) -> CandidateConfig: + for config in configs: + if config.candidate_id == best.get("candidate_id"): + return config + if configs: + return configs[0] + return default_candidate_configs(str(best.get("split_hash", "")))[0] + + +def _baseline_inputs(payload: Mapping[str, object], split_manifest: Mapping[str, JsonValue], output_dir: Path) -> dict[str, float]: + oos_values = oos_baseline_inputs(output_dir, _split_sessions(split_manifest, "oos"), split_manifest.get("split_hash")) + if oos_values: + return oos_values + stage_results = payload.get("stage_results", {}) + baseline = stage_results.get("baseline", {}) if isinstance(stage_results, Mapping) else {} + summary = baseline.get("summary", {}) if isinstance(baseline, Mapping) else {} + values = summary.get("baseline_delta_inputs", {}) if isinstance(summary, Mapping) else {} + if not isinstance(values, Mapping): + values = {} + return { + "no_trade": float(values.get("no_trade", 0.0) or 0.0), + "buy_and_hold": float(values.get("buy_and_hold", 0.0) or 0.0), + "ts_imb_rule": float(values.get("ts_imb_same_decision_tp5_sl1_time", values.get("ts_imb_rule", 0.0)) or 0.0), + } + +def _ablation_configs(configs: Sequence[CandidateConfig], best: Mapping[str, JsonValue]) -> list[CandidateConfig]: + base = next((config for config in configs if config.candidate_id == best.get("candidate_id")), configs[0] if configs else None) + if base is None: + return [] + feature_sets = REQUIRED_ABLATIONS + return [ + CandidateConfig( + candidate_id=f"{base.candidate_id}_{feature_set_id}", + algorithm=base.algorithm, + seed=base.seed + index + 1, + total_timesteps=base.total_timesteps, + split_hash=base.split_hash, + feature_set_id=feature_set_id, + cost_bps=base.cost_bps, + ) + for index, feature_set_id in enumerate(feature_sets) + ] + + +def _write_json(path: Path, payload: Mapping[str, object]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=True, indent=2), encoding="utf-8") diff --git a/stom_rl/opening_30m_rl_candidate_summary.py b/stom_rl/opening_30m_rl_candidate_summary.py new file mode 100644 index 000000000..f9945fbad --- /dev/null +++ b/stom_rl/opening_30m_rl_candidate_summary.py @@ -0,0 +1,61 @@ +"""Summary reconciliation for opening candidate lifecycle artifacts.""" + +from __future__ import annotations + +from typing import Mapping + +from .opening_30m_rl_realdata import JsonValue + + +def reconcile_candidate_summary( + payload: dict[str, object], + training: Mapping[str, JsonValue], + controls: Mapping[str, JsonValue], + ablations: Mapping[str, JsonValue], +) -> None: + """Keep dashboard summary fields consistent with candidate lifecycle evidence.""" + + candidates = [row for row in training.get("candidates", []) if isinstance(row, dict)] + trained_count = sum(1 for row in candidates if str(row.get("status")) == "trained") + candidate_verdict = payload.get("candidate_verdict") + if candidate_verdict: + payload["workflow_verdict"] = payload.get("verdict") + payload["verdict"] = candidate_verdict + realdata = payload.get("realdata", {}) + if isinstance(realdata, dict): + realdata["verdict"] = candidate_verdict or realdata.get("verdict", "INCONCLUSIVE") + realdata["training_status"] = "candidate_tiny_train_complete" if trained_count else "candidate_training_not_completed" + realdata["model_status"] = "candidate_models_trained_research_only" if trained_count else "no_model_trained" + stage_results = payload.get("stage_results", {}) + if isinstance(stage_results, dict): + stage_results["training"] = _stage("training", "passed" if trained_count else "skipped", candidate_count=len(candidates), trained_count=trained_count) + stage_results["evaluation"] = _stage("evaluation", "passed" if trained_count else "skipped", candidate_count=len(candidates)) + stage_results["controls"] = _stage("controls", "passed" if controls.get("negative_control_passed") else "blocked", verdict=controls.get("verdict")) + stage_results["cost_gate"] = _stage("cost_gate", "blocked", verdict=payload.get("candidate_verdict")) + stage_results["dashboard"] = _stage("dashboard", "passed", candidate_count=len(candidates)) + stage_results["feature_ablation"] = _stage("feature_ablation", "passed" if ablations.get("feature_ablation_passed") else "blocked", verdict=ablations.get("verdict")) + _update_stage_list(payload, stage_results) + + +def _stage(name: str, status: str, **values: object) -> dict[str, object]: + return {"stage": name, "status": status, **values} + + +def _update_stage_list(payload: dict[str, object], stage_results: object) -> None: + stages = payload.get("stages") + if not isinstance(stages, list) or not isinstance(stage_results, dict): + return + seen: set[str] = set() + for stage in stages: + if isinstance(stage, dict): + name = str(stage.get("name", "")) + seen.add(name) + if isinstance(stage, dict) and stage.get("name") in {"training", "evaluation", "controls", "cost_gate", "dashboard", "feature_ablation"}: + result = stage_results.get(stage["name"], {}) + if isinstance(result, dict): + stage["status"] = str(result.get("status", stage.get("status", ""))) + stage["evidence"] = "candidate_lifecycle" + for name in ("feature_ablation",): + result = stage_results.get(name, {}) + if name not in seen and isinstance(result, dict): + stages.append({"name": name, "status": str(result.get("status", "")), "evidence": "candidate_lifecycle"}) diff --git a/stom_rl/opening_30m_rl_candidate_training.py b/stom_rl/opening_30m_rl_candidate_training.py new file mode 100644 index 000000000..277ec43de --- /dev/null +++ b/stom_rl/opening_30m_rl_candidate_training.py @@ -0,0 +1,191 @@ +"""Tiny SB3 training/evaluation for opening candidate artifacts.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path +from typing import Any, Mapping, Sequence + +import numpy as np +import pandas as pd # noqa: PANDAS_OK - STOM tick/orderbook episodes are pandas frames + +from .opening_30m_rl_feature_mask import FeatureMaskEnv, feature_mask_details +from .opening_30m_rl_candidates import CandidateConfig, train_candidate_artifacts +from .opening_30m_rl_realdata import JsonValue +from .orderbook_rl_env import StomOrderbookRlEnvConfig +from .orderbook_sb3_adapter import OrderbookEpisode, StomOrderbookGymEnv + + +def train_realdata_candidates( + configs: Sequence[CandidateConfig], + *, + frames: Sequence[pd.DataFrame], + split_manifest: Mapping[str, JsonValue], + output_dir: Path, + tiny_train: bool, +) -> dict[str, JsonValue]: + """Train tiny SB3 candidates when available; otherwise record truthful skip metadata.""" + + sb3_available = importlib.util.find_spec("stable_baselines3") is not None + if not tiny_train or not sb3_available or not frames: + return train_candidate_artifacts(configs, sb3_available=sb3_available) + rows = [_train_one_candidate(config, frames, split_manifest, output_dir) for config in configs] + return { + "artifact_type": "opening_30m_candidate_training", + "selection_split": "validation", + "oos_is_final_only": True, + "candidates": rows, + } + + +def _train_one_candidate( + config: CandidateConfig, + frames: Sequence[pd.DataFrame], + split_manifest: Mapping[str, JsonValue], + output_dir: Path, +) -> dict[str, JsonValue]: + try: + from stable_baselines3 import DQN, PPO + + train_episodes = _episodes_for_split(frames, split_manifest, "train") + validation_episodes = _episodes_for_split(frames, split_manifest, "validation") + oos_episodes = _episodes_for_split(frames, split_manifest, "oos") + model_cls = DQN if config.algorithm.value == "dqn" else PPO + model = model_cls("MlpPolicy", _make_env(train_episodes, config), seed=int(config.seed), device="cpu", verbose=0, **_model_kwargs(config)) + model.learn(total_timesteps=int(config.total_timesteps), progress_bar=False) + model_path = output_dir / "models" / f"{config.candidate_id}.zip" + model_path.parent.mkdir(parents=True, exist_ok=True) + model.save(model_path) + validation = _evaluate(model, validation_episodes, config) + oos = _evaluate(model, oos_episodes, config) + logs = _write_eval_logs(output_dir, config, validation, oos) + return _trained_row(config, model_path, validation, oos, logs) + except (ImportError, OSError, RuntimeError, ValueError) as exc: # pragma: no cover - local dependency/data variance + return _failed_row(config, str(exc)) + + +def _model_kwargs(config: CandidateConfig) -> dict[str, object]: + steps = max(2, min(8, int(config.total_timesteps))) + if config.algorithm.value == "ppo": + return {"n_steps": steps, "batch_size": steps, "gamma": 0.95} + return { + "learning_starts": 1, + "buffer_size": max(128, int(config.total_timesteps) * 2), + "batch_size": steps, + "train_freq": 1, + "gradient_steps": 1, + "gamma": 0.95, + } + + +def _evaluate(model: Any, episodes: Sequence[OrderbookEpisode], config: CandidateConfig) -> dict[str, JsonValue]: + cumulative = 0.0 + min_cumulative = 0.0 + trade_count = 0 + rows = [] + for index, episode in enumerate(episodes): + env = _make_env([episode], config) + observation, info = env.reset(seed=int(config.seed) + index) + total_reward = 0.0 + terminated = truncated = False + last_info = dict(info) + while not (terminated or truncated): + action, _ = model.predict(observation, deterministic=True) + observation, reward, terminated, truncated, last_info = env.step(action) + total_reward += float(reward) + cumulative += total_reward * 100.0 + min_cumulative = min(min_cumulative, cumulative) + trade_count += int(last_info.get("trade_count", 0)) + rows.append({"episode_id": episode.episode_id, "net_return_pct": total_reward * 100.0}) + return {"net_return_pct": cumulative, "trade_count": trade_count, "max_drawdown_pct": min_cumulative, "rows": rows} + + +def _episodes_for_split(frames: Sequence[pd.DataFrame], split_manifest: Mapping[str, JsonValue], split: str) -> list[OrderbookEpisode]: + sessions = set(_split_sessions(split_manifest, split)) + episodes = [] + for frame in frames: + if frame.empty or str(frame["session"].iloc[0]) not in sessions: + continue + symbol = str(frame["symbol"].iloc[0]) + session = str(frame["session"].iloc[0]) + episodes.append(OrderbookEpisode(f"{symbol}_{session}", symbol, session, frame)) + if not episodes: + raise ValueError(f"{split} split has no matching real-data frames") + return episodes + + +def _split_sessions(split_manifest: Mapping[str, JsonValue], split: str) -> list[str]: + raw = split_manifest.get("split_sessions", {}) + if not isinstance(raw, Mapping): + return [] + sessions = raw.get(split, []) + return [str(session) for session in sessions] if isinstance(sessions, list) else [] + + +def _make_env(episodes: Sequence[OrderbookEpisode], config: CandidateConfig) -> StomOrderbookGymEnv: + env = StomOrderbookGymEnv( + episodes, + StomOrderbookRlEnvConfig(lookback_window=3, cost_bps=float(config.cost_bps), max_episode_steps=5, seed=int(config.seed)), + fixed_entry_exit_only=True, + constrain_invalid_actions=True, + ) + return FeatureMaskEnv(env, config.feature_set_id) + + +def _write_eval_logs( + output_dir: Path, + config: CandidateConfig, + validation: Mapping[str, JsonValue], + oos: Mapping[str, JsonValue], +) -> dict[str, JsonValue]: + log_dir = output_dir / "eval_logs" + log_dir.mkdir(parents=True, exist_ok=True) + validation_path = log_dir / f"{config.candidate_id}_validation.json" + oos_path = log_dir / f"{config.candidate_id}_oos.json" + validation_path.write_text(json.dumps(validation, ensure_ascii=True, indent=2), encoding="utf-8") + oos_path.write_text(json.dumps(oos, ensure_ascii=True, indent=2), encoding="utf-8") + return {"validation_eval_json": str(validation_path), "oos_eval_json": str(oos_path)} + + +def _trained_row( + config: CandidateConfig, + model_path: Path, + validation: Mapping[str, JsonValue], + oos: Mapping[str, JsonValue], + logs: Mapping[str, JsonValue], +) -> dict[str, JsonValue]: + return { + "candidate_id": config.candidate_id, + "algorithm": config.algorithm.value, + "seed": int(config.seed), + "split_hash": config.split_hash, + "feature_set_id": config.feature_set_id, + "total_timesteps": int(config.total_timesteps), + "status": "trained" if model_path.is_file() else "not_trained", + "model_path": str(model_path) if model_path.is_file() else "", + "validation_net_return_pct": validation["net_return_pct"], + "oos_net_return_pct": oos["net_return_pct"], + "oos_trade_count": oos["trade_count"], + "oos_max_drawdown_pct": oos["max_drawdown_pct"], + "monitor_log_path": logs["validation_eval_json"], + "eval_log_path": logs["oos_eval_json"], + "selected_by": "validation", + } + + +def _failed_row(config: CandidateConfig, reason: str) -> dict[str, JsonValue]: + return { + "candidate_id": config.candidate_id, + "algorithm": config.algorithm.value, + "seed": int(config.seed), + "split_hash": config.split_hash, + "feature_set_id": config.feature_set_id, + "total_timesteps": int(config.total_timesteps), + "status": "training_failed", + "model_path": "", + "validation_net_return_pct": 0.0, + "oos_net_return_pct": None, + "skip_reason": reason[:500], + "selected_by": "validation", + } diff --git a/stom_rl/opening_30m_rl_candidates.py b/stom_rl/opening_30m_rl_candidates.py new file mode 100644 index 000000000..413e912e4 --- /dev/null +++ b/stom_rl/opening_30m_rl_candidates.py @@ -0,0 +1,164 @@ +"""Candidate contracts and artifacts for opening PPO/DQN validation.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from enum import StrEnum +from pathlib import Path +from typing import Mapping, Sequence + +from .opening_30m_rl_realdata import JsonValue + + +class CandidateAlgorithm(StrEnum): + """Supported opening candidate algorithms.""" + + DQN = "dqn" + PPO = "ppo" + + +class OpeningAction(StrEnum): + """Shared discrete opening action set.""" + + HOLD_AFTER_FIXED_ENTRY = "HOLD_AFTER_FIXED_ENTRY" + EXIT_LONG_MARKETABLE = "EXIT_LONG_MARKETABLE" + + +@dataclass(frozen=True, slots=True) +class CandidateConfig: + """Candidate configuration that cannot tune on OOS.""" + + candidate_id: str + algorithm: CandidateAlgorithm + seed: int + total_timesteps: int + split_hash: str + feature_set_id: str + cost_bps: float = 23.0 + action_contract_version: str = "opening_discrete_v1" + use_oos_for_selection: bool = False + + +@dataclass(frozen=True, slots=True) +class CandidateConfigError(ValueError): + """Raised when a candidate config violates research guardrails.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def opening_action_contract() -> dict[str, JsonValue]: + """Return the shared discrete action contract used by PPO and DQN.""" + + return { + "version": "opening_discrete_v1", + "space": "Discrete(2)", + "actions": [action.value for action in OpeningAction], + "environment_mode": "fixed_entry_exit_only", + "policy_action_names": ["hold", "exit"], + "live_order_actions": False, + "continuous_sizing": False, + } + + +def default_candidate_configs(split_hash: str, feature_set_id: str = "full_context") -> list[CandidateConfig]: + """Return bounded DQN/PPO defaults for a frozen split.""" + + return [ + CandidateConfig("dqn_default_seed100", CandidateAlgorithm.DQN, 100, 64, split_hash, feature_set_id), + CandidateConfig("ppo_default_seed100", CandidateAlgorithm.PPO, 100, 64, split_hash, feature_set_id), + ] + + +def validate_candidate_config(config: CandidateConfig) -> None: + """Reject OOS tuning and incompatible candidate configs.""" + + if config.use_oos_for_selection: + raise CandidateConfigError("OOS cannot be used for model selection") + if config.cost_bps != 23.0: + raise CandidateConfigError("opening candidates must use 23bp cost by default") + if config.total_timesteps <= 0: + raise CandidateConfigError("total_timesteps must be positive") + + +def candidate_config_artifact(configs: Sequence[CandidateConfig]) -> dict[str, JsonValue]: + """Serialize candidate configs for dashboard evidence.""" + + for config in configs: + validate_candidate_config(config) + return { + "artifact_type": "opening_30m_candidate_configs", + "action_contract": opening_action_contract(), + "candidates": [_config_row(config) for config in configs], + "guardrail": "Validation may select candidates; OOS is final-only.", + } + + +def train_candidate_artifacts( + configs: Sequence[CandidateConfig], + *, + sb3_available: bool, + validation_score_by_candidate: Mapping[str, float] | None = None, + oos_score_by_candidate: Mapping[str, float] | None = None, + model_path_by_candidate: Mapping[str, str] | None = None, +) -> dict[str, JsonValue]: + """Write deterministic candidate training/evaluation metadata.""" + + scores = validation_score_by_candidate or {} + oos_scores = oos_score_by_candidate or {} + model_paths = model_path_by_candidate or {} + rows = [ + _training_row( + config, + sb3_available=sb3_available, + validation_score=float(scores.get(config.candidate_id, 0.0)), + oos_score=oos_scores.get(config.candidate_id), + model_path=str(model_paths.get(config.candidate_id, "")), + ) + for config in configs + ] + return { + "artifact_type": "opening_30m_candidate_training", + "selection_split": "validation", + "oos_is_final_only": True, + "candidates": rows, + } + + +def _config_row(config: CandidateConfig) -> dict[str, JsonValue]: + row = asdict(config) + row["algorithm"] = config.algorithm.value + return row + + +def _training_row( + config: CandidateConfig, + *, + sb3_available: bool, + validation_score: float, + oos_score: float | None, + model_path: str, +) -> dict[str, JsonValue]: + validate_candidate_config(config) + verified_model_path = str(model_path) if model_path and Path(model_path).is_file() else "" + if not sb3_available: + status = "skipped_sb3_unavailable" + elif verified_model_path: + status = "trained" + else: + status = "not_trained" + return { + "candidate_id": config.candidate_id, + "algorithm": config.algorithm.value, + "seed": int(config.seed), + "split_hash": config.split_hash, + "feature_set_id": config.feature_set_id, + "total_timesteps": int(config.total_timesteps), + "status": status, + "model_path": verified_model_path, + "validation_net_return_pct": validation_score if status == "trained" else 0.0, + "oos_net_return_pct": oos_score if status == "trained" else None, + "selected_by": "validation", + } diff --git a/stom_rl/opening_30m_rl_context.py b/stom_rl/opening_30m_rl_context.py new file mode 100644 index 000000000..df618166d --- /dev/null +++ b/stom_rl/opening_30m_rl_context.py @@ -0,0 +1,214 @@ +"""Participant/orderbook context vector for opening RL experiments.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Final, Mapping + +import pandas as pd # noqa: PANDAS_OK - STOM opening RL context uses pandas frames + +from .orderbook_persistence import ( + COL_ASK1, + COL_BID1, + build_orderbook_persistence_score, +) +from .participant_pressure_features import ( + COL_FOREIGN_NET_BUY, + COL_INSTITUTION_NET_BUY, + COL_PROGRAM_NET_BUY, + compute_participant_pressure_features, +) + + +@dataclass(frozen=True, slots=True) +class FeatureContract: + """Dashboard and training metadata for one causal opening RL context feature.""" + + feature_name: str + feature_group: str + source: str + causal_lookback: str + missing_policy: str + dashboard_label: str + + +CANONICAL_FEATURE_GROUPS: Final[tuple[str, ...]] = ( + "price_volume", + "participant_pressure", + "orderbook_imbalance", + "orderbook_persistence", + "overheat_upper_wick", + "optional_investor_flow", +) +OPENING_RL_CONTEXT_FEATURE_NAMES: Final[tuple[str, ...]] = ( + "participant_pressure_score", + "orderbook_persistence_score", + "overheat_score", + "proxy_available_trade_strength", + "proxy_available_bid_depth_imbalance", + "proxy_available_foreign_net_buy", + "proxy_available_institution_net_buy", + "proxy_available_program_net_buy", +) +CANONICAL_FEATURE_SET_IDS: Final[tuple[str, ...]] = ( + "full_context", + "no_participant_pressure", + "no_orderbook_imbalance", + "no_orderbook_persistence", + "no_overheat_upper_wick", + "minimal_price_volume", + "shuffled_participant_context", + "ts_imb_rule_baseline", +) +REQUIRED_ABLATION_KEYS: Final[tuple[str, ...]] = CANONICAL_FEATURE_SET_IDS +LEGACY_FEATURE_SET_ALIASES: Final[Mapping[str, str]] = { + "full": "full_context", + "no_participant": "no_participant_pressure", + "no_orderbook": "no_orderbook_imbalance", + "no_overheat": "no_overheat_upper_wick", + "no_overheat_penalty": "no_overheat_upper_wick", +} +FEATURE_CONTRACTS: Final[tuple[FeatureContract, ...]] = ( + FeatureContract("participant_pressure_score", "participant_pressure", "tick trade/amount/depth proxy", "rows <= decision_second", "set unavailable flags; do not infer actor identity", "Participant proxy pressure"), + FeatureContract("orderbook_persistence_score", "orderbook_persistence", "bid/ask depth and order-flow persistence", "rows <= decision_second", "missing book columns block persistence evidence", "Orderbook persistence"), + FeatureContract("overheat_score", "overheat_upper_wick", "causal return/volume/orderbook overheat proxy", "rows <= decision_second", "missing values reduce confidence, not profit claim", "Overheat and upper-wick risk"), + FeatureContract("proxy_available_trade_strength", "participant_pressure", "trade strength column readiness", "schema at decision time", "false when absent", "Trade strength available"), + FeatureContract("proxy_available_bid_depth_imbalance", "orderbook_imbalance", "bid/ask depth schema readiness", "schema at decision time", "false when absent", "Bid depth imbalance available"), + FeatureContract("proxy_available_foreign_net_buy", "optional_investor_flow", "optional investor-flow column", "schema at decision time", "None/false when absent; never zero-fill as truth", "Foreign net-buy proxy available"), + FeatureContract("proxy_available_institution_net_buy", "optional_investor_flow", "optional investor-flow column", "schema at decision time", "None/false when absent; never zero-fill as truth", "Institution net-buy proxy available"), + FeatureContract("proxy_available_program_net_buy", "optional_investor_flow", "optional investor-flow column", "schema at decision time", "None/false when absent; never zero-fill as truth", "Program net-buy proxy available"), +) + + +def normalize_feature_set_id(feature_set_id: str) -> str: + """Return the canonical opening RL feature-set ID for legacy or current input.""" + + return LEGACY_FEATURE_SET_ALIASES.get(feature_set_id, feature_set_id) + + +def _clip01(value: float) -> float: + return max(0.0, min(1.0, value)) + + +def _participant_pressure_score(features: Mapping[str, Any]) -> float: + trade_strength = _clip01(float(features["trade_strength"]) / 150.0) + signed_flow = _clip01((float(features["signed_amount_ratio"]) + 1.0) / 2.0) + bid_depth = _clip01(float(features["bid_depth_imbalance"])) + return (trade_strength + signed_flow + bid_depth) / 3.0 + + +def _proxy_flag(frame: pd.DataFrame, column: str) -> float: + return 1.0 if column in frame.columns else 0.0 + + +def build_opening_rl_context( + frame: pd.DataFrame, + *, + decision_second: int, +) -> dict[str, Any]: + """Build the causal context vector that Task 7 training can concatenate.""" + + participant = compute_participant_pressure_features(frame, decision_second=decision_second) + orderbook = build_orderbook_persistence_score(frame, decision_second=decision_second) + participant_score = _participant_pressure_score(participant) + orderbook_score = float(orderbook["score"]) + overheat_score = float(orderbook["components"]["overheat_penalty"]) + vector = [ + participant_score, + orderbook_score, + overheat_score, + 1.0, + 1.0, + _proxy_flag(frame, COL_FOREIGN_NET_BUY), + _proxy_flag(frame, COL_INSTITUTION_NET_BUY), + _proxy_flag(frame, COL_PROGRAM_NET_BUY), + ] + return { + "artifact_type": "opening_rl_context", + "participant_context_version": "market_participant_proxy_v1", + "decision_second": int(decision_second), + "feature_names": list(OPENING_RL_CONTEXT_FEATURE_NAMES), + "vector": vector, + "diagnostics": { + "participant_pressure_features": participant, + "orderbook_components": orderbook["components"], + "source": "causal rows only; no future rows after decision_second", + }, + "strategy_context": { + "line": "rl_experiment_context", + "label": "RL EXPERIMENT CONTEXT", + "is_reinforcement_learning": False, + "is_live_ready": False, + "is_profit_model": False, + }, + } + + +def _latest_has_liquidity(frame: pd.DataFrame, decision_second: int) -> bool: + row = frame.iloc[decision_second] + return float(row[COL_BID1]) > 0.0 and float(row[COL_ASK1]) > 0.0 + + +def compute_opening_context_reward_penalty( + frame: pd.DataFrame, + *, + decision_second: int, + action_name: str, +) -> dict[str, Any]: + """Compute causal reward penalties for participant/orderbook context use.""" + + context = build_opening_rl_context(frame, decision_second=decision_second) + components = context["diagnostics"]["orderbook_components"] + is_entry = action_name == "market_buy" + overheat_penalty = float(components["overheat_penalty"]) * 0.01 if is_entry else 0.0 + liquidity_penalty = 0.02 if is_entry and not _latest_has_liquidity(frame, decision_second) else 0.0 + bid_deterioration = ( + 0.005 + if is_entry + and ( + float(components["bid_depth_persistence"]) < 0.45 + or float(components["ofi_pressure"]) < 0.45 + ) + else 0.0 + ) + diagnostics = { + "decision_second": int(decision_second), + "action_name": action_name, + "overheat_entry_penalty": overheat_penalty, + "missing_liquidity_entry_penalty": liquidity_penalty, + "deteriorating_bid_depth_overtrade_penalty": bid_deterioration, + } + return {"total_penalty": sum(diagnostics[key] for key in diagnostics if key.endswith("_penalty")), "diagnostics": diagnostics} + + +def apply_participant_context_ablation_gate( + candidate: Mapping[str, Any], + ablation_results: Mapping[str, float], +) -> dict[str, Any]: + """Block GO_CANDIDATE unless participant context survives ablation controls.""" + + normalized_results = {normalize_feature_set_id(key): value for key, value in ablation_results.items()} + missing = [key for key in REQUIRED_ABLATION_KEYS if key not in normalized_results] + full = float(normalized_results.get("full_context", 0.0)) + ts_imb = float(normalized_results.get("ts_imb_rule_baseline", candidate.get("ts_imb_net_return_pct", 0.0))) + passes = ( + not missing + and str(candidate.get("candidate_verdict")) == "GO_CANDIDATE" + and bool(candidate.get("cost_gate_passed")) + and full > ts_imb + and full > float(normalized_results.get("no_participant_pressure", full)) + and full > float(normalized_results.get("no_orderbook_imbalance", full)) + and full > float(normalized_results.get("no_orderbook_persistence", full)) + and full > float(normalized_results.get("no_overheat_upper_wick", full)) + and float(normalized_results.get("shuffled_participant_context", full)) <= ts_imb + ) + return { + "verdict": "GO_CANDIDATE" if passes else "NO-GO", + "participant_context_ablation_passed": passes, + "negative_control_blocked_go": not passes and str(candidate.get("candidate_verdict")) == "GO_CANDIDATE", + "go_block_reason": "" if passes else "participant_context_failed_ablation_or_cost_gate", + "required_ablation_keys": list(REQUIRED_ABLATION_KEYS), + "missing_ablation_keys": missing, + "ablation_results": normalized_results, + "baseline": {"ts_imb_rule_baseline": ts_imb}, + } diff --git a/stom_rl/opening_30m_rl_controls.py b/stom_rl/opening_30m_rl_controls.py new file mode 100644 index 000000000..71ae26dd5 --- /dev/null +++ b/stom_rl/opening_30m_rl_controls.py @@ -0,0 +1,69 @@ +"""Negative/shuffle control gate for opening RL workflow evidence.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping, Sequence + + +SUMMARY_JSON = "opening_negative_controls_summary.json" + + +def _control_row(raw: Mapping[str, Any], seed: int) -> dict[str, Any]: + return { + "control_type": str(raw.get("control_type", "unknown_control")), + "verdict": str(raw.get("verdict", "NO-GO")), + "seed": int(raw.get("seed", seed)), + "metric": raw.get("metric"), + "is_negative_control": True, + } + + +def apply_opening_negative_controls( + *, + primary_verdict: str, + controls: Sequence[Mapping[str, Any]], + seed: int, +) -> dict[str, Any]: + """Apply deterministic opening negative controls to a primary verdict.""" + + rows = [_control_row(control, seed) for control in controls] + all_controls_no_go = bool(rows) and all(row["verdict"] == "NO-GO" for row in rows) + primary_is_candidate = primary_verdict == "GO_CANDIDATE" + blocked = primary_is_candidate and not all_controls_no_go + final_verdict = "NO-GO" if blocked else primary_verdict + return { + "artifact_type": "opening_30m_negative_controls", + "mode": "opening_30m_negative_control_gate", + "seed": int(seed), + "primary_verdict_before_controls": primary_verdict, + "final_verdict": final_verdict, + "negative_control_passed": all_controls_no_go, + "negative_control_blocked_go": blocked, + "blocked_reason": "negative_control_not_no_go" if blocked else "", + "controls": rows, + "strategy_context": { + "line": "opening_rl_controls", + "label": "NEGATIVE CONTROL GATE", + "is_live_ready": False, + "is_profit_model": False, + }, + } + + +def write_opening_controls_artifact( + *, + output_dir: Path, + primary_verdict: str, + controls: Sequence[Mapping[str, Any]], + seed: int, +) -> dict[str, Any]: + """Write opening negative-control verdict metadata.""" + + payload = apply_opening_negative_controls(primary_verdict=primary_verdict, controls=controls, seed=seed) + output_dir.mkdir(parents=True, exist_ok=True) + summary_path = output_dir / SUMMARY_JSON + payload["artifacts"] = {"summary_json": str(summary_path)} + summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload diff --git a/stom_rl/opening_30m_rl_dataset.py b/stom_rl/opening_30m_rl_dataset.py new file mode 100644 index 000000000..f01c8b192 --- /dev/null +++ b/stom_rl/opening_30m_rl_dataset.py @@ -0,0 +1,75 @@ +"""Dataset artifacts for opening real-data candidate training.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Mapping, Sequence + +from .opening_30m_rl_oos_split import validate_oos_split_manifest +from .opening_30m_rl_realdata import JsonValue + + +def build_dataset_artifact( + rows: Sequence[Mapping[str, JsonValue]], + *, + split_manifest: Mapping[str, JsonValue], + features: Sequence[str], + participant_proxy_availability: Mapping[str, bool], + orderbook_feature_availability: Mapping[str, bool], + output_path: Path | None = None, +) -> dict[str, JsonValue]: + """Build a dashboard-readable training dataset contract artifact.""" + + validate_oos_split_manifest(split_manifest) + split_sessions = split_manifest["split_sessions"] + if not isinstance(split_sessions, Mapping): + raise TypeError("validated split_sessions must be a mapping") + session_to_split = _session_to_split(split_sessions) + dataset_rows = [_dataset_row(row, session_to_split) for row in rows] + payload: dict[str, JsonValue] = { + "artifact_type": "opening_30m_realdata_dataset", + "split_hash": str(split_manifest["split_hash"]), + "features": list(features), + "rows": dataset_rows, + "row_count": len(dataset_rows), + "participant_proxy_availability": _availability(participant_proxy_availability), + "orderbook_feature_availability": _availability(orderbook_feature_availability), + "guardrail": "Unavailable proxies are reported, not zero-filled.", + } + if output_path is not None: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload + + +def _session_to_split(split_sessions: Mapping[str, JsonValue]) -> dict[str, str]: + lookup: dict[str, str] = {} + for split, sessions in split_sessions.items(): + if not isinstance(sessions, list): + raise TypeError("split sessions must be lists") + for session in sessions: + lookup[str(session)] = str(split) + return lookup + + +def _dataset_row(row: Mapping[str, JsonValue], session_to_split: Mapping[str, str]) -> dict[str, JsonValue]: + session = str(row.get("session_date", row.get("session", ""))) + split = session_to_split.get(session) + if split is None: + raise ValueError(f"session {session} is absent from the split manifest") + return { + "symbol": str(row.get("symbol", "")), + "session_date": session, + "split": split, + "row_count": int(row.get("row_count", 0) or 0), + "exclusion_reason": str(row.get("exclusion_reason", "")), + "action_contract_version": str(row.get("action_contract_version", "opening_discrete_v1")), + } + + +def _availability(availability: Mapping[str, bool]) -> dict[str, dict[str, JsonValue]]: + return { + str(name): {"available": bool(is_available), "filled_with_zero": False} + for name, is_available in sorted(availability.items()) + } diff --git a/stom_rl/opening_30m_rl_env_contract.py b/stom_rl/opening_30m_rl_env_contract.py new file mode 100644 index 000000000..7c42fd841 --- /dev/null +++ b/stom_rl/opening_30m_rl_env_contract.py @@ -0,0 +1,143 @@ +"""Opening orderbook environment compliance checks.""" + +from __future__ import annotations + +import importlib +import subprocess +import sys +from dataclasses import dataclass +from typing import Any, Sequence + +from .orderbook_rl_env import ACTION_NAMES, StomOrderbookRlEnvConfig +from .orderbook_sb3_adapter import OrderbookEpisode, StomOrderbookGymEnv + + +@dataclass(frozen=True, slots=True) +class OpeningEnvContractError(ValueError): + """Raised when opening env contract evidence cannot be built.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def _orderbook_episodes(frames: Sequence[Any]) -> list[OrderbookEpisode]: + episodes: list[OrderbookEpisode] = [] + for frame in frames: + if frame.empty: + continue + symbol = str(frame["symbol"].iloc[0]) + session = str(frame["session"].iloc[0]) + episodes.append( + OrderbookEpisode( + episode_id=f"{symbol}_{session}", + symbol=symbol, + session=session, + frame=frame, + ) + ) + if not episodes: + raise OpeningEnvContractError("opening env contract requires at least one non-empty frame") + return episodes + + +def _probe_sb3_check_env_import() -> str: + try: + probe = subprocess.run( + [ + sys.executable, + "-c", + "from stable_baselines3.common.env_checker import check_env; print(check_env.__name__)", + ], + check=False, + capture_output=True, + text=True, + timeout=15, + ) + except subprocess.TimeoutExpired as exc: + return f"SB3 check_env import probe timed out after {exc.timeout} seconds" + if probe.returncode != 0 or probe.stderr.strip(): + return (probe.stderr or probe.stdout).strip() + return "" + + +def _run_sb3_check(env: StomOrderbookGymEnv) -> dict[str, bool | str]: + import_message = _probe_sb3_check_env_import() + if import_message: + return { + "check_env_passed": False, + "check_env_status": "skipped_sb3_unavailable", + "check_env_message": import_message, + } + try: + env_checker = importlib.import_module("stable_baselines3.common.env_checker") + except (ImportError, OSError) as exc: + return { + "check_env_passed": False, + "check_env_status": "skipped_sb3_unavailable", + "check_env_message": str(exc), + } + check_env = getattr(env_checker, "check_env") + try: + check_env(env, warn=False) + except (AssertionError, ValueError, TypeError) as exc: + return { + "check_env_passed": False, + "check_env_status": "failed", + "check_env_message": str(exc), + } + return { + "check_env_passed": True, + "check_env_status": "passed", + "check_env_message": "", + } + + +def build_opening_env_contract_stage( + frames: Sequence[Any], + *, + fixed_entry_exit_only: bool = False, + constrain_invalid_actions: bool = True, +) -> dict[str, Any]: + """Validate opening orderbook env contracts on deterministic frames.""" + + episodes = _orderbook_episodes(frames) + config = StomOrderbookRlEnvConfig(lookback_window=3, cost_bps=0.0, max_episode_steps=8) + env = StomOrderbookGymEnv( + episodes, + config, + constrain_invalid_actions=constrain_invalid_actions, + fixed_entry_exit_only=fixed_entry_exit_only, + ) + observation, info = env.reset(seed=100) + action_space = ( + {"0": "hold", "1": "exit"} + if fixed_entry_exit_only + else {str(key): value for key, value in ACTION_NAMES.items()} + ) + sb3_check = _run_sb3_check( + StomOrderbookGymEnv( + episodes, + config, + constrain_invalid_actions=constrain_invalid_actions, + fixed_entry_exit_only=fixed_entry_exit_only, + ) + ) + result: dict[str, Any] = { + "stage": "readiness_env", + "status": "complete", + "evidence": "env_contract", + "environment": "StomOrderbookGymEnv", + "raw_environment": "StomOrderbookRlEnv", + "observation_shape": list(observation.shape), + "observation_space_shape": list(env.observation_space.shape), + "action_space": action_space, + "action_space_n": int(env.action_space.n), + "constraint_mode": str(info.get("constraint_mode", "hold_on_invalid" if constrain_invalid_actions else "none")), + "fixed_entry_exit_only": bool(fixed_entry_exit_only), + "constrain_invalid_actions": bool(constrain_invalid_actions), + "episode_count": len(episodes), + } + result.update(sb3_check) + return result diff --git a/stom_rl/opening_30m_rl_feature_mask.py b/stom_rl/opening_30m_rl_feature_mask.py new file mode 100644 index 000000000..9a9fe293a --- /dev/null +++ b/stom_rl/opening_30m_rl_feature_mask.py @@ -0,0 +1,155 @@ +"""Feature-set masking and context observation wrapper for opening RL candidates.""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +import gymnasium as gym +import numpy as np +from gymnasium import spaces + +from .opening_30m_rl_context import ( + FEATURE_CONTRACTS, + OPENING_RL_CONTEXT_FEATURE_NAMES, + build_opening_rl_context, + normalize_feature_set_id, +) +from .opening_30m_rl_realdata import JsonValue +from .orderbook_persistence import OrderbookPersistenceError +from .orderbook_rl_env import ORDERBOOK_FEATURE_NAMES +from .orderbook_sb3_adapter import StomOrderbookGymEnv +from .participant_pressure_features import ParticipantPressureError + +DEFAULT_FEATURE_COLUMNS = tuple(ORDERBOOK_FEATURE_NAMES) + tuple(OPENING_RL_CONTEXT_FEATURE_NAMES) + + +class FeatureMaskEnv(gym.Wrapper): + """Apply feature-set ablations and append causal opening context features.""" + + def __init__(self, env: StomOrderbookGymEnv, feature_set_id: str) -> None: + super().__init__(env) + self.feature_set_id = normalize_feature_set_id(feature_set_id) + self._feature_columns = list(env.raw_env.feature_columns) + list(OPENING_RL_CONTEXT_FEATURE_NAMES) + self._zero_indices = _zero_indices(self._feature_columns, self.feature_set_id) + self._shuffle_indices = _shuffle_indices(self._feature_columns, self.feature_set_id) + size = len(self._feature_columns) + self.observation_space = spaces.Box(low=-np.inf, high=np.inf, shape=(size,), dtype=np.float32) + + def reset(self, **kwargs: Any) -> tuple[np.ndarray, dict[str, Any]]: + observation, info = self.env.reset(**kwargs) + return self._mask_and_append(observation), self._with_feature_info(info) + + def step(self, action: Any) -> tuple[np.ndarray, float, bool, bool, dict[str, Any]]: + observation, reward, terminated, truncated, info = self.env.step(action) + return self._mask_and_append(observation), reward, terminated, truncated, self._with_feature_info(info) + + def _mask_and_append(self, observation: np.ndarray) -> np.ndarray: + values = np.concatenate([np.asarray(observation, dtype=np.float32), self._context_vector()]).astype(np.float32) + for index in self._zero_indices: + if 0 <= index < len(values): + values[index] = 0.0 + if len(self._shuffle_indices) > 1: + values[self._shuffle_indices] = np.roll(values[self._shuffle_indices], 1) + return values + + def _context_vector(self) -> np.ndarray: + try: + context = build_opening_rl_context( + self.env.current_episode.frame, + decision_second=min(int(self.env.raw_env.current_idx), len(self.env.current_episode.frame) - 1), + ) + return np.asarray(context["vector"], dtype=np.float32) + except (ParticipantPressureError, OrderbookPersistenceError, KeyError, IndexError): + return np.zeros(len(OPENING_RL_CONTEXT_FEATURE_NAMES), dtype=np.float32) + + def _with_feature_info(self, info: Mapping[str, Any]) -> dict[str, Any]: + payload = dict(info) + context_available = bool(np.any(self._context_vector())) + payload["feature_set_id"] = self.feature_set_id + payload["zeroed_feature_count"] = len(self._zero_indices) + payload["context_feature_names"] = list(OPENING_RL_CONTEXT_FEATURE_NAMES) + payload["context_feature_status"] = "available" if context_available else "missing" + payload.update(feature_mask_details(self._feature_columns, self.feature_set_id)) + payload["feature_columns"] = self._feature_columns + return payload + + +def _zero_indices(feature_columns: Sequence[str], feature_set_id: str) -> list[int]: + details = feature_mask_details(feature_columns, feature_set_id) + zero_names = set(details["zeroed_feature_names"]) + return [index for index, name in enumerate(feature_columns) if name in zero_names] + + +def _shuffle_indices(feature_columns: Sequence[str], feature_set_id: str) -> list[int]: + details = feature_mask_details(feature_columns, feature_set_id) + shuffle_names = set(details["shuffled_feature_names"]) + return [index for index, name in enumerate(feature_columns) if name in shuffle_names] + + +def feature_mask_details(feature_columns: Sequence[str] = DEFAULT_FEATURE_COLUMNS, feature_set_id: str = "full") -> dict[str, JsonValue]: + """Return auditable feature-mask details for candidate ablations.""" + + canonical_id = normalize_feature_set_id(feature_set_id) + groups = _removed_groups(canonical_id) + shuffled_groups = _shuffled_groups(canonical_id) + names = [name for name in feature_columns if _belongs_to_removed_group(name, groups, canonical_id)] + shuffled = [name for name in feature_columns if any(_belongs_to_group(name, group) for group in shuffled_groups)] + checked_groups = tuple(dict.fromkeys((*groups, *shuffled_groups))) + unavailable = [group for group in checked_groups if not any(_belongs_to_group(name, group) for name in feature_columns)] + return { + "feature_set_id": canonical_id, + "removed_feature_groups": list(groups), + "shuffled_feature_groups": list(shuffled_groups), + "zeroed_feature_names": names, + "zeroed_feature_count": len(names), + "shuffled_feature_names": shuffled, + "shuffled_feature_count": len(shuffled), + "unavailable_feature_groups": unavailable, + } + + +def _removed_groups(canonical_id: str) -> tuple[str, ...]: + if canonical_id == "no_orderbook_imbalance": + return ("orderbook_imbalance",) + if canonical_id == "no_orderbook_persistence": + return ("orderbook_persistence",) + if canonical_id == "no_overheat_upper_wick": + return ("overheat_upper_wick",) + if canonical_id == "minimal_price_volume": + return ( + "participant_pressure", + "orderbook_imbalance", + "orderbook_persistence", + "overheat_upper_wick", + "optional_investor_flow", + ) + if canonical_id == "no_participant_pressure": + return ("participant_pressure",) + return () + + +def _shuffled_groups(canonical_id: str) -> tuple[str, ...]: + if canonical_id == "shuffled_participant_context": + return ("participant_pressure",) + return () + + +def _belongs_to_removed_group(name: str, groups: Sequence[str], feature_set_id: str) -> bool: + if feature_set_id == "minimal_price_volume": + return not name.startswith(("ret_", "vol_")) and name not in {"position", "time_frac"} + return any(_belongs_to_group(name, group) for group in groups) + + +def _belongs_to_group(name: str, group: str) -> bool: + for contract in FEATURE_CONTRACTS: + if contract.feature_name == name: + return contract.feature_group == group + if group == "participant_pressure": + return any(token in name for token in ("foreign", "institution", "program", "personal", "participant", "net_buy")) + if group == "orderbook_imbalance": + return any(token in name for token in ("book_imb", "spread", "micro")) + if group == "orderbook_persistence": + return any(token in name for token in ("ofi", "sflow")) + if group == "overheat_upper_wick": + return name.startswith(("ret_", "vol_")) or name.startswith("ts_") + return False diff --git a/stom_rl/opening_30m_rl_leaderboard.py b/stom_rl/opening_30m_rl_leaderboard.py new file mode 100644 index 000000000..b9b93a40d --- /dev/null +++ b/stom_rl/opening_30m_rl_leaderboard.py @@ -0,0 +1,59 @@ +"""Cost-gate leaderboard row for opening 30-minute RL workflow artifacts.""" + +from __future__ import annotations + +from typing import Any, Mapping + + +RULE_BASELINE_POLICY = "ts_imb_same_decision_tp5_sl1_time" + + +def evaluate_opening_workflow_leaderboard_row( + *, + run_id: str, + rl_mean_return_pct: float, + baseline_delta_inputs: Mapping[str, float], + controls_payload: Mapping[str, Any], + target_cost_bps: float = 23.0, + trade_count: int, + max_drawdown_pct: float, +) -> dict[str, Any]: + """Evaluate one opening workflow row against controls, cost, and baselines.""" + + rule_baseline = float(baseline_delta_inputs.get(RULE_BASELINE_POLICY, 0.0)) + buy_hold = float(baseline_delta_inputs.get("buy_and_hold", 0.0)) + best_baseline = max(rule_baseline, buy_hold, float(baseline_delta_inputs.get("no_trade", 0.0))) + baseline_delta = float(rl_mean_return_pct) - best_baseline + controls_blocked = bool(controls_payload.get("negative_control_blocked_go")) + controls_passed = str(controls_payload.get("final_verdict")) == "GO_CANDIDATE" and not controls_blocked + below_baseline = baseline_delta <= 0.0 + risk_ok = int(trade_count) > 0 and float(max_drawdown_pct) > -20.0 + passes_cost_gate = controls_passed and not below_baseline and risk_ok and float(target_cost_bps) == 23.0 + if controls_blocked: + decision = "NO-GO" + elif below_baseline: + decision = "below_baseline" + else: + decision = "GO_CANDIDATE" if passes_cost_gate else "NO-GO" + return { + "run_id": run_id, + "artifact_type": "opening_30m_leaderboard_row", + "target_cost_bps": float(target_cost_bps), + "rl_mean_return_pct": float(rl_mean_return_pct), + "baseline_policy": RULE_BASELINE_POLICY, + "best_baseline_return_pct": best_baseline, + "baseline_delta_pct": baseline_delta, + "below_rule_baseline": float(rl_mean_return_pct) <= rule_baseline, + "negative_control_blocked_go": controls_blocked, + "controls_passed": controls_passed, + "passes_cost_gate": passes_cost_gate, + "trade_count": int(trade_count), + "max_drawdown_pct": float(max_drawdown_pct), + "decision": decision, + "strategy_context": { + "line": "opening_rl_leaderboard", + "label": "RL EXPERIMENT LEADERBOARD", + "is_live_ready": False, + "is_profit_model": False, + }, + } diff --git a/stom_rl/opening_30m_rl_manifest.py b/stom_rl/opening_30m_rl_manifest.py new file mode 100644 index 000000000..5c3810ec7 --- /dev/null +++ b/stom_rl/opening_30m_rl_manifest.py @@ -0,0 +1,202 @@ +"""Opening-window episode manifest adapter for orderbook RL workflows.""" + +from __future__ import annotations + +import csv +import json +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Mapping, Sequence + +import pandas as pd # noqa: PANDAS_OK - interoperates with existing STOM DataFrame pipeline + +from .episode_manifest import _validate_split_sessions + + +DEFAULT_OUTPUT_DIR = Path("webui") / "rl_runs" / "opening_30m_episode_manifest" +DEFAULT_SPLIT_SESSIONS = { + "train": ("20250103",), + "val": ("20250106",), + "test": ("20250107",), +} +MANIFEST_JSON = "opening_episode_manifest.json" +MANIFEST_CSV = "opening_episode_manifest.csv" +SUMMARY_JSON = "opening_episode_manifest_summary.json" + + +@dataclass(frozen=True, slots=True) +class OpeningEpisodeManifestConfig: + """Configuration for fixture-safe opening-window manifest generation.""" + + output_dir: Path | str = DEFAULT_OUTPUT_DIR + split_sessions: Mapping[str, Sequence[str]] = field(default_factory=lambda: DEFAULT_SPLIT_SESSIONS) + time_start: str = "090000" + time_end: str = "093000" + lookback_window: int = 30 + reward_horizon_seconds: int = 300 + write_artifacts: bool = True + + +@dataclass(frozen=True, slots=True) +class OpeningManifestError(ValueError): + """Typed opening manifest contract error.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _quote_coverage(frame: pd.DataFrame) -> float: + has_quote = ( + frame["매수호가1"].astype(float).gt(0.0) + & frame["매도호가1"].astype(float).gt(0.0) + & frame["매수잔량1"].astype(float).gt(0.0) + & frame["매도잔량1"].astype(float).gt(0.0) + ) + total_rows = int(len(frame)) + return float(has_quote.sum()) / total_rows if total_rows else 0.0 + + +def _split_lookup(split_sessions: Mapping[str, Sequence[str]]) -> dict[str, str]: + lookup: dict[str, str] = {} + for split, sessions in split_sessions.items(): + for session in sessions: + session_text = str(session) + if session_text in lookup and lookup[session_text] != split: + raise OpeningManifestError( + f"split overlap: session {session_text} is both {lookup[session_text]} and {split}" + ) + lookup[session_text] = split + return lookup + + +def _assert_chronological(split_validation: Mapping[str, Any]) -> None: + if not bool(split_validation.get("chronological_train_val_test")): + raise OpeningManifestError("split sessions must be chronological train -> val -> test") + + +def _episode_rows( + frames: Sequence[pd.DataFrame], + config: OpeningEpisodeManifestConfig, + manifest_csv_path: Path, + summary_json_path: Path, +) -> list[dict[str, Any]]: + split_by_session = _split_lookup(config.split_sessions) + rows: list[dict[str, Any]] = [] + for frame in frames: + if frame.empty: + continue + symbol = str(frame["symbol"].iloc[0]) + session = str(frame["session"].iloc[0]) + rows.append( + { + "episode_id": f"{symbol}_{session}", + "symbol": symbol, + "session": session, + "split": split_by_session.get(session, "unknown"), + "time_start": config.time_start, + "time_end": config.time_end, + "lookback_window": int(config.lookback_window), + "reward_horizon_seconds": int(config.reward_horizon_seconds), + "row_count": int(len(frame)), + "quote_coverage": _quote_coverage(frame), + "source_csv": str(manifest_csv_path), + "stage_evidence_json": str(summary_json_path), + } + ) + return sorted(rows, key=lambda item: (str(item["session"]), str(item["symbol"]))) + + +def _summary( + episodes: Sequence[Mapping[str, Any]], + split_validation: Mapping[str, Any], +) -> dict[str, Any]: + by_split: dict[str, int] = {} + for episode in episodes: + split = str(episode["split"]) + by_split[split] = by_split.get(split, 0) + 1 + return { + "episode_count": len(episodes), + "by_split": dict(sorted(by_split.items())), + "split_validation": dict(split_validation), + "min_quote_coverage": min((float(row["quote_coverage"]) for row in episodes), default=0.0), + "time_start": episodes[0]["time_start"] if episodes else "", + "time_end": episodes[0]["time_end"] if episodes else "", + } + + +def _write_manifest_csv(path: Path, episodes: Sequence[Mapping[str, Any]]) -> None: + fieldnames = [ + "episode_id", + "symbol", + "session", + "split", + "time_start", + "time_end", + "lookback_window", + "reward_horizon_seconds", + "row_count", + "quote_coverage", + "source_csv", + "stage_evidence_json", + ] + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for episode in episodes: + writer.writerow({field: episode.get(field) for field in fieldnames}) + + +def build_opening_episode_manifest( + frames: Sequence[pd.DataFrame], + config: OpeningEpisodeManifestConfig, +) -> dict[str, Any]: + """Build an opening 09:00-09:30 manifest from tick/orderbook frames.""" + + split_validation = _validate_split_sessions(config.split_sessions) + if int(split_validation["overlap_count"]): + raise OpeningManifestError("split overlap; refusing to write opening episode manifest") + _assert_chronological(split_validation) + + output_dir = Path(config.output_dir) + manifest_json_path = output_dir / MANIFEST_JSON + manifest_csv_path = output_dir / MANIFEST_CSV + summary_json_path = output_dir / SUMMARY_JSON + episodes = _episode_rows(frames, config, manifest_csv_path, summary_json_path) + if not episodes: + raise OpeningManifestError("opening episode manifest requires at least one episode") + + summary = _summary(episodes, split_validation) + payload: dict[str, Any] = { + "mode": "opening_30m_episode_manifest", + "artifact_type": "opening_30m_episode_manifest", + "created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "config": { + "output_dir": str(output_dir), + "split_sessions": {key: list(value) for key, value in config.split_sessions.items()}, + "time_start": config.time_start, + "time_end": config.time_end, + "lookback_window": int(config.lookback_window), + "reward_horizon_seconds": int(config.reward_horizon_seconds), + }, + "summary": summary, + "episodes": episodes, + "artifacts": { + "manifest_json": str(manifest_json_path), + "manifest_csv": str(manifest_csv_path), + "summary_json": str(summary_json_path), + }, + } + if config.write_artifacts: + _write_json(manifest_json_path, payload) + _write_json(summary_json_path, {key: value for key, value in payload.items() if key != "episodes"}) + _write_manifest_csv(manifest_csv_path, episodes) + return payload diff --git a/stom_rl/opening_30m_rl_oos_split.py b/stom_rl/opening_30m_rl_oos_split.py new file mode 100644 index 000000000..575a96f02 --- /dev/null +++ b/stom_rl/opening_30m_rl_oos_split.py @@ -0,0 +1,115 @@ +"""Frozen chronological split manifests for opening real-data candidates.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Mapping, Sequence + +from .opening_30m_rl_realdata import JsonValue + + +@dataclass(frozen=True, slots=True) +class OosSplitError(ValueError): + """Raised when an opening OOS split would leak evaluation data.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def build_oos_split_manifest( + split_sessions: Mapping[str, Sequence[str]], + *, + symbol_sessions: Mapping[str, Sequence[str]] | None = None, + output_path: Path | None = None, +) -> dict[str, JsonValue]: + """Build a frozen train/validation/OOS manifest from session dates.""" + + normalized = _normalize_splits(split_sessions) + _assert_no_overlap(normalized) + _assert_chronological(normalized) + symbols = _symbol_counts(symbol_sessions or {}) + payload: dict[str, JsonValue] = { + "artifact_type": "opening_30m_realdata_oos_split", + "created_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "split_sessions": normalized, + "split_ranges": _split_ranges(normalized), + "symbol_session_counts": symbols, + "session_count": sum(len(values) for values in normalized.values()), + "split_hash": _split_hash(normalized), + "guardrail": "OOS is frozen before training; OOS tuning is forbidden.", + } + if output_path is not None: + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload + + +def validate_oos_split_manifest(manifest: Mapping[str, JsonValue]) -> None: + """Validate a dashboard or training split manifest.""" + + raw = manifest.get("split_sessions") + if not isinstance(raw, Mapping): + raise OosSplitError("split_sessions is required") + normalized = _normalize_splits({str(key): _as_str_sequence(value) for key, value in raw.items()}) + _assert_no_overlap(normalized) + _assert_chronological(normalized) + expected_hash = _split_hash(normalized) + if str(manifest.get("split_hash", "")) != expected_hash: + raise OosSplitError("split_hash does not match split membership") + + +def _normalize_splits(split_sessions: Mapping[str, Sequence[str]]) -> dict[str, list[str]]: + required = ("train", "validation", "oos") + normalized: dict[str, list[str]] = {} + for split in required: + sessions = sorted(str(session) for session in split_sessions.get(split, ())) + if not sessions: + raise OosSplitError(f"{split} split must not be empty") + normalized[split] = sessions + return normalized + + +def _as_str_sequence(value: JsonValue) -> Sequence[str]: + if not isinstance(value, list): + raise OosSplitError("split membership must be a list") + return [str(item) for item in value] + + +def _assert_no_overlap(split_sessions: Mapping[str, Sequence[str]]) -> None: + seen: dict[str, str] = {} + for split, sessions in split_sessions.items(): + for session in sessions: + if session in seen: + raise OosSplitError(f"session {session} overlaps {seen[session]} and {split}") + seen[session] = split + + +def _assert_chronological(split_sessions: Mapping[str, Sequence[str]]) -> None: + train_max = max(split_sessions["train"]) + validation_min = min(split_sessions["validation"]) + validation_max = max(split_sessions["validation"]) + oos_min = min(split_sessions["oos"]) + if not train_max < validation_min <= validation_max < oos_min: + raise OosSplitError("split sessions must be chronological train < validation < oos") + + +def _split_ranges(split_sessions: Mapping[str, Sequence[str]]) -> dict[str, dict[str, JsonValue]]: + return { + split: {"start": min(sessions), "end": max(sessions), "session_count": len(sessions)} + for split, sessions in split_sessions.items() + } + + +def _symbol_counts(symbol_sessions: Mapping[str, Sequence[str]]) -> dict[str, JsonValue]: + return {str(symbol): len(tuple(sessions)) for symbol, sessions in sorted(symbol_sessions.items())} + + +def _split_hash(split_sessions: Mapping[str, Sequence[str]]) -> str: + encoded = json.dumps(split_sessions, ensure_ascii=False, sort_keys=True).encode("utf-8") + return hashlib.sha256(encoded).hexdigest()[:16] diff --git a/stom_rl/opening_30m_rl_realdata.py b/stom_rl/opening_30m_rl_realdata.py new file mode 100644 index 000000000..d220ac9a1 --- /dev/null +++ b/stom_rl/opening_30m_rl_realdata.py @@ -0,0 +1,254 @@ +"""Read-only real-data readiness sampling for opening 30-minute RL research.""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Final + +JsonValue = str | int | float | bool | None | list["JsonValue"] | dict[str, "JsonValue"] + +DEFAULT_DB_PATH: Final[Path] = Path("_database") / "stock_tick_back.db" +DEFAULT_OUTPUT_PATH: Final[Path] = Path(".omo") / "evidence" / "opening_30m_realdata_readiness.json" +REQUIRED_COLUMNS: Final[tuple[str, ...]] = ( + "index", + "\ud604\uc7ac\uac00", + "\uccb4\uacb0\uac15\ub3c4", + "\ucd08\ub2f9\ub9e4\uc218\uae08\uc561", + "\ucd08\ub2f9\ub9e4\ub3c4\uae08\uc561", + "\ucd08\ub2f9\ub9e4\uc218\uc218\ub7c9", + "\ucd08\ub2f9\ub9e4\ub3c4\uc218\ub7c9", + "\ub9e4\uc218\ucd1d\uc794\ub7c9", + "\ub9e4\ub3c4\ucd1d\uc794\ub7c9", + "\ub9e4\uc218\ud638\uac001", + "\ub9e4\ub3c4\ud638\uac001", + "\ub9e4\uc218\uc794\ub7c91", + "\ub9e4\ub3c4\uc794\ub7c91", +) +OPTIONAL_PARTICIPANT_FLOW_COLUMNS: Final[dict[str, tuple[str, ...]]] = { + "foreign_net_buy": ("foreign_net_buy", "외국인순매수"), + "institution_net_buy": ("institution_net_buy", "기관순매수"), + "program_net_buy": ("program_net_buy", "프로그램순매수"), +} + + +@dataclass(frozen=True, slots=True) +class RealdataSamplerConfig: + """Bounded sampler configuration for local STOM SQLite opening data.""" + + db_path: Path | str = DEFAULT_DB_PATH + output_path: Path | str = DEFAULT_OUTPUT_PATH + max_tables: int = 5 + max_rows_per_table: int = 20 + time_start: str = "090000" + time_end: str = "093000" + write_artifact: bool = True + + +@dataclass(frozen=True, slots=True) +class RealdataSamplerBoundsError(ValueError): + """Raised when a real-data sample request is not safely bounded.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +@dataclass(frozen=True, slots=True) +class RealdataSamplerSchemaError(ValueError): + """Raised when the SQLite source cannot satisfy sampler safety rules.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def classify_staging_path(path: str) -> str: + """Classify generated/session paths for commit-scope policy checks.""" + + normalized = path.replace("\\", "/").lower() + if normalized.startswith((".omc/", ".codegraph/", "_database/")) or "__pycache__/" in normalized: + return "exclude_from_commit" + if "cdp-profile" in normalized or ("browser" in normalized and "profile" in normalized): + return "exclude_from_commit" + if normalized.startswith("webui/static/v2/dist/"): + return "frontend_dist" + if normalized.startswith(".omo/"): + return "omo_plan_evidence" + return "source_or_review_required" + + +def sample_opening_realdata_readiness(config: RealdataSamplerConfig) -> dict[str, JsonValue]: + """Sample bounded real DB schema/readiness evidence without write authority.""" + + _validate_config(config) + db_path = Path(config.db_path).resolve() + if not db_path.is_file(): + raise RealdataSamplerSchemaError(f"SQLite DB not found: {db_path}") + + conn = _connect_readonly(db_path) + try: + query_only = _query_only_enabled(conn) + tables = _list_stock_tables(conn, max_tables=config.max_tables) + sampled_tables = [ + _sample_table_readiness(conn, table, config) + for table in tables + ] + finally: + conn.close() + + payload: dict[str, JsonValue] = { + "artifact_type": "opening_30m_realdata_readiness", + "generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "db_path": str(db_path), + "sqlite_uri_mode": "ro", + "query_only": query_only, + "bounds": { + "max_tables": int(config.max_tables), + "max_rows_per_table": int(config.max_rows_per_table), + "time_start": config.time_start, + "time_end": config.time_end, + }, + "required_columns": list(REQUIRED_COLUMNS), + "sampled_table_count": len(sampled_tables), + "sampled_tables": sampled_tables, + "optional_participant_flow": _participant_flow_availability(sampled_tables), + "safety_note": "Read-only bounded schema/readiness sample; not a live-ready or profitability claim.", + "config": _json_ready_config(config), + } + if config.write_artifact: + _write_json(Path(config.output_path), payload) + return payload + + +def _validate_config(config: RealdataSamplerConfig) -> None: + if int(config.max_tables) <= 0: + raise RealdataSamplerBoundsError("max_tables must be positive to prevent unbounded DB scans") + if int(config.max_rows_per_table) <= 0: + raise RealdataSamplerBoundsError("max_rows_per_table must be positive to prevent unbounded DB scans") + if not _is_hhmmss(config.time_start): + raise RealdataSamplerBoundsError("time_start must be HHMMSS") + if not _is_hhmmss(config.time_end): + raise RealdataSamplerBoundsError("time_end must be HHMMSS") + if config.time_start > config.time_end: + raise RealdataSamplerBoundsError("time_start must be <= time_end") + + +def _is_hhmmss(value: str) -> bool: + return len(value) == 6 and value.isdigit() + + +def _connect_readonly(db_path: Path) -> sqlite3.Connection: + conn = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True) + conn.execute("PRAGMA query_only=ON") + return conn + + +def _query_only_enabled(conn: sqlite3.Connection) -> bool: + row = conn.execute("PRAGMA query_only").fetchone() + if row is None: + raise RealdataSamplerSchemaError("PRAGMA query_only returned no row") + return int(row[0]) == 1 + + +def _quote_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def _list_stock_tables(conn: sqlite3.Connection, *, max_tables: int) -> list[str]: + rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name LIMIT ?", (max_tables,)).fetchall() + return [str(row[0]) for row in rows if str(row[0]).isdigit()] + + +def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: + rows = conn.execute(f"PRAGMA table_info({_quote_identifier(table)})").fetchall() + return [str(row[1]) for row in rows] + + +def _sample_table_readiness( + conn: sqlite3.Connection, + table: str, + config: RealdataSamplerConfig, +) -> dict[str, JsonValue]: + columns = _table_columns(conn, table) + column_set = set(columns) + missing = [column for column in REQUIRED_COLUMNS if column not in column_set] + row_count = _bounded_row_count(conn, table, config) if not missing else 0 + return { + "symbol": table, + "leading_zero_preserved": table.startswith("0"), + "columns": columns, + "missing_required_columns": missing, + "required_columns_available": not missing, + "sample_row_count": row_count, + "optional_participant_flow": { + key: { + "available": any(alias in column_set for alias in aliases), + "filled_with_zero": False, + } + for key, aliases in OPTIONAL_PARTICIPANT_FLOW_COLUMNS.items() + }, + } + + +def _bounded_row_count( + conn: sqlite3.Connection, + table: str, + config: RealdataSamplerConfig, +) -> int: + query = ( + f"SELECT COUNT(*) FROM (" + f"SELECT 1 FROM {_quote_identifier(table)} " + "WHERE substr(CAST(\"index\" AS TEXT), 9, 6) >= ? " + "AND substr(CAST(\"index\" AS TEXT), 9, 6) <= ? " + "ORDER BY \"index\" LIMIT ?)" + ) + row = conn.execute(query, (config.time_start, config.time_end, int(config.max_rows_per_table))).fetchone() + if row is None: + return 0 + return int(row[0]) + + +def _participant_flow_availability(sampled_tables: list[dict[str, JsonValue]]) -> dict[str, JsonValue]: + availability: dict[str, JsonValue] = {} + for key in OPTIONAL_PARTICIPANT_FLOW_COLUMNS: + available = any(_table_participant_available(table, key) for table in sampled_tables) + availability[key] = {"available": available, "filled_with_zero": False} + return availability + + +def _table_participant_available(table: dict[str, JsonValue], key: str) -> bool: + flow = table.get("optional_participant_flow") + if not isinstance(flow, dict): + return False + detail = flow.get(key) + if not isinstance(detail, dict): + return False + return detail.get("available") is True + + +def _json_ready_config(config: RealdataSamplerConfig) -> dict[str, JsonValue]: + raw = asdict(config) + return {key: str(value) if isinstance(value, Path) else value for key, value in raw.items()} + + +def _write_json(path: Path, payload: dict[str, JsonValue]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def main() -> int: + """Run the bounded real-data smoke CLI.""" + + from .opening_30m_rl_realdata_cli import main as cli_main + + return cli_main() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/opening_30m_rl_realdata_adapter.py b/stom_rl/opening_30m_rl_realdata_adapter.py new file mode 100644 index 000000000..d452c5838 --- /dev/null +++ b/stom_rl/opening_30m_rl_realdata_adapter.py @@ -0,0 +1,231 @@ +"""Bridge bounded real SQLite opening episodes into workflow frames.""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Final + +import pandas as pd # noqa: PANDAS_OK - existing opening workflow consumes pandas frames + +from .opening_30m_rl_realdata import DEFAULT_DB_PATH, REQUIRED_COLUMNS, JsonValue +from .participant_pressure_features import FEATURE_SPECS + +SUMMARY_FILENAME: Final[str] = "realdata_adapter_summary.json" +ADAPTER_REQUIRED_COLUMNS: Final[tuple[str, ...]] = tuple( + dict.fromkeys( + list(REQUIRED_COLUMNS) + + [ + column + for spec in FEATURE_SPECS + if spec.available_at_decision_second == "required" + for column in spec.required_columns + ] + ) +) + + +@dataclass(frozen=True, slots=True) +class RealdataAdapterConfig: + """Bounded DB-to-frame adapter configuration.""" + + db_path: Path | str = DEFAULT_DB_PATH + output_dir: Path | str = Path(".omo") / "evidence" / "opening_30m_realdata_adapter" + max_tables: int = 5 + max_sessions_per_table: int = 1 + max_rows_per_session: int = 1800 + min_rows_per_session: int = 4 + time_start: str = "090000" + time_end: str = "093000" + write_artifact: bool = True + + +@dataclass(frozen=True, slots=True) +class RealdataAdapterResult: + """Workflow-ready real-data frames plus read-only extraction summary.""" + + frames: list[pd.DataFrame] + summary: dict[str, JsonValue] + + +@dataclass(frozen=True, slots=True) +class RealdataNoGoDataError(ValueError): + """Raised after writing a NO-GO_DATA adapter summary.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def load_opening_realdata_frames(config: RealdataAdapterConfig) -> RealdataAdapterResult: + """Return bounded real DB frames in the opening workflow input shape.""" + + _validate_config(config) + output_dir = Path(config.output_dir) + frames: list[pd.DataFrame] = [] + sampled_tables: list[dict[str, JsonValue]] = [] + + conn = _connect_readonly(Path(config.db_path).resolve()) + try: + for table in _list_stock_tables(conn, max_tables=config.max_tables): + table_summary, table_frames = _frames_for_table(conn, table, config) + sampled_tables.append(table_summary) + frames.extend(table_frames) + finally: + conn.close() + + verdict = "INCONCLUSIVE" if frames else "NO-GO_DATA" + summary: dict[str, JsonValue] = { + "artifact_type": "opening_30m_realdata_adapter", + "generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "verdict": verdict, + "sqlite_uri_mode": "ro", + "query_only": True, + "bounds": { + "max_tables": int(config.max_tables), + "max_sessions_per_table": int(config.max_sessions_per_table), + "max_rows_per_session": int(config.max_rows_per_session), + "min_rows_per_session": int(config.min_rows_per_session), + "time_start": config.time_start, + "time_end": config.time_end, + }, + "frame_count": len(frames), + "sampled_tables": sampled_tables, + "safety_note": "Read-only bounded real-data frames; not live-ready and not a profit model.", + } + if config.write_artifact: + _write_json(output_dir / SUMMARY_FILENAME, summary) + if not frames: + raise RealdataNoGoDataError("NO-GO_DATA: no eligible bounded real-data opening frames") + return RealdataAdapterResult(frames=frames, summary=summary) + + +def _validate_config(config: RealdataAdapterConfig) -> None: + if int(config.max_tables) <= 0: + raise RealdataNoGoDataError("NO-GO_DATA: max_tables must be positive") + if int(config.max_sessions_per_table) <= 0: + raise RealdataNoGoDataError("NO-GO_DATA: max_sessions_per_table must be positive") + if int(config.max_rows_per_session) <= 0: + raise RealdataNoGoDataError("NO-GO_DATA: max_rows_per_session must be positive") + if int(config.min_rows_per_session) <= 0: + raise RealdataNoGoDataError("NO-GO_DATA: min_rows_per_session must be positive") + if not _is_hhmmss(config.time_start) or not _is_hhmmss(config.time_end): + raise RealdataNoGoDataError("NO-GO_DATA: time bounds must be HHMMSS") + if config.time_start > config.time_end: + raise RealdataNoGoDataError("NO-GO_DATA: time_start must be <= time_end") + + +def _is_hhmmss(value: str) -> bool: + return len(value) == 6 and value.isdigit() + + +def _connect_readonly(db_path: Path) -> sqlite3.Connection: + if not db_path.is_file(): + raise RealdataNoGoDataError(f"NO-GO_DATA: SQLite DB not found: {db_path}") + conn = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True) + conn.execute("PRAGMA query_only=ON") + return conn + + +def _quote_identifier(name: str) -> str: + return '"' + name.replace('"', '""') + '"' + + +def _list_stock_tables(conn: sqlite3.Connection, *, max_tables: int) -> list[str]: + rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall() + return [str(row[0]) for row in rows if str(row[0]).isdigit()][:max_tables] + + +def _table_columns(conn: sqlite3.Connection, table: str) -> list[str]: + rows = conn.execute(f"PRAGMA table_info({_quote_identifier(table)})").fetchall() + return [str(row[1]) for row in rows] + + +def _frames_for_table( + conn: sqlite3.Connection, + table: str, + config: RealdataAdapterConfig, +) -> tuple[dict[str, JsonValue], list[pd.DataFrame]]: + columns = _table_columns(conn, table) + missing = [column for column in ADAPTER_REQUIRED_COLUMNS if column not in set(columns)] + if missing: + return ( + { + "symbol": table, + "exclusion_reason": f"missing required columns: {missing}", + "missing_required_columns": missing, + "sessions": [], + }, + [], + ) + + sessions = _sessions_for_table(conn, table, config) + frames: list[pd.DataFrame] = [] + session_summaries: list[dict[str, JsonValue]] = [] + for session in sessions: + frame = _read_session_frame(conn, table, session, config) + row_count = int(len(frame)) + if row_count < int(config.min_rows_per_session): + session_summaries.append( + {"session": session, "row_count": row_count, "eligible": False, "exclusion_reason": "insufficient rows"} + ) + continue + frame.insert(0, "session", session) + frame.insert(0, "symbol", table) + frames.append(frame) + session_summaries.append({"session": session, "row_count": row_count, "eligible": True, "exclusion_reason": ""}) + + table_summary: dict[str, JsonValue] = { + "symbol": table, + "leading_zero_preserved": table.startswith("0"), + "missing_required_columns": [], + "exclusion_reason": "" if frames else "no eligible sessions", + "sessions": session_summaries, + } + return table_summary, frames + + +def _sessions_for_table( + conn: sqlite3.Connection, + table: str, + config: RealdataAdapterConfig, +) -> list[str]: + query = ( + f"SELECT substr(CAST(\"index\" AS TEXT), 1, 8) AS session " + f"FROM {_quote_identifier(table)} " + "WHERE substr(CAST(\"index\" AS TEXT), 9, 6) >= ? " + "AND substr(CAST(\"index\" AS TEXT), 9, 6) <= ? " + "GROUP BY session ORDER BY session LIMIT ?" + ) + rows = conn.execute(query, (config.time_start, config.time_end, int(config.max_sessions_per_table))).fetchall() + return [str(row[0]) for row in rows] + + +def _read_session_frame( + conn: sqlite3.Connection, + table: str, + session: str, + config: RealdataAdapterConfig, +) -> pd.DataFrame: + columns = ", ".join(_quote_identifier(column) for column in ADAPTER_REQUIRED_COLUMNS) + query = ( + f"SELECT {columns} FROM {_quote_identifier(table)} " + "WHERE substr(CAST(\"index\" AS TEXT), 1, 8) = ? " + "AND substr(CAST(\"index\" AS TEXT), 9, 6) >= ? " + "AND substr(CAST(\"index\" AS TEXT), 9, 6) <= ? " + "ORDER BY \"index\" LIMIT ?" + ) + return pd.read_sql_query( + query, + conn, + params=(session, config.time_start, config.time_end, int(config.max_rows_per_session)), + ) + + +def _write_json(path: Path, payload: dict[str, JsonValue]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") diff --git a/stom_rl/opening_30m_rl_realdata_cli.py b/stom_rl/opening_30m_rl_realdata_cli.py new file mode 100644 index 000000000..4eb815aeb --- /dev/null +++ b/stom_rl/opening_30m_rl_realdata_cli.py @@ -0,0 +1,172 @@ +"""CLI for bounded opening real-data workflow smoke runs.""" + +from __future__ import annotations + +import argparse +import importlib.util +import json +import sys +from pathlib import Path +from typing import Sequence + +from .opening_30m_rl_realdata_adapter import ( + RealdataAdapterConfig, + RealdataNoGoDataError, + load_opening_realdata_frames, +) +from .opening_30m_rl_runner import run_opening_workflow_stages +from .opening_30m_rl_candidate_smoke import attach_candidate_smoke_artifacts +from .opening_30m_rl_workflow import OpeningWorkflowConfig, build_opening_workflow_manifest + +SUMMARY_FILENAME = "opening_30m_rl_workflow_summary.json" + + +def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a bounded opening 30-minute real-data smoke workflow.") + parser.add_argument("--db", required=True, help="Read-only STOM SQLite DB path.") + parser.add_argument("--run-id", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--max-tables", type=int, default=5) + parser.add_argument("--max-sessions-per-table", type=int, default=1) + parser.add_argument("--max-rows-per-session", type=int, default=1800) + parser.add_argument("--min-rows-per-session", type=int, default=4) + parser.add_argument("--time-start", default="090000") + parser.add_argument("--time-end", default="093000") + parser.add_argument("--cost-bps", type=float, default=23.0) + parser.add_argument("--create-split", action="store_true") + parser.add_argument("--split-manifest", default="") + parser.add_argument("--candidate-algos", default="") + parser.add_argument("--tiny-train", action="store_true") + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the real-data smoke CLI and return a process exit code.""" + + args = _parse_args(argv) + db_path = Path(args.db) + candidate_mode = bool(args.candidate_algos) + output_dir = _effective_output_dir(Path(args.output_dir), str(args.run_id), candidate_mode) + if args.candidate_algos and not args.create_split and not args.split_manifest: + print("candidate OOS smoke requires --split-manifest or explicit --create-split", file=sys.stderr) + return 2 + if args.split_manifest and not Path(str(args.split_manifest)).is_file(): + print(f"split manifest not found: {args.split_manifest}", file=sys.stderr) + return 2 + if not db_path.is_file(): + print(f"SQLite DB not found: {db_path}", file=sys.stderr) + return 2 + + workflow_config = OpeningWorkflowConfig( + run_id=str(args.run_id), + output_dir=output_dir, + cost_bps=float(args.cost_bps), + time_start=str(args.time_start), + time_end=str(args.time_end), + mode="realdata_smoke", + ) + adapter_config = RealdataAdapterConfig( + db_path=db_path, + output_dir=output_dir / "realdata_adapter", + max_tables=int(args.max_tables), + max_sessions_per_table=int(args.max_sessions_per_table), + max_rows_per_session=int(args.max_rows_per_session), + min_rows_per_session=int(args.min_rows_per_session), + time_start=str(args.time_start), + time_end=str(args.time_end), + ) + + try: + adapter = load_opening_realdata_frames(adapter_config) + except RealdataNoGoDataError as exc: + payload = _no_go_data_payload(workflow_config, adapter_config, str(exc)) + frames = [] + else: + frames = adapter.frames + payload = run_opening_workflow_stages(adapter.frames, workflow_config, request_training=False) + if str(payload.get("verdict", "")) in {"PENDING", "PENDING_TRAINING"}: + payload["verdict"] = "INCONCLUSIVE" + payload["realdata"] = _realdata_context(adapter.summary) + _write_summary(output_dir, payload) + + if args.candidate_algos: + attach_candidate_smoke_artifacts( + output_dir, + payload, + frames=frames, + candidate_algos=str(args.candidate_algos), + create_split=bool(args.create_split), + split_manifest_path=str(args.split_manifest), + tiny_train=bool(args.tiny_train), + cost_bps=float(args.cost_bps), + ) + _write_summary(output_dir, payload) + + print(json.dumps(_stdout_summary(payload), ensure_ascii=False)) + return 0 + + +def _no_go_data_payload( + workflow_config: OpeningWorkflowConfig, + adapter_config: RealdataAdapterConfig, + reason: str, +) -> dict[str, object]: + payload = build_opening_workflow_manifest(workflow_config) + payload["verdict"] = "NO-GO_DATA" + payload["realdata"] = { + "verdict": "NO-GO_DATA", + "reason": reason, + "bounds": _bounds(adapter_config), + "sampled_tables": [], + "training_status": _training_status(), + } + _write_summary(Path(workflow_config.output_dir), payload) + return payload + + +def _effective_output_dir(output_dir: Path, run_id: str, candidate_mode: bool) -> Path: + if candidate_mode and output_dir.name != run_id: + return output_dir / run_id + return output_dir + + +def _realdata_context(summary: dict[str, object]) -> dict[str, object]: + return { + "verdict": summary.get("verdict", "INCONCLUSIVE"), + "bounds": summary.get("bounds", {}), + "sampled_tables": summary.get("sampled_tables", []), + "training_status": _training_status(), + "model_status": "no_model_trained", + "guardrail": "RL EXPERIMENT; bounded real-data smoke; not live-ready.", + } + + +def _bounds(config: RealdataAdapterConfig) -> dict[str, object]: + return { + "max_tables": int(config.max_tables), + "max_sessions_per_table": int(config.max_sessions_per_table), + "max_rows_per_session": int(config.max_rows_per_session), + "min_rows_per_session": int(config.min_rows_per_session), + "time_start": config.time_start, + "time_end": config.time_end, + } + + +def _training_status() -> str: + if importlib.util.find_spec("stable_baselines3") is None: + return "skipped_sb3_unavailable" + return "available_not_requested" + + +def _write_summary(output_dir: Path, payload: dict[str, object]) -> None: + output_dir.mkdir(parents=True, exist_ok=True) + (output_dir / SUMMARY_FILENAME).write_text(json.dumps(payload, ensure_ascii=True, indent=2), encoding="utf-8") + + +def _stdout_summary(payload: dict[str, object]) -> dict[str, object]: + return { + "artifact_type": payload.get("artifact_type"), + "run_id": payload.get("run_id"), + "verdict": payload.get("verdict"), + "summary_json": str(Path(str(payload["artifacts"]["summary_json"]))), + } diff --git a/stom_rl/opening_30m_rl_realdata_validation.py b/stom_rl/opening_30m_rl_realdata_validation.py new file mode 100644 index 000000000..ee0b9c352 --- /dev/null +++ b/stom_rl/opening_30m_rl_realdata_validation.py @@ -0,0 +1,94 @@ +"""Cost-aware real-data validation gates for opening RL candidates.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping + +from .opening_30m_rl_realdata import JsonValue + + +@dataclass(frozen=True, slots=True) +class RealdataGateInput: + """Inputs required before a real-data opening candidate may be promoted.""" + + candidate_net_return_pct: float + no_trade_net_return_pct: float + buy_and_hold_net_return_pct: float + ts_imb_rule_net_return_pct: float + cost_bps: float + has_oos_split: bool + has_negative_controls: bool + negative_controls_passed: bool + ablations: Mapping[str, bool] + + +def evaluate_realdata_candidate_gate(gate: RealdataGateInput) -> dict[str, JsonValue]: + """Return a dashboard-readable NO-GO/INCONCLUSIVE/GO_CANDIDATE gate.""" + + blocking_reasons: list[str] = [] + if not gate.has_oos_split: + blocking_reasons.append("missing_oos_split") + if not gate.has_negative_controls: + blocking_reasons.append("missing_negative_controls") + if gate.has_negative_controls and not gate.negative_controls_passed: + blocking_reasons.append("failed_negative_controls") + if float(gate.cost_bps) != 23.0: + blocking_reasons.append("unexpected_cost_bps") + + baseline_results = _baseline_results(gate) + for name, result in baseline_results.items(): + if not bool(result["passed"]): + blocking_reasons.append(f"failed_baseline:{name}") + + feature_ablation_results = { + name: {"passed": bool(passed), "required_for_go_candidate": True} + for name, passed in sorted(gate.ablations.items()) + } + for name, result in feature_ablation_results.items(): + if not bool(result["passed"]): + blocking_reasons.append(f"failed_ablation:{name}") + + verdict = _verdict(blocking_reasons) + return { + "artifact_type": "opening_30m_realdata_validation_gate", + "verdict": verdict, + "can_show_go_candidate": verdict == "GO_CANDIDATE", + "cost_bps": float(gate.cost_bps), + "candidate_net_return_pct": float(gate.candidate_net_return_pct), + "baseline_results": baseline_results, + "feature_ablation_results": feature_ablation_results, + "control_results": { + "has_oos_split": bool(gate.has_oos_split), + "has_negative_controls": bool(gate.has_negative_controls), + "negative_controls_passed": bool(gate.negative_controls_passed), + }, + "blocking_reasons": blocking_reasons, + "guardrail": "GO_CANDIDATE is blocked unless OOS, controls, ablations, and 23bp baselines all pass.", + } + + +def _baseline_results(gate: RealdataGateInput) -> dict[str, dict[str, JsonValue]]: + baselines = { + "no_trade": gate.no_trade_net_return_pct, + "buy_and_hold": gate.buy_and_hold_net_return_pct, + "ts_imb_rule": gate.ts_imb_rule_net_return_pct, + } + return { + name: { + "baseline_net_return_pct": float(value), + "candidate_net_return_pct": float(gate.candidate_net_return_pct), + "passed": float(gate.candidate_net_return_pct) > float(value), + "is_reinforcement_learning": False, + "label": "ts_imb RULE baseline" if name == "ts_imb_rule" else name, + } + for name, value in baselines.items() + } + + +def _verdict(blocking_reasons: list[str]) -> str: + if not blocking_reasons: + return "GO_CANDIDATE" + if any(reason.startswith("missing_") or reason == "unexpected_cost_bps" for reason in blocking_reasons): + return "INCONCLUSIVE" + return "NO-GO" diff --git a/stom_rl/opening_30m_rl_runner.py b/stom_rl/opening_30m_rl_runner.py new file mode 100644 index 000000000..bd43b1a86 --- /dev/null +++ b/stom_rl/opening_30m_rl_runner.py @@ -0,0 +1,232 @@ +"""Stage runner for the opening 30-minute RL workflow.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping, Sequence + +import pandas as pd # noqa: PANDAS_OK - STOM opening workflow stages use pandas frames + +from .opening_30m_rl_baselines import OpeningBaselineConfig, OpeningBaselineError, evaluate_opening_baselines +from .opening_30m_rl_context import CANONICAL_FEATURE_GROUPS, OPENING_RL_CONTEXT_FEATURE_NAMES +from .opening_30m_rl_env_contract import OpeningEnvContractError, build_opening_env_contract_stage +from .opening_30m_rl_manifest import OpeningEpisodeManifestConfig, OpeningManifestError, build_opening_episode_manifest +from .opening_30m_rl_workflow import OpeningWorkflowConfig, build_opening_workflow_manifest, record_workflow_stage +from .orderbook_persistence import OrderbookPersistenceError, write_orderbook_persistence_artifact +from .participant_pressure_features import ParticipantPressureError, build_participant_pressure_readiness + + +FEATURE_GROUPS = list(CANONICAL_FEATURE_GROUPS) + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _stage(name: str, status: str, evidence: str, reason: str = "") -> dict[str, Any]: + return {"stage": name, "status": status, "evidence": evidence, "reason": reason} + + +def _stage_status(payload: Mapping[str, Any], name: str) -> str: + for stage in payload["stages"]: + if stage["name"] == name: + return str(stage["status"]) + return "missing" + + +def _record(payload: Mapping[str, Any], name: str, result: Mapping[str, Any]) -> dict[str, Any]: + updated = record_workflow_stage(payload, name, result) + evidence = list(updated.get("evidence", [])) + evidence_path = str(result.get("evidence", "")) + if evidence_path and evidence_path not in evidence: + evidence.append(evidence_path) + updated["evidence"] = evidence + return updated + + +def _block_pending(payload: Mapping[str, Any], stages: Sequence[str], reason: str) -> dict[str, Any]: + updated = dict(payload) + for stage_name in stages: + if _stage_status(updated, stage_name) == "pending": + updated = _record(updated, stage_name, _stage(stage_name, "blocked", "", reason)) + return updated + + +def _skip_pending(payload: Mapping[str, Any], stages: Sequence[str], reason: str) -> dict[str, Any]: + updated = dict(payload) + for stage_name in stages: + if _stage_status(updated, stage_name) == "pending": + updated = _record(updated, stage_name, _stage(stage_name, "skipped", "", reason)) + return updated + + +def _write_baseline(output_dir: Path, baseline: Mapping[str, Any]) -> str: + summary_path = output_dir / "baseline" / "opening_baseline_summary.json" + _write_json(summary_path, baseline) + return str(summary_path) + + +def _base_payload(config: OpeningWorkflowConfig) -> dict[str, Any]: + payload = build_opening_workflow_manifest(config) + payload["feature_groups"] = list(FEATURE_GROUPS) + payload["proxy_availability"] = {} + payload["missing_proxy_columns"] = [] + payload["participant_study_artifacts"] = {} + payload["feature_ablation_results"] = {} + payload["participant_context_version"] = "market_participant_proxy_v1" + payload["opening_rl_context_feature_names"] = list(OPENING_RL_CONTEXT_FEATURE_NAMES) + return payload + + +def _run_manifest_stage( + payload: Mapping[str, Any], + frames: Sequence[pd.DataFrame], + output_dir: Path, +) -> dict[str, Any]: + manifest = build_opening_episode_manifest( + frames, + OpeningEpisodeManifestConfig(output_dir=output_dir / "episodes"), + ) + evidence = str(output_dir / "episodes" / "opening_episode_manifest_summary.json") + result = _stage("manifest", "passed", evidence) | { + "artifact_type": manifest["artifact_type"], + "episode_count": manifest["summary"]["episode_count"], + "artifacts": manifest["artifacts"], + } + return _record(payload, "manifest", result) + + +def _run_participant_stage( + payload: Mapping[str, Any], + frames: Sequence[pd.DataFrame], + output_dir: Path, +) -> dict[str, Any]: + participant = build_participant_pressure_readiness( + frames[0], + output_dir=output_dir / "participant_pressure", + decision_second=min(3, len(frames[0]) - 1), + ) + evidence = participant["artifacts"]["summary_json"] + result = _stage("participant_pressure", "passed", evidence) | { + "artifact_type": participant["artifact_type"], + "proxy_availability": participant["proxy_availability"], + "missing_proxy_columns": participant["missing_proxy_columns"], + } + updated = _record(payload, "participant_pressure", result) + updated["proxy_availability"] = participant["proxy_availability"] + updated["missing_proxy_columns"] = participant["missing_proxy_columns"] + updated["participant_study_artifacts"] = { + "participant_pressure_readiness_summary_json": str(evidence), + } + return updated + + +def _run_orderbook_stage( + payload: Mapping[str, Any], + frames: Sequence[pd.DataFrame], + output_dir: Path, +) -> dict[str, Any]: + orderbook = write_orderbook_persistence_artifact( + frames[0], + output_dir=output_dir, + decision_second=min(3, len(frames[0]) - 1), + ) + evidence = orderbook["artifacts"]["summary_json"] + result = _stage("orderbook_persistence", "passed", evidence) | { + "artifact_type": orderbook["artifact_type"], + "score": orderbook["score"], + "feature_groups": orderbook["feature_groups"], + "components": orderbook["components"], + } + return _record(payload, "orderbook_persistence", result) + + +def _run_env_stage( + payload: Mapping[str, Any], + frames: Sequence[pd.DataFrame], +) -> dict[str, Any]: + env_result = build_opening_env_contract_stage(frames, fixed_entry_exit_only=True) + status = "failed" if env_result["check_env_status"] == "failed" else "passed" + result = dict(env_result) + result["status"] = status + result["evidence"] = "env_contract" + return _record(payload, "readiness_env", result) + + +def _run_baseline_stage( + payload: Mapping[str, Any], + frames: Sequence[pd.DataFrame], + config: OpeningWorkflowConfig, + output_dir: Path, +) -> dict[str, Any]: + baseline = evaluate_opening_baselines(frames, OpeningBaselineConfig(cost_bps=float(config.cost_bps))) + evidence = _write_baseline(output_dir, baseline) + result = _stage("baseline", "passed", evidence) | { + "artifact_type": baseline["artifact_type"], + "summary": baseline["summary"], + } + return _record(payload, "baseline", result) + + +def _apply_training_state( + payload: Mapping[str, Any], + *, + request_training: bool, + reason: str | None, +) -> dict[str, Any]: + if reason: + blocked = _block_pending(payload, ("baseline", "training", "evaluation", "controls", "cost_gate", "dashboard"), reason) + blocked["verdict"] = "NO-GO_DATA" + return blocked + if request_training: + blocked = _block_pending( + payload, + ("training", "evaluation", "controls", "cost_gate", "dashboard"), + "training stage waits for Task 7", + ) + blocked["verdict"] = "PENDING_TRAINING" + return blocked + skipped = _skip_pending(payload, ("training",), "training flag not set") + return _skip_pending(skipped, ("evaluation", "controls", "cost_gate", "dashboard"), "requires training output") + + +def run_opening_workflow_stages( + frames: Sequence[pd.DataFrame], + config: OpeningWorkflowConfig, + *, + request_training: bool = False, +) -> dict[str, Any]: + """Run non-training opening workflow stages and write the workflow summary.""" + + output_dir = Path(config.output_dir) + payload = _base_payload(config) + payload = _record(payload, "contract", _stage("contract", "passed", str(output_dir / "contract"))) + failure_reason: str | None = None + try: + payload = _run_manifest_stage(payload, frames, output_dir) + payload = _run_participant_stage(payload, frames, output_dir) + payload = _run_orderbook_stage(payload, frames, output_dir) + payload = _run_env_stage(payload, frames) + if _stage_status(payload, "readiness_env") == "failed": + failure_reason = "NO-GO_DATA: readiness_env failed" + if failure_reason is None: + payload = _run_baseline_stage(payload, frames, config, output_dir) + except ( + OpeningManifestError, + ParticipantPressureError, + OpeningEnvContractError, + OpeningBaselineError, + OrderbookPersistenceError, + KeyError, + ValueError, + ) as exc: + current = next((stage["name"] for stage in payload["stages"] if stage["status"] == "pending"), "readiness_env") + payload = _record(payload, str(current), _stage(str(current), "failed", "", str(exc))) + failure_reason = f"NO-GO_DATA: {current} failed: {exc}" + payload = _apply_training_state(payload, request_training=request_training, reason=failure_reason) + summary_path = output_dir / "opening_30m_rl_workflow_summary.json" + payload["artifacts"]["summary_json"] = str(summary_path) + _write_json(summary_path, payload) + return payload diff --git a/stom_rl/opening_30m_rl_train.py b/stom_rl/opening_30m_rl_train.py new file mode 100644 index 000000000..7b59571c0 --- /dev/null +++ b/stom_rl/opening_30m_rl_train.py @@ -0,0 +1,251 @@ +"""Tiny DQN training stage for the opening 30-minute RL workflow.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Sequence + +import pandas as pd # noqa: PANDAS_OK - STOM opening fixtures are pandas frames + +from .orderbook_rl_env import StomOrderbookRlEnvConfig +from .orderbook_sb3_adapter import OrderbookEpisode, StomOrderbookGymEnv + + +SUMMARY_JSON = "opening_training_summary.json" +MODEL_ZIP = "dqn_model.zip" + + +@dataclass(frozen=True, slots=True) +class OpeningTrainingConfig: + """Configuration for the fixture-safe opening DQN training stage.""" + + output_dir: Path | str + total_timesteps: int = 16 + seed: int = 100 + cost_bps: float = 23.0 + lookback_window: int = 3 + max_episode_steps: int = 5 + fixed_entry_exit_only: bool = True + constrain_invalid_actions: bool = True + device: str = "cpu" + + +@dataclass(frozen=True, slots=True) +class OpeningTrainingError(ValueError): + """Raised when the opening training stage contract is violated.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +@dataclass(frozen=True, slots=True) +class OpeningTrainingUnavailable(Exception): + """Raised when local SB3/Torch cannot run the DQN stage safely.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def _episode_id(frame: pd.DataFrame) -> str: + return f"{frame['symbol'].iloc[0]}_{frame['session'].iloc[0]}" + + +def _episodes_by_id(frames: Sequence[pd.DataFrame]) -> dict[str, OrderbookEpisode]: + episodes: dict[str, OrderbookEpisode] = {} + for frame in frames: + if frame.empty: + continue + episode_id = _episode_id(frame) + episodes[episode_id] = OrderbookEpisode( + episode_id=episode_id, + symbol=str(frame["symbol"].iloc[0]), + session=str(frame["session"].iloc[0]), + frame=frame, + ) + if not episodes: + raise OpeningTrainingError("opening training requires at least one episode") + return episodes + + +def _select_episodes( + episodes: dict[str, OrderbookEpisode], + ids: Sequence[str], + split_name: str, +) -> list[OrderbookEpisode]: + selected: list[OrderbookEpisode] = [] + for episode_id in ids: + if episode_id not in episodes: + raise OpeningTrainingError(f"unknown {split_name} episode_id: {episode_id}") + selected.append(episodes[episode_id]) + if not selected: + raise OpeningTrainingError(f"{split_name} episode_ids must not be empty") + return selected + + +def _assert_no_overlap(train_episode_ids: Sequence[str], eval_episode_ids: Sequence[str]) -> None: + overlap = sorted(set(train_episode_ids) & set(eval_episode_ids)) + if overlap: + raise OpeningTrainingError(f"train/eval episode overlap: {overlap}") + + +def _env_config(config: OpeningTrainingConfig) -> StomOrderbookRlEnvConfig: + return StomOrderbookRlEnvConfig( + lookback_window=int(config.lookback_window), + cost_bps=float(config.cost_bps), + max_episode_steps=int(config.max_episode_steps), + seed=int(config.seed), + ) + + +def _make_env(episodes: Sequence[OrderbookEpisode], config: OpeningTrainingConfig) -> StomOrderbookGymEnv: + return StomOrderbookGymEnv( + episodes, + _env_config(config), + fixed_entry_exit_only=bool(config.fixed_entry_exit_only), + constrain_invalid_actions=bool(config.constrain_invalid_actions), + ) + + +def _probe_sb3_dqn_import() -> str: + try: + probe = subprocess.run( + [sys.executable, "-c", "from stable_baselines3 import DQN; print(DQN.__name__)"], + check=False, + capture_output=True, + text=True, + timeout=20, + ) + except subprocess.TimeoutExpired as exc: + return f"SB3 DQN import probe timed out after {exc.timeout} seconds" + output = (probe.stdout + probe.stderr).strip() + if probe.returncode != 0: + return output or f"SB3 DQN import probe failed with exit code {probe.returncode}" + return "" if output == "DQN" else output + + +def _train_model(train_episodes: Sequence[OrderbookEpisode], config: OpeningTrainingConfig): + import_message = _probe_sb3_dqn_import() + if import_message: + raise OpeningTrainingUnavailable(import_message) + try: + from stable_baselines3 import DQN + except (ImportError, OSError) as exc: + raise OpeningTrainingUnavailable(str(exc)) from exc + + env = _make_env(train_episodes, config) + batch_size = max(2, min(8, int(config.total_timesteps))) + model = DQN( + "MlpPolicy", + env, + seed=int(config.seed), + device=str(config.device), + verbose=0, + learning_starts=1, + buffer_size=max(128, int(config.total_timesteps) * 2), + batch_size=batch_size, + train_freq=1, + gradient_steps=1, + gamma=0.95, + ) + try: + model.learn(total_timesteps=int(config.total_timesteps), progress_bar=False) + except OSError as exc: + raise OpeningTrainingUnavailable(str(exc)) from exc + return model + + +def _evaluate_model(model, eval_episodes: Sequence[OrderbookEpisode], config: OpeningTrainingConfig) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for index, episode in enumerate(eval_episodes): + env = _make_env([episode], config) + observation, info = env.reset(seed=int(config.seed) + index) + total_reward = 0.0 + steps = 0 + terminated = False + truncated = False + last_info = dict(info) + while not (terminated or truncated) and steps < int(config.max_episode_steps): + action, _ = model.predict(observation, deterministic=True) + observation, reward, terminated, truncated, last_info = env.step(action) + total_reward += float(reward) + steps += 1 + rows.append( + { + "episode_id": episode.episode_id, + "symbol": episode.symbol, + "session": episode.session, + "reward": total_reward, + "steps": steps, + "trade_count": int(last_info.get("trade_count", 0)), + } + ) + return rows + + +def run_opening_training_stage( + frames: Sequence[pd.DataFrame], + *, + train_episode_ids: Sequence[str], + eval_episode_ids: Sequence[str], + config: OpeningTrainingConfig, +) -> dict[str, Any]: + """Run a tiny fixed-entry DQN stage and write metadata artifacts.""" + + _assert_no_overlap(train_episode_ids, eval_episode_ids) + episodes = _episodes_by_id(frames) + train_episodes = _select_episodes(episodes, train_episode_ids, "train") + eval_episodes = _select_episodes(episodes, eval_episode_ids, "eval") + output_dir = Path(config.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + summary_path = output_dir / SUMMARY_JSON + payload: dict[str, Any] = { + "artifact_type": "opening_30m_training_stage", + "mode": "opening_30m_dqn_training", + "status": "passed", + "algorithm": "DQN", + "seed": int(config.seed), + "total_timesteps": int(config.total_timesteps), + "device": str(config.device), + "fixed_entry_exit_only": bool(config.fixed_entry_exit_only), + "constrain_invalid_actions": bool(config.constrain_invalid_actions), + "cost_bps": float(config.cost_bps), + "train_episode_count": len(train_episodes), + "eval_episode_count": len(eval_episodes), + "train_episode_ids": list(train_episode_ids), + "eval_episode_ids": list(eval_episode_ids), + "evaluation": [], + "model_files": {}, + "artifacts": {"summary_json": str(summary_path)}, + "safety_note": "DQN opening training fixture only; not live-ready and not a profit model.", + "strategy_context": { + "line": "opening_rl_experiment", + "label": "RL EXPERIMENT", + "is_reinforcement_learning": True, + "is_live_ready": False, + "is_profit_model": False, + }, + } + try: + model = _train_model(train_episodes, config) + eval_rows = _evaluate_model(model, eval_episodes, config) + except OpeningTrainingUnavailable as exc: + payload["status"] = "skipped_sb3_unavailable" + payload["sb3_status"] = "skipped_sb3_unavailable" + payload["skip_reason"] = str(exc) + else: + model_path = output_dir / MODEL_ZIP + model.save(model_path) + payload["evaluation"] = eval_rows + payload["model_files"] = {"dqn": str(model_path)} + payload["artifacts"] = {"summary_json": str(summary_path), "model_zip": str(model_path)} + payload["sb3_status"] = "passed" + summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload diff --git a/stom_rl/opening_30m_rl_workflow.py b/stom_rl/opening_30m_rl_workflow.py new file mode 100644 index 000000000..d6bdb200b --- /dev/null +++ b/stom_rl/opening_30m_rl_workflow.py @@ -0,0 +1,238 @@ +"""Contract-first opening 30-minute RL workflow manifest. + +The workflow manifest is an evidence contract, not a training routine. It +lets later stages attach readiness, baseline, training, control, cost-gate, and +dashboard artifacts while preserving the project guardrails: research-only, +not live-ready, and not a profit model. +""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, Mapping, Sequence +from zoneinfo import ZoneInfo + +from .opening_30m_rl_env_contract import build_opening_env_contract_stage + + +DEFAULT_COST_BPS = 23.0 +DEFAULT_TIME_START = "090000" +DEFAULT_TIME_END = "093000" +DEFAULT_RUN_ID = "opening_30m_rl_workflow" +SUMMARY_FILENAME = "opening_30m_rl_workflow_summary.json" +GUARDRAIL = "research evidence only; not live-ready and not a profit model" + + +@dataclass(frozen=True, slots=True) +class UnsafeWorkflowClaimError(Exception): + """Raised when a workflow manifest tries to claim unsafe readiness.""" + + field_name: str + + def __str__(self) -> str: + return f"opening workflow cannot set {self.field_name}=true" + + +@dataclass(frozen=True, slots=True) +class WorkflowStageError(ValueError): + """Raised when workflow stage evidence cannot be attached.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +@dataclass(frozen=True, slots=True) +class OpeningWorkflowConfig: + """Boundary config for the opening RL workflow contract.""" + + run_id: str = DEFAULT_RUN_ID + output_dir: Path | str = Path("webui") / "rl_runs" / DEFAULT_RUN_ID + cost_bps: float = DEFAULT_COST_BPS + time_start: str = DEFAULT_TIME_START + time_end: str = DEFAULT_TIME_END + seed: int = 100 + mode: str = "fixture_contract" + is_live_ready: bool = False + is_profit_model: bool = False + + +def _assert_research_only(config: OpeningWorkflowConfig) -> None: + if config.is_live_ready: + raise UnsafeWorkflowClaimError("is_live_ready") + if config.is_profit_model: + raise UnsafeWorkflowClaimError("is_profit_model") + + +def _config_payload(config: OpeningWorkflowConfig) -> dict[str, Any]: + return { + "run_id": config.run_id, + "output_dir": str(Path(config.output_dir)), + "cost_bps": float(config.cost_bps), + "time_start": config.time_start, + "time_end": config.time_end, + "seed": int(config.seed), + "mode": config.mode, + "is_live_ready": False, + "is_profit_model": False, + } + + +def _strategy_context() -> dict[str, Any]: + return { + "line": "rl_experiment", + "label": "RL EXPERIMENT", + "primary_baseline": "ts_imb", + "is_reinforcement_learning": True, + "is_environment_readiness": False, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": GUARDRAIL, + } + + +def _stages() -> list[dict[str, str]]: + return [ + { + "name": "contract", + "status": "complete", + "evidence": "opening workflow contract emitted", + }, + {"name": "manifest", "status": "pending", "evidence": ""}, + {"name": "participant_pressure", "status": "pending", "evidence": ""}, + {"name": "orderbook_persistence", "status": "pending", "evidence": ""}, + {"name": "readiness_env", "status": "pending", "evidence": ""}, + {"name": "baseline", "status": "pending", "evidence": ""}, + {"name": "training", "status": "pending", "evidence": ""}, + {"name": "evaluation", "status": "pending", "evidence": ""}, + {"name": "controls", "status": "pending", "evidence": ""}, + {"name": "cost_gate", "status": "pending", "evidence": ""}, + {"name": "dashboard", "status": "pending", "evidence": ""}, + ] + + +def record_workflow_stage( + payload: Mapping[str, Any], + stage_name: str, + result: Mapping[str, Any], +) -> dict[str, Any]: + """Attach a stage result to an opening workflow manifest payload.""" + + updated = dict(payload) + stages = [dict(stage) for stage in payload.get("stages", [])] + stage_found = False + for stage in stages: + if stage.get("name") == stage_name: + stage["status"] = str(result.get("status", "complete")) + stage["evidence"] = str(result.get("evidence", stage_name)) + stage_found = True + break + if not stage_found: + raise WorkflowStageError(f"unknown workflow stage: {stage_name}") + stage_results = dict(payload.get("stage_results", {})) + stage_results[stage_name] = dict(result) + updated["stages"] = stages + updated["stage_results"] = stage_results + return updated + + +def build_opening_workflow_manifest(config: OpeningWorkflowConfig) -> dict[str, Any]: + """Build a minimal research-only opening workflow manifest.""" + + _assert_research_only(config) + output_dir = Path(config.output_dir) + summary_path = output_dir / SUMMARY_FILENAME + return { + "mode": "opening_30m_rl_workflow", + "artifact_type": "opening_30m_rl_workflow", + "run_id": config.run_id, + "created_at_kst": datetime.now(ZoneInfo("Asia/Seoul")).isoformat(), + "config": _config_payload(config), + "stages": _stages(), + "evidence": [], + "verdict": "PENDING", + "strategy_context": _strategy_context(), + "guardrails": { + "not_live_ready": True, + "not_profit_model": True, + "research_only": True, + "baseline": "ts_imb RULE baseline", + "cost_bps": float(config.cost_bps), + }, + "artifacts": { + "output_dir": str(output_dir), + "summary_json": str(summary_path), + }, + } + + +def write_opening_workflow_manifest(config: OpeningWorkflowConfig) -> dict[str, Any]: + """Write the opening workflow manifest and return the payload.""" + + payload = build_opening_workflow_manifest(config) + output_dir = Path(config.output_dir) + output_dir.mkdir(parents=True, exist_ok=True) + summary_path = output_dir / SUMMARY_FILENAME + summary_path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + return payload + + +def _load_input_frames(csv_paths: Sequence[str]) -> list[Any]: + if not csv_paths: + raise WorkflowStageError("--run-stages requires at least one --input-csv path") + import pandas as pd # noqa: PANDAS_OK - explicit CLI fixture CSV input for workflow stages + + return [pd.read_csv(Path(path), encoding="utf-8-sig") for path in csv_paths] + + +def _parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Write an opening 30-minute RL workflow manifest.") + parser.add_argument("--run-id", default=DEFAULT_RUN_ID) + parser.add_argument("--output-dir", default=str(Path("webui") / "rl_runs" / DEFAULT_RUN_ID)) + parser.add_argument("--cost-bps", type=float, default=DEFAULT_COST_BPS) + parser.add_argument("--time-start", default=DEFAULT_TIME_START) + parser.add_argument("--time-end", default=DEFAULT_TIME_END) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--mode", default="fixture_contract") + parser.add_argument("--no-write", action="store_true") + parser.add_argument("--run-stages", action="store_true") + parser.add_argument("--train", action="store_true") + parser.add_argument("--input-csv", action="append", default=[]) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + args = _parse_args(argv) + config = OpeningWorkflowConfig( + run_id=args.run_id, + output_dir=args.output_dir, + cost_bps=args.cost_bps, + time_start=args.time_start, + time_end=args.time_end, + seed=args.seed, + mode=args.mode, + ) + if args.run_stages: + from .opening_30m_rl_runner import run_opening_workflow_stages + + payload = run_opening_workflow_stages( + _load_input_frames(args.input_csv), + config, + request_training=bool(args.train), + ) + else: + payload = build_opening_workflow_manifest(config) if args.no_write else write_opening_workflow_manifest(config) + print(json.dumps(payload, ensure_ascii=False)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/opening_30m_rule_filter_ablations.py b/stom_rl/opening_30m_rule_filter_ablations.py new file mode 100644 index 000000000..3693a8771 --- /dev/null +++ b/stom_rl/opening_30m_rule_filter_ablations.py @@ -0,0 +1,49 @@ +"""Feature ablations for opening RULE filters.""" + +from __future__ import annotations + +from typing import Final, Mapping + +REQUIRED_RULE_FILTER_ABLATIONS: Final[tuple[str, ...]] = ( + "no_participant_pressure", + "no_orderbook_imbalance", + "no_orderbook_persistence", + "no_overheat_upper_wick", + "no_time_bucket", + "context_only", + "tick_only", + "shuffled_participant_context", +) + + +def build_rule_filter_ablation_artifact(*, full_context_return_pct: float, ablation_returns: Mapping[str, float], split_hash: str) -> dict[str, object]: + """Build required context-ablation rows for a RULE filter.""" + + rows = [_row(feature_set_id, full_context_return_pct, ablation_returns.get(feature_set_id), split_hash) for feature_set_id in REQUIRED_RULE_FILTER_ABLATIONS] + passed = all(bool(row["passed"]) for row in rows) + return { + "artifact_type": "opening_rule_filter_ablations", + "table_alias": "rule_filter_ablations", + "split_hash": split_hash, + "full_context_return_pct": float(full_context_return_pct), + "feature_ablation_passed": passed, + "ablations": rows, + } + + +def _row(feature_set_id: str, full_return: float, ablated_return: float | None, split_hash: str) -> dict[str, object]: + applicable = ablated_return is not None + value = float(ablated_return) if ablated_return is not None else None + shuffled = feature_set_id == "shuffled_participant_context" + passed = False if not applicable else float(full_return) > float(value) + return { + "feature_set_id": feature_set_id, + "split_hash": split_hash, + "applicable": applicable, + "comparison_status": "compared" if applicable else "missing_required_ablation", + "full_context_return_pct": float(full_return), + "ablated_return_pct": value, + "delta_vs_full_oos_pct": None if value is None else float(full_return) - float(value), + "shuffled_context": shuffled, + "passed": passed, + } diff --git a/stom_rl/opening_30m_rule_filter_artifacts.py b/stom_rl/opening_30m_rule_filter_artifacts.py new file mode 100644 index 000000000..161c164b2 --- /dev/null +++ b/stom_rl/opening_30m_rule_filter_artifacts.py @@ -0,0 +1,78 @@ +"""Artifact writer for opening RULE filter runs.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Mapping, Sequence + +from .opening_30m_rule_filter_contract import rule_filter_strategy_context + + +def write_rule_filter_artifacts( + *, + output_dir: Path, + split_manifest: Mapping[str, Any], + policy: Mapping[str, Any], + controls: Mapping[str, Any], + ablations: Mapping[str, Any], + gate: Mapping[str, Any], + dataset_rows: Sequence[Mapping[str, Any]], +) -> dict[str, Any]: + """Write lifecycle and table artifacts for dashboard inspection.""" + + output_dir.mkdir(parents=True, exist_ok=True) + baseline_semantics = _baseline_semantics() + lifecycle = { + "artifact_type": "opening_rule_filter_lifecycle", + "strategy_context": rule_filter_strategy_context(), + "feature_set_id": policy.get("feature_set_id", "full_context"), + "baseline_semantics": baseline_semantics, + "split_manifest": dict(split_manifest), + "policy": dict(policy), + "controls": dict(controls), + "ablations": dict(ablations), + "promotion_gate": dict(gate), + "dataset_rows": [dict(row) for row in dataset_rows], + "context_features": _context_features(dataset_rows), + } + _write(output_dir / "opening_rule_filter_lifecycle.json", lifecycle) + _write(output_dir / "opening_rule_filter_controls.json", dict(controls)) + _write(output_dir / "opening_rule_filter_ablations.json", dict(ablations)) + _write(output_dir / "opening_rule_filter_gate.json", dict(gate)) + summary = { + "artifact_type": "opening_30m_rule_filter", + "verdict": gate.get("verdict", "INCONCLUSIVE"), + "split_hash": split_manifest.get("split_hash"), + "cost_bps": gate.get("cost_bps"), + "feature_set_id": policy.get("feature_set_id", "full_context"), + "baseline_semantics": baseline_semantics, + "strategy_context": rule_filter_strategy_context(), + "artifacts": {"output_dir": str(output_dir), "lifecycle_json": str(output_dir / "opening_rule_filter_lifecycle.json")}, + "rule_filter_lifecycle": lifecycle, + } + _write(output_dir / "opening_rule_filter_summary.json", summary) + return summary + + +def _baseline_semantics() -> dict[str, str]: + return { + "artifact_buy_and_hold": "rule-filter control derived from base dataset rows; may equal ts_imb_rule when base_action is already ts_imb-gated", + "artifact_ts_imb_rule": "RULE baseline derived from base rule-filter dataset rows; never reinforcement learning", + "independent_buy_and_hold_source": "stom_rl.opening_30m_rl_baselines.evaluate_opening_baselines", + "guardrail": "Do not report artifact baseline equality as independent outperformance.", + } + + +def _context_features(rows: Sequence[Mapping[str, Any]]) -> dict[str, Any]: + if not rows: + return {"feature_names": [], "sample": {}, "status": "empty"} + first = rows[0] + features = first.get("feature_values", {}) + if not isinstance(features, dict): + return {"feature_names": [], "sample": {}, "status": "missing"} + return {"feature_names": list(features), "sample": {"episode_id": first.get("episode_id"), "vector": list(features.values())}, "status": "available"} + + +def _write(path: Path, payload: Mapping[str, Any]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") diff --git a/stom_rl/opening_30m_rule_filter_cli.py b/stom_rl/opening_30m_rule_filter_cli.py new file mode 100644 index 000000000..3f50b50d1 --- /dev/null +++ b/stom_rl/opening_30m_rule_filter_cli.py @@ -0,0 +1,257 @@ +"""Bounded CLI for opening 30m RULE filter smoke runs.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any, Final, Mapping, Sequence + +from .opening_30m_rl_oos_split import OosSplitError, build_oos_split_manifest, validate_oos_split_manifest +from .opening_30m_rl_realdata_adapter import RealdataAdapterConfig, RealdataNoGoDataError, load_opening_realdata_frames +from .opening_30m_rule_filter_ablations import build_rule_filter_ablation_artifact +from .opening_30m_rule_filter_artifacts import write_rule_filter_artifacts +from .opening_30m_rule_filter_contract import RULE_FILTER_FEATURE_SET_IDS, RuleFilterConfig +from .opening_30m_rule_filter_controls import build_rule_filter_control_artifact +from .opening_30m_rule_filter_dataset import build_rule_filter_dataset +from .opening_30m_rule_filter_gate import RuleFilterGateInput, evaluate_rule_filter_gate +from .opening_30m_rule_filter_policy import evaluate_rule_filter_metrics, select_rule_filter_policy +from .opening_30m_rule_filter_transforms import build_rule_filter_ablation_returns + +_RUN_ID_PATTERN: Final[re.Pattern[str]] = re.compile(r"^[A-Za-z0-9_.-]+$") + + +def main(argv: Sequence[str] | None = None) -> int: + """Run a bounded read-only RULE filter smoke workflow.""" + + args = _parse_args(argv) + split_path = Path(str(args.split_manifest)) if args.split_manifest else None + if split_path is not None and not split_path.is_file(): + print(f"split manifest not found: {split_path}", file=sys.stderr) + return 2 + output_dir = _resolve_output_dir(Path(str(args.output_dir)), str(args.run_id)) + if output_dir is None: + print(f"invalid run id: {args.run_id}", file=sys.stderr) + return 2 + if output_dir.exists(): + print(f"run output directory already exists: {output_dir}", file=sys.stderr) + return 2 + config = RuleFilterConfig(cost_bps=float(args.cost_bps), decision_second=int(args.decision_second), min_oos_take_trades=int(args.min_oos_take_trades), feature_set_id=str(args.feature_set)) + adapter_config = RealdataAdapterConfig( + db_path=Path(str(args.db)), + output_dir=output_dir / "realdata_adapter", + max_tables=int(args.max_tables), + max_sessions_per_table=int(args.max_sessions_per_table), + max_rows_per_session=int(args.max_rows_per_session), + min_rows_per_session=int(args.min_rows_per_session), + time_start=str(args.time_start), + time_end=str(args.time_end), + ) + try: + adapter = load_opening_realdata_frames(adapter_config) + split_manifest = _load_or_create_split(adapter.frames, split_path, bool(args.create_split), output_dir) + except (RealdataNoGoDataError, OosSplitError, FileNotFoundError) as exc: + print(str(exc), file=sys.stderr) + return 2 + dataset = build_rule_filter_dataset(adapter.frames, split_manifest=split_manifest, config=config) + policy = select_rule_filter_policy(dataset["rows"], config=config, split_hash=str(split_manifest["split_hash"])) + controls = _controls(policy, dataset, config, str(split_manifest["split_hash"])) + ablations = _ablations(policy, dataset, config, str(split_manifest["split_hash"])) + gate = _gate(policy, dataset, controls, ablations, config, str(split_manifest["split_hash"])) + summary = write_rule_filter_artifacts( + output_dir=output_dir, + split_manifest=split_manifest, + policy=policy, + controls=controls, + ablations=ablations, + gate=gate, + dataset_rows=dataset["rows"], + ) + print(json.dumps({"artifact_type": summary["artifact_type"], "run_id": str(args.run_id), "verdict": summary["verdict"], "summary_json": str(output_dir / "opening_rule_filter_summary.json"), "split_hash": summary["split_hash"]}, ensure_ascii=False)) + return 0 + + +def _parse_args(argv: Sequence[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run a bounded opening 30m RULE filter smoke workflow.") + parser.add_argument("--db", required=True) + parser.add_argument("--output-dir", required=True) + parser.add_argument("--run-id", default="opening_30m_rule_filter_smoke") + parser.add_argument("--split-manifest", default="") + parser.add_argument("--create-split", action="store_true") + parser.add_argument("--max-tables", type=int, default=1) + parser.add_argument("--max-sessions-per-table", type=int, default=3) + parser.add_argument("--max-rows-per-session", type=int, default=1800) + parser.add_argument("--min-rows-per-session", type=int, default=4) + parser.add_argument("--time-start", default="090000") + parser.add_argument("--time-end", default="093000") + parser.add_argument("--cost-bps", type=float, default=23.0) + parser.add_argument("--decision-second", type=int, default=0) + parser.add_argument("--min-oos-take-trades", type=int, default=1) + parser.add_argument("--feature-set", choices=RULE_FILTER_FEATURE_SET_IDS, default="full_context") + return parser.parse_args(argv) + + +def _resolve_output_dir(output_root: Path, run_id: str) -> Path | None: + if not _is_safe_run_id(run_id): + return None + return output_root / run_id + + +def _is_safe_run_id(run_id: str) -> bool: + return run_id not in {".", ".."} and _RUN_ID_PATTERN.fullmatch(run_id) is not None + + +def _load_or_create_split(frames: Sequence[object], split_path: Path | None, create_split: bool, output_dir: Path) -> dict[str, object]: + if split_path is not None: + manifest = json.loads(split_path.read_text(encoding="utf-8-sig")) + validate_oos_split_manifest(manifest) + return manifest + if not create_split: + raise OosSplitError("rule filter smoke requires --create-split or --split-manifest") + sessions = sorted({str(frame["session"].iloc[0]) for frame in frames}) + if len(sessions) < 3: + raise OosSplitError("rule filter smoke requires at least three sessions") + train_end = max(1, int(len(sessions) * 0.6)) + validation_end = max(train_end + 1, int(len(sessions) * 0.8)) + validation_end = min(validation_end, len(sessions) - 1) + symbol_sessions: dict[str, list[str]] = {} + for frame in frames: + symbol = str(frame["symbol"].iloc[0]) + symbol_sessions.setdefault(symbol, []).append(str(frame["session"].iloc[0])) + return build_oos_split_manifest( + { + "train": sessions[:train_end], + "validation": sessions[train_end:validation_end], + "oos": sessions[validation_end:], + }, + symbol_sessions=symbol_sessions, + output_path=output_dir / "opening_rule_filter_split_manifest.json", + ) + + +def _controls(policy: dict[str, object], dataset: dict[str, object], config: RuleFilterConfig, split_hash: str) -> dict[str, object]: + rows = _rows(dataset) + oos_rows = [row for row in rows if str(row.get("split")) == "oos"] + threshold = _threshold(policy) + filter_return = float(policy["oos_metrics"]["net_return_pct"]) + return build_rule_filter_control_artifact( + filter_oos_net_return_pct=filter_return, + baseline_returns={ + "no_trade": 0.0, + "buy_and_hold": _buy_and_hold_return(oos_rows), + "ts_imb_rule": _ts_imb_return(oos_rows), + }, + split_hash=split_hash, + cost_bps=config.cost_bps, + shuffled_label_return_pct=float(evaluate_rule_filter_metrics(_shuffled_returns(oos_rows), threshold, feature_set_id=config.feature_set_id)["net_return_pct"]), + time_session_shuffle_return_pct=float(evaluate_rule_filter_metrics(_rotated_features(oos_rows), threshold, feature_set_id=config.feature_set_id)["net_return_pct"]), + randomized_feature_return_pct=float(evaluate_rule_filter_metrics(_randomized_features(oos_rows), threshold, feature_set_id=config.feature_set_id)["net_return_pct"]), + ) + + +def _ablations(policy: dict[str, object], dataset: dict[str, object], config: RuleFilterConfig, split_hash: str) -> dict[str, object]: + rows = _rows(dataset) + full = float(policy["oos_metrics"]["net_return_pct"]) + return build_rule_filter_ablation_artifact( + full_context_return_pct=full, + ablation_returns=build_rule_filter_ablation_returns(rows, config, split_hash), + split_hash=split_hash, + ) + + +def _gate(policy: dict[str, object], dataset: dict[str, object], controls: dict[str, object], ablations: dict[str, object], config: RuleFilterConfig, split_hash: str) -> dict[str, object]: + oos = policy["oos_metrics"] + control_rows = {str(row["control_type"]): row for row in controls.get("controls", []) if isinstance(row, dict)} + return evaluate_rule_filter_gate( + RuleFilterGateInput( + split_hash=split_hash, + cost_bps=config.cost_bps, + validation_net_return_pct=float(policy["validation_metrics"]["net_return_pct"]), + oos_net_return_pct=float(oos["net_return_pct"]), + no_trade_net_return_pct=float(control_rows.get("no_trade", {}).get("control_net_return_pct", 0.0)), + buy_and_hold_net_return_pct=float(control_rows.get("buy_and_hold", {}).get("control_net_return_pct", 0.0)), + ts_imb_rule_net_return_pct=float(control_rows.get("ts_imb_rule", {}).get("control_net_return_pct", 0.0)), + controls_passed=bool(controls["negative_control_passed"]), + ablations_passed=bool(ablations["feature_ablation_passed"]), + oos_take_count=int(oos["take_count"]), + min_oos_take_count=int(config.min_oos_take_trades), + max_drawdown_pct=_max_drawdown_pct(_taken_oos_returns(policy, dataset)), + max_allowed_drawdown_pct=float(config.max_drawdown_pct), + skipped_opportunity_cost_pct=float(oos["skipped_opportunity_cost_pct"]), + ) + ) + + +def _rows(dataset: Mapping[str, object]) -> list[dict[str, Any]]: + return [dict(row) for row in dataset.get("rows", []) if isinstance(row, dict)] + + +def _threshold(policy: Mapping[str, object]) -> dict[str, float]: + raw = policy.get("selected_thresholds", {}) + return {str(key): float(value) for key, value in raw.items()} if isinstance(raw, dict) else {} + + +def _copy_row(row: Mapping[str, Any], features: Mapping[str, Any] | None = None, base_return: float | None = None) -> dict[str, Any]: + copied = dict(row) + copied["feature_values"] = dict(features if features is not None else row.get("feature_values", {})) + if base_return is not None: + copied["base_net_return_pct"] = float(base_return) + copied["skipped_opportunity_net_return_pct"] = float(base_return) if str(copied.get("base_action")) == "TAKE" else 0.0 + return copied + + +def _rotate(values: list[Any]) -> list[Any]: + return values[1:] + values[:1] if len(values) > 1 else values + + +def _shuffled_returns(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + returns = _rotate([float(row.get("base_net_return_pct", 0.0)) for row in rows]) + return [_copy_row(row, base_return=returns[index]) for index, row in enumerate(rows)] + + +def _rotated_features(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + feature_sets = _rotate([dict(row.get("feature_values", {})) for row in rows]) + return [_copy_row(row, features=feature_sets[index]) for index, row in enumerate(rows)] + + +def _randomized_features(rows: Sequence[Mapping[str, Any]]) -> list[dict[str, Any]]: + return [_copy_row(row, features={key: _pseudo_unit(str(row.get("episode_id", "")) + key) for key in dict(row.get("feature_values", {}))}) for row in rows] + + +def _pseudo_unit(seed: str) -> float: + return (sum((idx + 1) * ord(ch) for idx, ch in enumerate(seed)) % 1000) / 1000.0 + + +def _ts_imb_return(rows: Sequence[Mapping[str, Any]]) -> float: + return sum(float(row.get("base_net_return_pct", 0.0)) for row in rows) + + +def _buy_and_hold_return(rows: Sequence[Mapping[str, Any]]) -> float: + return sum(float(row.get("base_net_return_pct", 0.0)) for row in rows if str(row.get("base_action")) == "TAKE") + + +def _taken_oos_returns(policy: Mapping[str, object], dataset: Mapping[str, object]) -> list[float]: + actions = policy.get("actions_by_episode", {}) + action_map = actions if isinstance(actions, dict) else {} + returns: list[float] = [] + for row in _rows(dataset): + if str(row.get("split")) == "oos" and str(action_map.get(str(row.get("episode_id")))) == "TAKE": + returns.append(float(row.get("base_net_return_pct", 0.0))) + return returns + + +def _max_drawdown_pct(returns: Sequence[float]) -> float: + equity = 0.0 + peak = 0.0 + max_drawdown = 0.0 + for value in returns: + equity += float(value) + peak = max(peak, equity) + max_drawdown = max(max_drawdown, peak - equity) + return max_drawdown + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/opening_30m_rule_filter_contract.py b/stom_rl/opening_30m_rule_filter_contract.py new file mode 100644 index 000000000..28a861b01 --- /dev/null +++ b/stom_rl/opening_30m_rule_filter_contract.py @@ -0,0 +1,84 @@ +"""Contracts for opening 30m RULE/meta-label filters.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Literal, TypedDict + +ACTION_TAKE: Final = "TAKE" +ACTION_SKIP: Final = "SKIP" +ActionLabel = Literal["TAKE", "SKIP"] + +VERDICT_GO_RULE_FILTER: Final = "GO_RULE_FILTER" +VERDICT_NO_GO_BASELINE: Final = "NO-GO_BASELINE" +VERDICT_NO_GO_CONTROL: Final = "NO-GO_CONTROL" +VERDICT_NO_GO_ABLATION: Final = "NO-GO_ABLATION" +VERDICT_INCONCLUSIVE: Final = "INCONCLUSIVE" +RuleFilterVerdict = Literal[ + "GO_RULE_FILTER", + "NO-GO_BASELINE", + "NO-GO_CONTROL", + "NO-GO_ABLATION", + "INCONCLUSIVE", +] + +RULE_FILTER_ARTIFACT_TYPE: Final = "opening_30m_rule_filter" +RULE_FILTER_BASELINE_LABEL: Final = "ts_imb RULE baseline" +FEATURE_SET_FULL_CONTEXT: Final = "full_context" +FEATURE_SET_MINIMAL_TS_IMB: Final = "minimal_ts_imb" +FEATURE_SET_TIME_BUCKET_ONLY: Final = "time_bucket_only" +RuleFilterFeatureSetId = Literal["full_context", "minimal_ts_imb", "time_bucket_only"] +RULE_FILTER_FEATURE_SET_IDS: Final[tuple[RuleFilterFeatureSetId, ...]] = ( + "full_context", + "minimal_ts_imb", + "time_bucket_only", +) +RULE_FILTER_TABLE_ALIASES: Final[tuple[str, ...]] = ( + "rule_filter_lifecycle", + "rule_filter_splits", + "rule_filter_controls", + "rule_filter_ablations", + "rule_filter_equity_curve", + "rule_filter_time_buckets", + "rule_filter_failure_reasons", + "rule_filter_opportunity_cost", + "rule_filter_proxy_availability", + "rule_filter_orderbook_persistence", + "rule_filter_context_sample", +) + + +@dataclass(frozen=True, slots=True) +class RuleFilterConfig: + """Configuration for a bounded opening RULE filter.""" + + cost_bps: float = 23.0 + min_oos_take_trades: int = 3 + decision_second: int = 0 + max_drawdown_pct: float = 5.0 + primary_baseline: str = RULE_FILTER_BASELINE_LABEL + feature_set_id: RuleFilterFeatureSetId = FEATURE_SET_FULL_CONTEXT + + +class StrategyContext(TypedDict): + line: str + label: str + primary_baseline: str + is_reinforcement_learning: bool + is_live_ready: bool + is_profit_model: bool + guardrail: str + + +def rule_filter_strategy_context() -> StrategyContext: + """Return immutable research-only labeling for rule-filter artifacts.""" + + return { + "line": "rule_meta_label_filter", + "label": "RULE META-LABEL FILTER", + "primary_baseline": RULE_FILTER_BASELINE_LABEL, + "is_reinforcement_learning": False, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": "RULE filter research only; not live-ready; proxy evidence only.", + } diff --git a/stom_rl/opening_30m_rule_filter_controls.py b/stom_rl/opening_30m_rule_filter_controls.py new file mode 100644 index 000000000..d4b5c0462 --- /dev/null +++ b/stom_rl/opening_30m_rule_filter_controls.py @@ -0,0 +1,51 @@ +"""Negative controls for opening RULE filters.""" + +from __future__ import annotations + +from typing import Mapping + +from .opening_30m_rule_filter_contract import VERDICT_NO_GO_CONTROL + + +def build_rule_filter_control_artifact( + *, + filter_oos_net_return_pct: float, + baseline_returns: Mapping[str, float], + split_hash: str, + cost_bps: float, + shuffled_label_return_pct: float, + time_session_shuffle_return_pct: float, + randomized_feature_return_pct: float, +) -> dict[str, object]: + """Build same-split negative-control rows for a RULE filter.""" + + rows = [ + _row("no_trade", filter_oos_net_return_pct, baseline_returns.get("no_trade", 0.0), split_hash, cost_bps, "baseline_same_split"), + _row("buy_and_hold", filter_oos_net_return_pct, baseline_returns.get("buy_and_hold", 0.0), split_hash, cost_bps, "baseline_same_split"), + _row("ts_imb_rule", filter_oos_net_return_pct, baseline_returns.get("ts_imb_rule", 0.0), split_hash, cost_bps, "baseline_same_split"), + _row("shuffled_labels", filter_oos_net_return_pct, shuffled_label_return_pct, split_hash, cost_bps, "negative_control"), + _row("time_session_shuffle", filter_oos_net_return_pct, time_session_shuffle_return_pct, split_hash, cost_bps, "negative_control"), + _row("randomized_features", filter_oos_net_return_pct, randomized_feature_return_pct, split_hash, cost_bps, "negative_control"), + ] + passed = all(bool(row["passed"]) for row in rows) + return { + "artifact_type": "opening_rule_filter_controls", + "table_alias": "rule_filter_controls", + "split_hash": split_hash, + "cost_bps": float(cost_bps), + "negative_control_passed": passed, + "blocking_verdict": "" if passed else VERDICT_NO_GO_CONTROL, + "controls": rows, + } + + +def _row(control_type: str, filter_return: float, control_return: float, split_hash: str, cost_bps: float, source: str) -> dict[str, object]: + return { + "control_type": control_type, + "split_hash": split_hash, + "cost_bps": float(cost_bps), + "evaluation_source": source, + "filter_oos_net_return_pct": float(filter_return), + "control_net_return_pct": float(control_return), + "passed": float(filter_return) > float(control_return), + } diff --git a/stom_rl/opening_30m_rule_filter_dataset.py b/stom_rl/opening_30m_rule_filter_dataset.py new file mode 100644 index 000000000..0d2d9381e --- /dev/null +++ b/stom_rl/opening_30m_rule_filter_dataset.py @@ -0,0 +1,84 @@ +"""Causal dataset rows for the opening RULE filter.""" + +from __future__ import annotations + +from typing import Any, Mapping, Sequence + +import pandas as pd # noqa: PANDAS_OK - existing STOM opening pipeline is pandas-based + +from .opening_30m_rl_baselines import OpeningBaselineConfig, POLICY_TS_IMB, evaluate_opening_baselines +from .opening_30m_rl_context import build_opening_rl_context +from .opening_30m_rl_oos_split import validate_oos_split_manifest +from .opening_30m_rule_filter_contract import ACTION_SKIP, ACTION_TAKE, RuleFilterConfig, rule_filter_strategy_context + + +def build_rule_filter_dataset( + frames: Sequence[pd.DataFrame], + *, + split_manifest: Mapping[str, Any], + config: RuleFilterConfig, +) -> dict[str, Any]: + """Build TAKE/SKIP meta-label rows without using future rows for features.""" + + validate_oos_split_manifest(split_manifest) + split_hash = str(split_manifest["split_hash"]) + split_lookup = _split_lookup(split_manifest) + baseline = evaluate_opening_baselines( + frames, + OpeningBaselineConfig(decision_index=config.decision_second, cost_bps=config.cost_bps), + ) + baseline_rows = { + str(row["episode_id"]): row + for row in baseline["rows"] + if str(row.get("policy")) == POLICY_TS_IMB + } + rows = [_dataset_row(frame, baseline_rows, split_lookup, split_hash, config) for frame in frames] + return { + "artifact_type": "opening_rule_filter_dataset", + "split_hash": split_hash, + "cost_bps": float(config.cost_bps), + "rows": rows, + "strategy_context": rule_filter_strategy_context(), + } + + +def _dataset_row( + frame: pd.DataFrame, + baseline_rows: Mapping[str, Mapping[str, Any]], + split_lookup: Mapping[str, str], + split_hash: str, + config: RuleFilterConfig, +) -> dict[str, Any]: + episode_id = f"{frame['symbol'].iloc[0]}_{frame['session'].iloc[0]}" + baseline = baseline_rows[episode_id] + context = build_opening_rl_context(frame, decision_second=config.decision_second) + features = dict(zip(context["feature_names"], context["vector"], strict=True)) + features["time_bucket_0_10"] = 1.0 if config.decision_second <= 10 else 0.0 + base_net = float(baseline["net_return_pct"]) + base_action = ACTION_TAKE if int(baseline.get("trade_count", 0)) > 0 else ACTION_SKIP + session = str(frame["session"].iloc[0]) + return { + "episode_id": episode_id, + "symbol": str(frame["symbol"].iloc[0]), + "session": session, + "split": split_lookup[session], + "split_hash": split_hash, + "cost_bps": float(config.cost_bps), + "base_action": base_action, + "meta_label_take": 1 if base_action == ACTION_TAKE and base_net > 0.0 else 0, + "base_net_return_pct": base_net, + "skip_net_return_pct": 0.0, + "skipped_opportunity_net_return_pct": base_net if base_action == ACTION_TAKE else 0.0, + "feature_values": features, + "proxy_availability": {name: value for name, value in features.items() if name.startswith("proxy_available_")}, + "diagnostics": context["diagnostics"], + } + + +def _split_lookup(split_manifest: Mapping[str, Any]) -> dict[str, str]: + raw = split_manifest["split_sessions"] + lookup: dict[str, str] = {} + for split, sessions in raw.items(): + for session in sessions: + lookup[str(session)] = str(split) + return lookup diff --git a/stom_rl/opening_30m_rule_filter_gate.py b/stom_rl/opening_30m_rule_filter_gate.py new file mode 100644 index 000000000..515a920e8 --- /dev/null +++ b/stom_rl/opening_30m_rule_filter_gate.py @@ -0,0 +1,130 @@ +"""Promotion gate for opening RULE filters.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from .opening_30m_rule_filter_contract import ( + VERDICT_GO_RULE_FILTER, + VERDICT_INCONCLUSIVE, + VERDICT_NO_GO_ABLATION, + VERDICT_NO_GO_BASELINE, + VERDICT_NO_GO_CONTROL, + rule_filter_strategy_context, +) + + +EXPECTED_RULE_FILTER_COST_BPS = 23.0 + + +@dataclass(frozen=True, slots=True) +class RuleFilterGateInput: + """Evidence required for a RULE filter decision.""" + + split_hash: str + cost_bps: float + validation_net_return_pct: float + oos_net_return_pct: float + no_trade_net_return_pct: float + buy_and_hold_net_return_pct: float + ts_imb_rule_net_return_pct: float + controls_passed: bool + ablations_passed: bool + oos_take_count: int + min_oos_take_count: int = 1 + max_drawdown_pct: float = 0.0 + max_allowed_drawdown_pct: float = 5.0 + skipped_opportunity_cost_pct: float = 0.0 + + +def evaluate_rule_filter_gate(gate: RuleFilterGateInput) -> dict[str, Any]: + """Promote only filters that beat baselines, controls, and ablations.""" + + reasons = _blocking_reasons(gate) + verdict = _verdict(reasons) + return { + "artifact_type": "opening_rule_filter_gate", + "split_hash": gate.split_hash, + "cost_bps": float(gate.cost_bps), + "verdict": verdict, + "can_show_go_rule_filter": verdict == VERDICT_GO_RULE_FILTER, + "blocking_reasons": reasons, + "metrics": _metrics(gate), + "baseline_results": _baseline_results(gate), + "equity_curve": _equity_curve(gate), + "opportunity_cost_curve": _opportunity_cost_curve(gate), + "time_bucket_performance": _time_buckets(gate), + "strategy_context": rule_filter_strategy_context(), + } + + +def _blocking_reasons(gate: RuleFilterGateInput) -> list[str]: + reasons: list[str] = [] + if float(gate.cost_bps) != EXPECTED_RULE_FILTER_COST_BPS: + reasons.append("failed_cost:expected_23bp") + if gate.oos_take_count <= 0: + reasons.append("no_oos_take_trades") + if gate.oos_take_count < gate.min_oos_take_count: + reasons.append("insufficient_oos_take_trades") + if gate.max_drawdown_pct > gate.max_allowed_drawdown_pct: + reasons.append("failed_risk:max_drawdown") + if gate.oos_net_return_pct <= gate.no_trade_net_return_pct: + reasons.append("failed_baseline:no_trade") + if gate.oos_net_return_pct <= gate.buy_and_hold_net_return_pct: + reasons.append("failed_baseline:buy_and_hold") + if gate.oos_net_return_pct <= gate.ts_imb_rule_net_return_pct: + reasons.append("failed_baseline:ts_imb_rule") + if not gate.controls_passed: + reasons.append("failed_controls") + if not gate.ablations_passed: + reasons.append("failed_ablations") + return reasons + + +def _verdict(reasons: list[str]) -> str: + if not reasons: + return VERDICT_GO_RULE_FILTER + if "failed_controls" in reasons or "failed_cost:expected_23bp" in reasons: + return VERDICT_NO_GO_CONTROL + if "failed_ablations" in reasons: + return VERDICT_NO_GO_ABLATION + if any(reason.startswith("failed_baseline") for reason in reasons): + return VERDICT_NO_GO_BASELINE + return VERDICT_INCONCLUSIVE + + +def _metrics(gate: RuleFilterGateInput) -> dict[str, float | int]: + return { + "validation_net_return_pct": float(gate.validation_net_return_pct), + "oos_net_return_pct": float(gate.oos_net_return_pct), + "oos_take_count": int(gate.oos_take_count), + "max_drawdown_pct": float(gate.max_drawdown_pct), + "max_allowed_drawdown_pct": float(gate.max_allowed_drawdown_pct), + "min_oos_take_count": int(gate.min_oos_take_count), + "skipped_opportunity_cost_pct": float(gate.skipped_opportunity_cost_pct), + } + + +def _baseline_results(gate: RuleFilterGateInput) -> dict[str, dict[str, float | bool]]: + return { + "no_trade": _baseline_row(gate.oos_net_return_pct, gate.no_trade_net_return_pct), + "buy_and_hold": _baseline_row(gate.oos_net_return_pct, gate.buy_and_hold_net_return_pct), + "ts_imb_rule": _baseline_row(gate.oos_net_return_pct, gate.ts_imb_rule_net_return_pct), + } + + +def _baseline_row(candidate: float, baseline: float) -> dict[str, float | bool]: + return {"filter_net_return_pct": float(candidate), "baseline_net_return_pct": float(baseline), "passed": float(candidate) > float(baseline)} + + +def _equity_curve(gate: RuleFilterGateInput) -> list[dict[str, float | int]]: + return [{"step": 0, "net_return_pct": 0.0}, {"step": 1, "net_return_pct": float(gate.oos_net_return_pct)}] + + +def _opportunity_cost_curve(gate: RuleFilterGateInput) -> list[dict[str, float | int]]: + return [{"step": 0, "skipped_opportunity_cost_pct": 0.0}, {"step": 1, "skipped_opportunity_cost_pct": float(gate.skipped_opportunity_cost_pct)}] + + +def _time_buckets(gate: RuleFilterGateInput) -> list[dict[str, float | int | str]]: + return [{"bucket": "0900-0930", "take_count": int(gate.oos_take_count), "net_return_pct": float(gate.oos_net_return_pct)}] diff --git a/stom_rl/opening_30m_rule_filter_policy.py b/stom_rl/opening_30m_rule_filter_policy.py new file mode 100644 index 000000000..69ffb7ca3 --- /dev/null +++ b/stom_rl/opening_30m_rule_filter_policy.py @@ -0,0 +1,91 @@ +"""Deterministic threshold policy for opening RULE filters.""" + +from __future__ import annotations + +from itertools import product +from typing import Any, Mapping, Sequence, assert_never + +from .opening_30m_rule_filter_contract import ACTION_SKIP, ACTION_TAKE, FEATURE_SET_FULL_CONTEXT, VERDICT_INCONCLUSIVE, RuleFilterConfig, RuleFilterFeatureSetId, rule_filter_strategy_context + + +def select_rule_filter_policy(rows: Sequence[Mapping[str, Any]], *, config: RuleFilterConfig, split_hash: str) -> dict[str, Any]: + """Select thresholds on validation rows and evaluate once on OOS rows.""" + + train_rows = [row for row in rows if str(row.get("split")) == "train"] + validation_rows = [row for row in rows if str(row.get("split")) == "validation"] + oos_rows = [row for row in rows if str(row.get("split")) == "oos"] + threshold = _select_threshold(validation_rows or train_rows, feature_set_id=config.feature_set_id) + actions = {str(row["episode_id"]): _action(row, threshold, feature_set_id=config.feature_set_id) for row in rows} + validation_metrics = evaluate_rule_filter_metrics(validation_rows, threshold, feature_set_id=config.feature_set_id) + oos_metrics = evaluate_rule_filter_metrics(oos_rows, threshold, feature_set_id=config.feature_set_id) + verdict_hint = VERDICT_INCONCLUSIVE if int(oos_metrics["take_count"]) < config.min_oos_take_trades else "" + return { + "artifact_type": "opening_rule_filter_policy", + "policy_id": f"rule_filter_{split_hash}", + "split_hash": split_hash, + "feature_set_id": config.feature_set_id, + "selected_thresholds": threshold, + "validation_metrics": validation_metrics, + "oos_metrics": oos_metrics, + "actions_by_episode": actions, + "verdict_hint": verdict_hint, + "strategy_context": rule_filter_strategy_context(), + } + + +def _select_threshold(rows: Sequence[Mapping[str, Any]], *, feature_set_id: RuleFilterFeatureSetId) -> dict[str, float]: + candidates = [ + {"score_threshold": score, "max_overheat_score": overheat} + for score, overheat in product((0.0, 0.25, 0.5, 0.75), (0.25, 0.5, 0.75, 1.0)) + ] + return max(candidates, key=lambda item: (evaluate_rule_filter_metrics(rows, item, feature_set_id=feature_set_id)["net_return_pct"], evaluate_rule_filter_metrics(rows, item, feature_set_id=feature_set_id)["take_count"])) + + +def _action(row: Mapping[str, Any], threshold: Mapping[str, float], *, feature_set_id: RuleFilterFeatureSetId) -> str: + if str(row.get("base_action")) != ACTION_TAKE: + return ACTION_SKIP + match feature_set_id: + case "minimal_ts_imb": + return ACTION_TAKE + case "full_context": + features = row["feature_values"] + overheat = float(features.get("overheat_score", 0.0)) + if _score(features, feature_set_id=feature_set_id) >= float(threshold["score_threshold"]) and overheat <= float(threshold["max_overheat_score"]): + return ACTION_TAKE + return ACTION_SKIP + case "time_bucket_only": + if _score(row["feature_values"], feature_set_id=feature_set_id) >= float(threshold["score_threshold"]): + return ACTION_TAKE + return ACTION_SKIP + case unreachable: + assert_never(unreachable) + + +def evaluate_rule_filter_metrics(rows: Sequence[Mapping[str, Any]], threshold: Mapping[str, float], *, feature_set_id: RuleFilterFeatureSetId = FEATURE_SET_FULL_CONTEXT) -> dict[str, float | int]: + """Evaluate a fixed threshold on supplied rows without selecting on OOS.""" + net = 0.0 + take_count = 0 + skipped_cost = 0.0 + for row in rows: + if _action(row, threshold, feature_set_id=feature_set_id) == ACTION_TAKE: + net += float(row.get("base_net_return_pct", 0.0)) + take_count += 1 + else: + skipped_cost += max(0.0, float(row.get("skipped_opportunity_net_return_pct", 0.0))) + return {"net_return_pct": net, "take_count": take_count, "skipped_opportunity_cost_pct": skipped_cost} + + +def _score(features: Mapping[str, Any], *, feature_set_id: RuleFilterFeatureSetId) -> float: + match feature_set_id: + case "full_context": + participant = float(features.get("participant_pressure_score", 0.0)) + orderbook = float(features.get("orderbook_persistence_score", 0.0)) + overheat = float(features.get("overheat_score", 0.0)) + time_bucket = float(features.get("time_bucket_0_10", 0.0)) + return (participant + orderbook + time_bucket + (1.0 - overheat)) / 4.0 + case "minimal_ts_imb": + return 1.0 + case "time_bucket_only": + return float(features.get("time_bucket_0_10", 0.0)) + case unreachable: + assert_never(unreachable) diff --git a/stom_rl/opening_30m_rule_filter_transforms.py b/stom_rl/opening_30m_rule_filter_transforms.py new file mode 100644 index 000000000..91baf1773 --- /dev/null +++ b/stom_rl/opening_30m_rule_filter_transforms.py @@ -0,0 +1,115 @@ +"""Feature transforms for opening RULE filter controls and ablations.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, Mapping, Sequence + +from .opening_30m_rule_filter_contract import RuleFilterConfig +from .opening_30m_rule_filter_policy import select_rule_filter_policy + +RULE_FILTER_ABLATION_IDS: tuple[str, ...] = ( + "no_participant_pressure", + "no_orderbook_imbalance", + "no_orderbook_persistence", + "no_overheat_upper_wick", + "no_time_bucket", + "context_only", + "tick_only", + "shuffled_participant_context", +) + + +@dataclass(frozen=True, slots=True) +class RuleFilterTransformError(ValueError): + """Raised when a feature transform id is not supported.""" + + feature_set: str + + def __str__(self) -> str: + return f"unsupported rule-filter feature transform: {self.feature_set}" + + +def build_rule_filter_ablation_returns(rows: Sequence[Mapping[str, Any]], config: RuleFilterConfig, split_hash: str) -> dict[str, float]: + """Return OOS returns for all preregistered rule-filter ablations.""" + + return {feature_set: _ablation_return(rows, config, split_hash, feature_set) for feature_set in RULE_FILTER_ABLATION_IDS} + + +def _ablation_return(rows: Sequence[Mapping[str, Any]], config: RuleFilterConfig, split_hash: str, feature_set: str) -> float: + transformed = _apply_ablation(rows, feature_set) + policy = select_rule_filter_policy(transformed, config=config, split_hash=split_hash) + return float(policy["oos_metrics"]["net_return_pct"]) + + +def _apply_ablation(rows: Sequence[Mapping[str, Any]], feature_set: str) -> list[dict[str, Any]]: + match feature_set: + case "shuffled_participant_context": + participant_values = _rotate([_subset(dict(row.get("feature_values", {})), _is_participant_key) for row in rows]) + return _transform_rows(rows, lambda features, index: _replace_subset(features, participant_values[index])) + case "context_only": + return _transform_rows(rows, lambda features, _index: {key: value if _is_context_key(key) else 0.0 for key, value in features.items()}) + case "tick_only": + return _transform_rows(rows, lambda features, _index: {key: 0.0 if _is_context_key(key) else value for key, value in features.items()}) + case "no_participant_pressure": + return _zero_matching(rows, _is_participant_key) + case "no_orderbook_imbalance": + return _zero_matching(rows, _is_orderbook_imbalance_key) + case "no_orderbook_persistence": + return _zero_matching(rows, _is_orderbook_persistence_key) + case "no_overheat_upper_wick": + return _zero_matching(rows, _is_overheat_key) + case "no_time_bucket": + return _zero_matching(rows, lambda key: key.startswith("time_bucket")) + case _: + raise RuleFilterTransformError(feature_set) + + +def _zero_matching(rows: Sequence[Mapping[str, Any]], predicate: Callable[[str], bool]) -> list[dict[str, Any]]: + return _transform_rows(rows, lambda features, _index: {key: 0.0 if predicate(key) else value for key, value in features.items()}) + + +def _copy_row(row: Mapping[str, Any], features: Mapping[str, Any] | None = None) -> dict[str, Any]: + copied = dict(row) + copied["feature_values"] = dict(features if features is not None else row.get("feature_values", {})) + return copied + + +def _rotate(values: list[Any]) -> list[Any]: + return values[1:] + values[:1] if len(values) > 1 else values + + +def _transform_rows(rows: Sequence[Mapping[str, Any]], transform: Callable[[Mapping[str, Any], int], Mapping[str, Any]]) -> list[dict[str, Any]]: + return [_copy_row(row, features=transform(dict(row.get("feature_values", {})), index)) for index, row in enumerate(rows)] + + +def _subset(features: Mapping[str, Any], predicate: Callable[[str], bool]) -> dict[str, Any]: + return {key: value for key, value in features.items() if predicate(key)} + + +def _replace_subset(features: Mapping[str, Any], replacements: Mapping[str, Any]) -> dict[str, Any]: + copied = dict(features) + for key, value in replacements.items(): + if key in copied: + copied[key] = value + return copied + + +def _is_participant_key(key: str) -> bool: + return "participant" in key or key.startswith("proxy_") or "pressure" in key + + +def _is_orderbook_imbalance_key(key: str) -> bool: + return "imb" in key or "imbalance" in key + + +def _is_orderbook_persistence_key(key: str) -> bool: + return "orderbook_persistence" in key or key.startswith("ofi") or "micro" in key or "spread" in key + + +def _is_overheat_key(key: str) -> bool: + return "overheat" in key or "upper_wick" in key or "wick" in key + + +def _is_context_key(key: str) -> bool: + return _is_participant_key(key) or _is_orderbook_imbalance_key(key) or _is_orderbook_persistence_key(key) or _is_overheat_key(key) or key.startswith("time_bucket") diff --git a/stom_rl/orderbook_persistence.py b/stom_rl/orderbook_persistence.py new file mode 100644 index 000000000..a483f24fe --- /dev/null +++ b/stom_rl/orderbook_persistence.py @@ -0,0 +1,244 @@ +"""Causal orderbook persistence and overheat scoring for opening trades.""" + +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Final + +import pandas as pd # noqa: PANDAS_OK - STOM orderbook research frames are pandas-based + +from .participant_pressure_features import ( + COL_ASK_TOTAL, + COL_BID_TOTAL, + COL_BUY_AMOUNT, + COL_SELL_AMOUNT, + COL_TRADE_STRENGTH, +) + + +COL_PRICE: Final[str] = "현재가" +COL_BID1: Final[str] = "매수호가1" +COL_ASK1: Final[str] = "매도호가1" +COL_BID_QTY1: Final[str] = "매수잔량1" +COL_ASK_QTY1: Final[str] = "매도잔량1" +SUMMARY_JSON: Final[str] = "orderbook_persistence_score_summary.json" +REQUIRED_SCORE_COMPONENTS: Final[tuple[str, ...]] = ( + "bid_ask_depth_imbalance", + "bid_depth_persistence", + "trade_strength_persistence", + "signed_flow_persistence", + "ofi_pressure", + "microprice_pressure", + "spread_penalty", + "pullback_reacceleration", + "overheat_penalty", + "upper_wick_ratio", +) +FEATURE_GROUPS: Final[dict[str, list[str]]] = { + "orderbook_imbalance": ["bid_ask_depth_imbalance", "microprice_pressure", "spread_penalty"], + "orderbook_persistence": ["bid_depth_persistence", "ofi_pressure", "signed_flow_persistence", "trade_strength_persistence"], + "overheat_upper_wick": ["overheat_penalty", "upper_wick_ratio", "pullback_reacceleration"], +} + + +@dataclass(frozen=True, slots=True) +class OrderbookPersistenceError(ValueError): + """Raised when score input rows violate the causal orderbook contract.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +def _causal_rows(frame: pd.DataFrame, decision_second: int) -> pd.DataFrame: + if frame.empty: + raise OrderbookPersistenceError("orderbook persistence score requires non-empty frame") + if decision_second < 0 or decision_second >= len(frame): + raise OrderbookPersistenceError("decision_second out of range") + return frame.iloc[: decision_second + 1] + + +def _clip01(value: float) -> float: + return max(0.0, min(1.0, value)) + + +def _required_columns(frame: pd.DataFrame) -> None: + required = ( + COL_PRICE, + COL_TRADE_STRENGTH, + COL_BUY_AMOUNT, + COL_SELL_AMOUNT, + COL_BID_TOTAL, + COL_ASK_TOTAL, + COL_BID1, + COL_ASK1, + COL_BID_QTY1, + COL_ASK_QTY1, + ) + missing = [column for column in required if column not in frame.columns] + if missing: + raise OrderbookPersistenceError(f"missing orderbook persistence columns: {missing}") + + +def _bid_depth(rows: pd.DataFrame) -> float: + bid = rows[COL_BID_TOTAL].astype(float) + ask = rows[COL_ASK_TOTAL].astype(float) + ratio = bid / (bid + ask).replace(0.0, pd.NA) + return _clip01(float(ratio.fillna(0.0).mean())) + + +def _bid_ask_depth_imbalance(rows: pd.DataFrame) -> float: + latest = rows.iloc[-1] + bid_total = float(latest[COL_BID_TOTAL]) + ask_total = float(latest[COL_ASK_TOTAL]) + denominator = bid_total + ask_total + return _clip01(bid_total / denominator) if denominator > 0.0 else 0.0 + + +def _trade_strength(rows: pd.DataFrame) -> float: + return _clip01(float(rows[COL_TRADE_STRENGTH].astype(float).mean()) / 150.0) + + +def _signed_flow(rows: pd.DataFrame) -> float: + buy_sum = float(rows[COL_BUY_AMOUNT].astype(float).sum()) + sell_sum = float(rows[COL_SELL_AMOUNT].astype(float).sum()) + denominator = buy_sum + sell_sum + signed = (buy_sum - sell_sum) / denominator if denominator > 0.0 else 0.0 + return _clip01((signed + 1.0) / 2.0) + + +def _ofi_pressure(rows: pd.DataFrame) -> float: + bid_change = float(rows[COL_BID_TOTAL].iloc[-1]) - float(rows[COL_BID_TOTAL].iloc[0]) + ask_change = float(rows[COL_ASK_TOTAL].iloc[-1]) - float(rows[COL_ASK_TOTAL].iloc[0]) + raw = bid_change - ask_change + denominator = abs(bid_change) + abs(ask_change) + normalized = raw / denominator if denominator > 0.0 else 0.0 + return _clip01((normalized + 1.0) / 2.0) + + +def _microprice_pressure(rows: pd.DataFrame) -> float: + latest = rows.iloc[-1] + bid = float(latest[COL_BID1]) + ask = float(latest[COL_ASK1]) + bid_qty = float(latest[COL_BID_QTY1]) + ask_qty = float(latest[COL_ASK_QTY1]) + denominator = bid_qty + ask_qty + if denominator <= 0.0 or bid <= 0.0 or ask <= 0.0: + return 0.5 + microprice = (ask * bid_qty + bid * ask_qty) / denominator + mid = (bid + ask) / 2.0 + return _clip01(0.5 + ((microprice - mid) / mid) * 50.0) if mid > 0.0 else 0.5 + + +def _spread_penalty(rows: pd.DataFrame) -> float: + bid = rows[COL_BID1].astype(float) + ask = rows[COL_ASK1].astype(float) + price = rows[COL_PRICE].astype(float) + spread = ((ask - bid).clip(lower=0.0) / price.replace(0.0, pd.NA)).fillna(0.0) + return _clip01(float(spread.mean()) * 100.0) + + +def _pullback_reacceleration(rows: pd.DataFrame) -> float: + prices = rows[COL_PRICE].astype(float) + if len(prices) < 3: + return 0.0 + high = float(prices.max()) + low_after_high = float(prices.iloc[prices.argmax() :].min()) + latest = float(prices.iloc[-1]) + pullback = high - low_after_high + recovery = latest - low_after_high + return _clip01(recovery / pullback) if pullback > 0.0 else 0.0 + + +def _overheat_penalty(rows: pd.DataFrame) -> float: + ratio = _upper_wick_ratio(rows) + if ratio <= 2.0: + return 0.0 + return _clip01(ratio / 5.0) + + +def _upper_wick_ratio(rows: pd.DataFrame) -> float: + prices = rows[COL_PRICE].astype(float) + opening = float(prices.iloc[0]) + close = float(prices.iloc[-1]) + high = float(prices.max()) + body = abs(close - opening) + upper = max(0.0, high - max(opening, close)) + return upper / max(body, 1e-9) + + +def _components(rows: pd.DataFrame) -> dict[str, float]: + return { + "bid_ask_depth_imbalance": _bid_ask_depth_imbalance(rows), + "bid_depth_persistence": _bid_depth(rows), + "trade_strength_persistence": _trade_strength(rows), + "signed_flow_persistence": _signed_flow(rows), + "ofi_pressure": _ofi_pressure(rows), + "microprice_pressure": _microprice_pressure(rows), + "spread_penalty": _spread_penalty(rows), + "pullback_reacceleration": _pullback_reacceleration(rows), + "overheat_penalty": _overheat_penalty(rows), + "upper_wick_ratio": _upper_wick_ratio(rows), + } + + +def _score(components: dict[str, float]) -> float: + positive = ( + components["bid_depth_persistence"] + + components["trade_strength_persistence"] + + components["signed_flow_persistence"] + + components["bid_ask_depth_imbalance"] + + components["ofi_pressure"] + + components["microprice_pressure"] + + components["pullback_reacceleration"] + ) / 7.0 + penalty = (components["spread_penalty"] + components["overheat_penalty"]) / 2.0 + return _clip01(positive - penalty * 0.5) + + +def build_orderbook_persistence_score( + frame: pd.DataFrame, + *, + decision_second: int, +) -> dict[str, Any]: + """Compute a causal componentized orderbook persistence score.""" + + _required_columns(frame) + rows = _causal_rows(frame, decision_second) + components = _components(rows) + return { + "artifact_type": "orderbook_persistence_score", + "mode": "opening_orderbook_persistence", + "decision_second": int(decision_second), + "rows_used": int(len(rows)), + "score": _score(components), + "components": components, + "feature_groups": FEATURE_GROUPS, + "artifact_fields": ["component_values", "feature_groups", "score", "decision_second", "rows_used"], + "strategy_context": { + "line": "orderbook_proxy_evidence", + "label": "ORDERBOOK PERSISTENCE", + "is_reinforcement_learning": False, + "is_live_ready": False, + "is_profit_model": False, + }, + } + + +def write_orderbook_persistence_artifact( + frame: pd.DataFrame, + *, + output_dir: Path, + decision_second: int, +) -> dict[str, Any]: + """Write a componentized orderbook persistence score artifact.""" + + payload = build_orderbook_persistence_score(frame, decision_second=decision_second) + output_dir.mkdir(parents=True, exist_ok=True) + summary_path = output_dir / SUMMARY_JSON + payload["artifacts"] = {"summary_json": str(summary_path)} + summary_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + return payload diff --git a/stom_rl/orderbook_rl_env.py b/stom_rl/orderbook_rl_env.py new file mode 100644 index 000000000..add1c32f4 --- /dev/null +++ b/stom_rl/orderbook_rl_env.py @@ -0,0 +1,774 @@ +"""Marketable-only tick + orderbook RL environment for STOM opening trades. + +This module is intentionally narrower than a full limit-order-book simulator. +It supports only actions whose fills can be audited from the current 1-second +snapshot: + +* ``0`` hold +* ``1`` market_buy (buy crosses ``ask1``) +* ``2`` market_exit (sell hits ``bid1``) + +That conservative contract is deliberate. Without message-level queue +position, limit-order placement/cancel rewards would be mostly fabricated; this +environment instead makes the first reinforcement-learning step honest, +causal, and cost-aware. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import sqlite3 +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, ClassVar, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .marketable_fill import marketable_entry_price, marketable_exit_price +from .microstructure_features import causal_feature_vector +from .trading_env import BoxSpace, DiscreteSpace + + +ACTION_HOLD = 0 +ACTION_MARKET_BUY = 1 +ACTION_MARKET_EXIT = 2 +ACTION_NAMES = { + ACTION_HOLD: "hold", + ACTION_MARKET_BUY: "market_buy", + ACTION_MARKET_EXIT: "market_exit", +} + +ORDERBOOK_FEATURE_NAMES: Tuple[str, ...] = ( + "ret_open", + "ret_5", + "ret_15", + "ret_30", + "vol_5", + "vol_15", + "vol_30", + "spread_rel", + "micro_dev", + "book_imb_tot", + "book_imb_l1", + "ofi_5", + "ofi_15", + "ofi_30", + "sflow_ratio_5", + "sflow_ratio_15", + "sflow_ratio_30", + "ts_level", + "ts_slope_30", + "position", + "unrealized_net_pct", + "time_frac", +) + +KOREAN_TO_CANONICAL = { + "index": "timestamp_key", + "현재가": "price", + "등락율": "cr", + "체결강도": "ts", + "초당매수금액": "buy_val", + "초당매도금액": "sell_val", + "초당매수수량": "buy_qty", + "초당매도수량": "sell_qty", + "매수총잔량": "bid_tot", + "매도총잔량": "ask_tot", + "매수호가1": "bid1", + "매도호가1": "ask1", + "매수잔량1": "bidq1", + "매도잔량1": "askq1", +} + +CANONICAL_COLUMNS: Tuple[str, ...] = ( + "sec", + "price", + "ts", + "buy_val", + "sell_val", + "buy_qty", + "sell_qty", + "bid_tot", + "ask_tot", + "bid1", + "ask1", + "bidq1", + "askq1", +) + +REQUIRED_ORDERBOOK_COLUMNS: Tuple[str, ...] = ( + "price", + "bid_tot", + "ask_tot", + "bid1", + "ask1", + "bidq1", + "askq1", +) + +DB_REQUIRED_COLUMNS: Tuple[str, ...] = ( + "index", + "현재가", + "체결강도", + "초당매수금액", + "초당매도금액", + "초당매수수량", + "초당매도수량", + "매수총잔량", + "매도총잔량", + "매수호가1", + "매도호가1", + "매수잔량1", + "매도잔량1", +) + +DEFAULT_READINESS_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_orderbook_rl_readiness" +DEFAULT_OMX_READINESS_DIR = Path(".omx") / "artifacts" / "orderbook_rl_readiness" + + +@dataclass(frozen=True) +class StomOrderbookRlEnvConfig: + """Configuration for ``StomOrderbookRlEnv``.""" + + csv_path: Optional[str] = None + lookback_window: int = 30 + cost_bps: float = 23.0 + slippage_bps: float = 0.0 + invalid_action_penalty: float = 0.001 + overtrade_penalty: float = 0.0 + force_close_on_done: bool = True + max_episode_steps: int = 0 + normalize_observation: bool = False + seed: int = 100 + + +@dataclass(frozen=True) +class OrderbookRlReadinessConfig: + """Configuration for DB/data readiness assessment.""" + + db_path: str = str(Path("_database") / "stock_tick_back.db") + output_dir: str = str(DEFAULT_READINESS_OUTPUT_DIR) + omx_output_dir: str = str(DEFAULT_OMX_READINESS_DIR) + max_symbols: int = 200 + min_rows_per_episode: int = 35 + lookback_window: int = 30 + min_eligible_episodes: int = 1000 + min_quote_coverage: float = 0.95 + min_valid_spread_coverage: float = 0.90 + write_artifacts: bool = True + + +def _float_or_zero(value: Any) -> float: + try: + if pd.isna(value): + return 0.0 + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def seconds_since_open(value: Any) -> int: + """Return seconds since 09:00 from STOM ``index`` or timestamp-like values.""" + + if value is None: + return 0 + try: + if not isinstance(value, str) and np.isfinite(float(value)): + text = str(int(float(value))) + else: + text = str(value).strip() + except (TypeError, ValueError): + text = str(value).strip() + + digits = "".join(ch for ch in text if ch.isdigit()) + if len(digits) >= 14: + hhmmss = digits[-6:] + elif len(digits) >= 6: + hhmmss = digits[-6:] + else: + parsed = pd.to_datetime(text, errors="coerce") + if pd.isna(parsed): + return 0 + hhmmss = f"{parsed.hour:02d}{parsed.minute:02d}{parsed.second:02d}" + hh = int(hhmmss[:2]) + mm = int(hhmmss[2:4]) + ss = int(hhmmss[4:6]) + return hh * 3600 + mm * 60 + ss - 9 * 3600 + + +def _timestamp_iso(value: Any) -> Optional[str]: + try: + if not isinstance(value, str) and np.isfinite(float(value)): + digits = str(int(float(value))) + else: + digits = "".join(ch for ch in str(value) if ch.isdigit()) + except (TypeError, ValueError): + digits = "" + if len(digits) >= 14: + return ( + f"{digits[:4]}-{digits[4:6]}-{digits[6:8]}" + f"T{digits[8:10]}:{digits[10:12]}:{digits[12:14]}" + ) + parsed = pd.to_datetime(value, errors="coerce") + if pd.isna(parsed): + return None + return parsed.isoformat() + + +def normalize_orderbook_frame(frame: pd.DataFrame) -> pd.DataFrame: + """Normalize Korean STOM or canonical orderbook columns to dense floats.""" + + if frame.empty: + raise ValueError("orderbook frame is empty") + + normalized = frame.copy() + rename_map = {src: dst for src, dst in KOREAN_TO_CANONICAL.items() if src in normalized.columns} + normalized = normalized.rename(columns=rename_map) + + if "sec" not in normalized.columns: + if "timestamp_key" in normalized.columns: + normalized["sec"] = normalized["timestamp_key"].map(seconds_since_open) + elif "timestamp" in normalized.columns: + normalized["sec"] = normalized["timestamp"].map(seconds_since_open) + else: + normalized["sec"] = np.arange(len(normalized), dtype=float) + + if "timestamp" not in normalized.columns and "timestamp_key" in normalized.columns: + normalized["timestamp"] = normalized["timestamp_key"].map(_timestamp_iso) + + missing = [col for col in REQUIRED_ORDERBOOK_COLUMNS if col not in normalized.columns] + if missing: + raise ValueError(f"orderbook frame missing required columns: {missing}") + + for column in CANONICAL_COLUMNS: + if column not in normalized.columns: + normalized[column] = 0.0 + normalized[column] = pd.to_numeric(normalized[column], errors="coerce") + + canonical_columns = list(CANONICAL_COLUMNS) + normalized[canonical_columns] = normalized[canonical_columns].ffill().bfill().fillna(0.0) + normalized = normalized[normalized["price"] > 0].copy() + normalized = normalized.sort_values("sec").reset_index(drop=True) + if normalized.empty: + raise ValueError("orderbook frame has no positive price rows") + return normalized + + +class StomOrderbookRlEnv: + """Dependency-light single-symbol marketable-only orderbook RL environment.""" + + metadata: ClassVar[Dict[str, Any]] = {"render_modes": []} + + def __init__( + self, + config: Optional[StomOrderbookRlEnvConfig] = None, + *, + frame: Optional[pd.DataFrame] = None, + **overrides: Any, + ) -> None: + if config is not None and overrides: + raise ValueError("Pass either config or keyword overrides, not both.") + self.config = config or StomOrderbookRlEnvConfig(**overrides) + if self.config.lookback_window <= 0: + raise ValueError("lookback_window must be positive") + if self.config.cost_bps < 0 or self.config.slippage_bps < 0: + raise ValueError("cost_bps and slippage_bps must be non-negative") + if frame is None: + if not self.config.csv_path: + raise ValueError("Pass frame=... or config.csv_path") + frame = pd.read_csv(self.config.csv_path) + self.frame = normalize_orderbook_frame(frame) + if len(self.frame) < self.config.lookback_window + 1: + raise ValueError( + f"orderbook episode has {len(self.frame)} rows, " + f"but at least {self.config.lookback_window + 1} are required" + ) + + self.feature_columns = list(ORDERBOOK_FEATURE_NAMES) + self.action_space = DiscreteSpace(3) + self.observation_space = BoxSpace((len(self.feature_columns),), dtype=np.float32) + self._rng = np.random.default_rng(self.config.seed) + + self.current_idx = 0 + self.max_idx = len(self.frame) - 1 + self.start_idx = self.config.lookback_window - 1 + if self.config.max_episode_steps > 0: + self.max_idx = min(self.max_idx, self.start_idx + int(self.config.max_episode_steps)) + self.position = 0 + self.entry_fill = 0.0 + self.entry_idx: Optional[int] = None + self.realized_net_return = 0.0 + self.trade_count = 0 + self.invalid_action_count = 0 + self.cumulative_reward = 0.0 + self.last_info: Dict[str, Any] = {} + + @property + def cost_fraction(self) -> float: + return float(self.config.cost_bps) / 10_000.0 + + def reset( + self, + *, + seed: Optional[int] = None, + options: Optional[Mapping[str, Any]] = None, + ) -> Tuple[np.ndarray, Dict[str, Any]]: + if seed is not None: + self._rng = np.random.default_rng(seed) + del options + self.current_idx = self.start_idx + self.position = 0 + self.entry_fill = 0.0 + self.entry_idx = None + self.realized_net_return = 0.0 + self.trade_count = 0 + self.invalid_action_count = 0 + self.cumulative_reward = 0.0 + info = self._info(event="reset") + self.last_info = info + return self._observation(), info + + def step(self, action: int) -> Tuple[np.ndarray, float, bool, bool, Dict[str, Any]]: + if not self.action_space.contains(action): + raise ValueError(f"Invalid action {action!r}; expected 0, 1, or 2.") + if self.current_idx >= self.max_idx: + raise RuntimeError("Episode is already terminated; call reset().") + + action = int(action) + nav_before = self._equity_at(self.current_idx) + position_before = self.position + invalid_action = False + fill_price: Optional[float] = None + realized_trade_return = 0.0 + + if action == ACTION_MARKET_BUY: + if self.position: + invalid_action = True + else: + row = self._row(self.current_idx) + fill_price = marketable_entry_price( + row.get("bid1"), + row.get("ask1"), + row["price"], + slippage_bps=float(self.config.slippage_bps), + ) + self.position = 1 + self.entry_fill = fill_price + self.entry_idx = self.current_idx + self.trade_count += 1 + elif action == ACTION_MARKET_EXIT: + if not self.position: + invalid_action = True + else: + fill_price, realized_trade_return = self._exit_position(self.current_idx) + + if invalid_action: + self.invalid_action_count += 1 + + self.current_idx += 1 + terminated = self.current_idx >= self.max_idx + truncated = False + force_closed = False + if terminated and self.position and self.config.force_close_on_done: + fill_price, realized_trade_return = self._exit_position(self.current_idx) + force_closed = True + + nav_after = self._equity_at(self.current_idx) + reward = nav_after - nav_before + if invalid_action: + reward -= float(self.config.invalid_action_penalty) + if not invalid_action and action in {ACTION_MARKET_BUY, ACTION_MARKET_EXIT}: + reward -= float(self.config.overtrade_penalty) + + self.cumulative_reward += float(reward) + info = self._info(event="step") + info.update( + { + "action": action, + "action_name": ACTION_NAMES[action], + "position_before": position_before, + "position_after": self.position, + "invalid_action": invalid_action, + "invalid_action_count": self.invalid_action_count, + "fill_price": fill_price, + "realized_trade_return": realized_trade_return, + "realized_net_return": self.realized_net_return, + "reward": float(reward), + "cumulative_reward": self.cumulative_reward, + "trade_count": self.trade_count, + "force_closed": force_closed, + "terminated": terminated, + "truncated": truncated, + } + ) + self.last_info = info + return self._observation(), float(reward), terminated, truncated, info + + def _exit_position(self, idx: int) -> Tuple[float, float]: + row = self._row(idx) + fill_price = marketable_exit_price( + row.get("bid1"), + row.get("ask1"), + row["price"], + slippage_bps=float(self.config.slippage_bps), + ) + realized = (fill_price / self.entry_fill - 1.0) - self.cost_fraction + self.realized_net_return += float(realized) + self.position = 0 + self.entry_fill = 0.0 + self.entry_idx = None + self.trade_count += 1 + return fill_price, float(realized) + + def _row(self, idx: int) -> Dict[str, Any]: + return dict(self.frame.iloc[int(idx)]) + + def _window_rows(self) -> List[Dict[str, Any]]: + start = max(0, self.current_idx - self.config.lookback_window + 1) + rows = [] + for row in self.frame.iloc[start : self.current_idx + 1].to_dict("records"): + rows.append({key: row.get(key) for key in CANONICAL_COLUMNS}) + return rows + + def _equity_at(self, idx: int) -> float: + return 1.0 + self.realized_net_return + self._unrealized_net_return(idx) + + def _unrealized_net_return(self, idx: Optional[int] = None) -> float: + if not self.position or not self.entry_fill: + return 0.0 + row = self._row(self.current_idx if idx is None else idx) + exit_fill = marketable_exit_price( + row.get("bid1"), + row.get("ask1"), + row["price"], + slippage_bps=float(self.config.slippage_bps), + ) + return float((exit_fill / self.entry_fill - 1.0) - self.cost_fraction) + + def _observation(self) -> np.ndarray: + features = causal_feature_vector(self._window_rows()) + features["position"] = float(self.position) + features["unrealized_net_pct"] = self._unrealized_net_return() * 100.0 + features["time_frac"] = float(features.get("t_frac", 0.0)) + values = np.array([float(features.get(name, 0.0)) for name in self.feature_columns], dtype=np.float32) + if self.config.normalize_observation: + values = np.nan_to_num(values, nan=0.0, posinf=0.0, neginf=0.0) + return values + + def _timestamp(self) -> Optional[str]: + row = self._row(self.current_idx) + value = row.get("timestamp") + return str(value) if value else None + + def _info(self, *, event: str) -> Dict[str, Any]: + return { + "event": event, + "current_idx": self.current_idx, + "timestamp": self._timestamp(), + "sec": int(_float_or_zero(self._row(self.current_idx).get("sec"))), + "equity": self._equity_at(self.current_idx), + "position": self.position, + "feature_columns": list(self.feature_columns), + "action_space": dict(ACTION_NAMES), + "no_future_observation": True, + "config": asdict(self.config), + } + + +def _connect_readonly(db_path: str | Path) -> sqlite3.Connection: + path = Path(db_path).resolve() + if not path.is_file(): + raise FileNotFoundError(f"SQLite DB not found: {path}") + conn = sqlite3.connect(f"file:{path.as_posix()}?mode=ro", uri=True) + conn.execute("PRAGMA query_only=ON") + return conn + + +def _quote_ident(name: str) -> str: + return '"' + str(name).replace('"', '""') + '"' + + +def _list_stock_tables(conn: sqlite3.Connection, *, max_symbols: int) -> List[str]: + names = [ + str(row[0]) + for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall() + if str(row[0]).isdigit() + ] + if max_symbols and max_symbols > 0: + return names[: int(max_symbols)] + return names + + +def _table_columns(conn: sqlite3.Connection, table: str) -> List[str]: + return [str(row[1]) for row in conn.execute(f"PRAGMA table_info({_quote_ident(table)})").fetchall()] + + +def _coverage_rows_for_table(conn: sqlite3.Connection, table: str) -> List[Tuple[Any, ...]]: + qt = _quote_ident(table) + q = f""" + SELECT + substr(CAST("index" AS TEXT), 1, 8) AS session, + COUNT(*) AS total_rows, + SUM(CASE WHEN "현재가" > 0 THEN 1 ELSE 0 END) AS price_rows, + SUM(CASE WHEN "매수호가1" > 0 AND "매도호가1" > 0 + AND "매수잔량1" >= 0 AND "매도잔량1" >= 0 + THEN 1 ELSE 0 END) AS quote_rows, + SUM(CASE WHEN "매수호가1" > 0 AND "매도호가1" > "매수호가1" + THEN 1 ELSE 0 END) AS valid_spread_rows + FROM {qt} + WHERE substr(CAST("index" AS TEXT), 9, 6) >= '090000' + AND substr(CAST("index" AS TEXT), 9, 6) <= '092000' + GROUP BY session + ORDER BY session + """ + return conn.execute(q).fetchall() + + +def _sample_episode_frame( + conn: sqlite3.Connection, + table: str, + session: str, + *, + limit: int, +) -> pd.DataFrame: + qt = _quote_ident(table) + cols = ", ".join(_quote_ident(col) for col in DB_REQUIRED_COLUMNS) + q = f""" + SELECT {cols} + FROM {qt} + WHERE substr(CAST("index" AS TEXT), 1, 8) = ? + AND substr(CAST("index" AS TEXT), 9, 6) >= '090000' + AND substr(CAST("index" AS TEXT), 9, 6) <= '092000' + ORDER BY "index" + LIMIT ? + """ + return pd.read_sql_query(q, conn, params=(str(session), int(limit))) + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(fieldnames), extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def assess_orderbook_rl_readiness(config: Optional[OrderbookRlReadinessConfig] = None) -> Dict[str, Any]: + """Assess whether local STOM data is ready for marketable-only orderbook RL.""" + + config = config or OrderbookRlReadinessConfig() + db_path = Path(config.db_path) + scanned_tables: List[str] = [] + missing_column_tables: List[Dict[str, Any]] = [] + coverage_rows: List[Dict[str, Any]] = [] + sample_env_smoke: Dict[str, Any] = {"passed": False, "message": "no eligible sample episode"} + + conn = _connect_readonly(db_path) + try: + all_tables = _list_stock_tables(conn, max_symbols=0) + tables = all_tables[: int(config.max_symbols)] if config.max_symbols and config.max_symbols > 0 else all_tables + for table in tables: + columns = set(_table_columns(conn, table)) + missing = [col for col in DB_REQUIRED_COLUMNS if col not in columns] + if missing: + missing_column_tables.append({"table": table, "missing": missing}) + continue + scanned_tables.append(table) + for session, total_rows, price_rows, quote_rows, spread_rows in _coverage_rows_for_table(conn, table): + total = int(total_rows or 0) + row = { + "table": table, + "session": str(session), + "total_rows": total, + "price_rows": int(price_rows or 0), + "quote_rows": int(quote_rows or 0), + "valid_spread_rows": int(spread_rows or 0), + "eligible": total >= int(config.min_rows_per_episode), + } + coverage_rows.append(row) + if not sample_env_smoke["passed"] and row["eligible"]: + try: + sample = _sample_episode_frame( + conn, + table, + str(session), + limit=max(int(config.min_rows_per_episode), int(config.lookback_window) + 3), + ) + env = StomOrderbookRlEnv( + StomOrderbookRlEnvConfig( + lookback_window=int(config.lookback_window), + cost_bps=23.0, + slippage_bps=0.0, + max_episode_steps=3, + ), + frame=sample, + ) + obs, info = env.reset(seed=100) + _, buy_reward, _, _, buy_info = env.step(ACTION_MARKET_BUY) + _, hold_reward, _, _, hold_info = env.step(ACTION_HOLD) + _, exit_reward, _, _, exit_info = env.step(ACTION_MARKET_EXIT) + sample_env_smoke = { + "passed": bool(env.observation_space.contains(obs)), + "table": table, + "session": str(session), + "feature_count": len(info["feature_columns"]), + "action_space": dict(ACTION_NAMES), + "buy_reward": buy_reward, + "hold_reward": hold_reward, + "exit_reward": exit_reward, + "position_after_exit": exit_info["position_after"], + "trade_count": exit_info["trade_count"], + "no_future_observation": bool( + info["no_future_observation"] + and buy_info["no_future_observation"] + and hold_info["no_future_observation"] + and exit_info["no_future_observation"] + ), + } + except Exception as exc: # pragma: no cover - defensive path + sample_env_smoke = { + "passed": False, + "table": table, + "session": str(session), + "message": str(exc), + } + finally: + conn.close() + + total_rows = sum(int(row["total_rows"]) for row in coverage_rows) + quote_rows = sum(int(row["quote_rows"]) for row in coverage_rows) + valid_spread_rows = sum(int(row["valid_spread_rows"]) for row in coverage_rows) + eligible_episodes = sum(1 for row in coverage_rows if row["eligible"]) + quote_coverage = quote_rows / total_rows if total_rows else 0.0 + valid_spread_coverage = valid_spread_rows / total_rows if total_rows else 0.0 + sample_limited = bool(config.max_symbols and config.max_symbols > 0 and len(scanned_tables) < len(all_tables)) + + column_ok = bool(scanned_tables) and not missing_column_tables + coverage_ok = quote_coverage >= float(config.min_quote_coverage) + spread_ok = valid_spread_coverage >= float(config.min_valid_spread_coverage) + episode_ok = eligible_episodes >= int(config.min_eligible_episodes) + smoke_ok = bool(sample_env_smoke.get("passed")) and bool(sample_env_smoke.get("no_future_observation")) + + if not column_ok or not coverage_rows: + readiness_status = "NO-GO_DATA" + elif coverage_ok and spread_ok and smoke_ok and episode_ok and not sample_limited: + readiness_status = "READY_FOR_MARKETABLE_RL" + elif coverage_ok and spread_ok and smoke_ok: + readiness_status = "INCONCLUSIVE" + else: + readiness_status = "NO-GO_DATA" + + summary = { + "readiness_status": readiness_status, + "verdict": readiness_status, + "is_live_ready": False, + "is_profit_model": False, + "environment": "StomOrderbookRlEnv", + "action_space": dict(ACTION_NAMES), + "feature_count": len(ORDERBOOK_FEATURE_NAMES), + "scanned_table_count": len(scanned_tables), + "total_table_count": len(all_tables), + "sample_limited": sample_limited, + "coverage_row_count": total_rows, + "eligible_episode_count": eligible_episodes, + "quote_coverage": quote_coverage, + "valid_spread_coverage": valid_spread_coverage, + "min_eligible_episodes": int(config.min_eligible_episodes), + "min_quote_coverage": float(config.min_quote_coverage), + "min_valid_spread_coverage": float(config.min_valid_spread_coverage), + "sample_env_smoke_passed": smoke_ok, + "safety_note": ( + "Marketable-only RL readiness artifact. This is not a live-trading " + "approval and not a profitability claim." + ), + } + payload: Dict[str, Any] = { + "mode": "stom_orderbook_rl_readiness", + "artifact_type": "orderbook_rl_readiness", + "generated_at": datetime.now(timezone.utc).isoformat(), + "summary": summary, + "config": asdict(config), + "required_db_columns": list(DB_REQUIRED_COLUMNS), + "observation_features": list(ORDERBOOK_FEATURE_NAMES), + "sample_env_smoke": sample_env_smoke, + "coverage_sample": coverage_rows[:200], + "missing_column_tables": missing_column_tables[:50], + "artifacts": { + "output_dir": str(Path(config.output_dir)), + "summary_json": str(Path(config.output_dir) / "orderbook_rl_readiness_summary.json"), + "summary_csv": str(Path(config.output_dir) / "orderbook_rl_readiness.csv"), + "omx_summary_json": str(Path(config.omx_output_dir) / "summary.json"), + }, + } + + if config.write_artifacts: + output_dir = Path(config.output_dir) + _write_json(output_dir / "orderbook_rl_readiness_summary.json", payload) + _write_csv( + output_dir / "orderbook_rl_readiness.csv", + [summary], + [ + "readiness_status", + "verdict", + "scanned_table_count", + "total_table_count", + "sample_limited", + "coverage_row_count", + "eligible_episode_count", + "quote_coverage", + "valid_spread_coverage", + "sample_env_smoke_passed", + ], + ) + _write_json(Path(config.omx_output_dir) / "summary.json", payload) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> OrderbookRlReadinessConfig: + parser = argparse.ArgumentParser( + description="Assess STOM tick+orderbook readiness for marketable-only RL." + ) + parser.add_argument("--db-path", "--db", default=str(Path("_database") / "stock_tick_back.db")) + parser.add_argument("--output-dir", default=str(DEFAULT_READINESS_OUTPUT_DIR)) + parser.add_argument("--omx-output-dir", default=str(DEFAULT_OMX_READINESS_DIR)) + parser.add_argument("--max-symbols", type=int, default=200) + parser.add_argument("--min-rows-per-episode", type=int, default=35) + parser.add_argument("--lookback-window", type=int, default=30) + parser.add_argument("--min-eligible-episodes", type=int, default=1000) + parser.add_argument("--min-quote-coverage", type=float, default=0.95) + parser.add_argument("--min-valid-spread-coverage", type=float, default=0.90) + parser.add_argument("--no-write", action="store_true") + args = parser.parse_args(argv) + return OrderbookRlReadinessConfig( + db_path=args.db_path, + output_dir=args.output_dir, + omx_output_dir=args.omx_output_dir, + max_symbols=args.max_symbols, + min_rows_per_episode=args.min_rows_per_episode, + lookback_window=args.lookback_window, + min_eligible_episodes=args.min_eligible_episodes, + min_quote_coverage=args.min_quote_coverage, + min_valid_spread_coverage=args.min_valid_spread_coverage, + write_artifacts=not args.no_write, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = assess_orderbook_rl_readiness(_parse_args(argv)) + print(json.dumps(payload["summary"], ensure_ascii=False, indent=2)) + if payload.get("artifacts"): + print(f"wrote -> {payload['artifacts']['summary_json']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/orderbook_sb3_adapter.py b/stom_rl/orderbook_sb3_adapter.py new file mode 100644 index 000000000..7b7a7953f --- /dev/null +++ b/stom_rl/orderbook_sb3_adapter.py @@ -0,0 +1,314 @@ +"""Gymnasium adapter for the marketable-only STOM orderbook RL environment.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Mapping, Optional, Sequence, Tuple + +import gymnasium as gym +import numpy as np +import pandas as pd +from gymnasium import spaces + +from .orderbook_rl_env import ( + ACTION_HOLD, + ACTION_MARKET_BUY, + ACTION_MARKET_EXIT, + ACTION_NAMES, + StomOrderbookRlEnv, + StomOrderbookRlEnvConfig, +) + + +@dataclass(frozen=True) +class OrderbookEpisode: + """One pre-extracted tick+orderbook episode.""" + + episode_id: str + symbol: str + session: str + frame: pd.DataFrame + + +class StomOrderbookGymEnv(gym.Env): + """Gymnasium wrapper over one or more ``StomOrderbookRlEnv`` episodes.""" + + metadata = {"render_modes": []} + + def __init__( + self, + episodes: Sequence[OrderbookEpisode], + config: Optional[StomOrderbookRlEnvConfig] = None, + *, + constrain_invalid_actions: bool = False, + single_entry_exit: bool = False, + fixed_entry_exit_only: bool = False, + **overrides: Any, + ) -> None: + if config is not None and overrides: + raise ValueError("Pass either config or keyword overrides, not both.") + if not episodes: + raise ValueError("episodes must not be empty") + self.episodes = list(episodes) + self.config = config or StomOrderbookRlEnvConfig(**overrides) + self.constrain_invalid_actions = bool(constrain_invalid_actions) + self.single_entry_exit = bool(single_entry_exit) + self.fixed_entry_exit_only = bool(fixed_entry_exit_only) + self._rng = np.random.default_rng(self.config.seed) + self._next_episode = 0 + self._single_entry_entered = False + self._pending_fixed_entry_reward = 0.0 + self._pending_fixed_entry_info: dict[str, Any] = {} + self.current_episode: OrderbookEpisode = self.episodes[0] + self.raw_env = self._make_raw_env(self.current_episode) + self.action_space = spaces.Discrete(2 if self.fixed_entry_exit_only else self.raw_env.action_space.n) + self.observation_space = spaces.Box( + low=-np.inf, + high=np.inf, + shape=self.raw_env.observation_space.shape, + dtype=np.float32, + ) + + def _make_raw_env(self, episode: OrderbookEpisode) -> StomOrderbookRlEnv: + return StomOrderbookRlEnv(self.config, frame=episode.frame) + + def _select_episode(self, options: Optional[Mapping[str, Any]]) -> OrderbookEpisode: + options = dict(options or {}) + if "episode_index" in options: + return self.episodes[int(options["episode_index"]) % len(self.episodes)] + if "episode_id" in options: + episode_id = str(options["episode_id"]) + for episode in self.episodes: + if episode.episode_id == episode_id: + return episode + raise ValueError(f"Unknown orderbook episode_id: {episode_id}") + if len(self.episodes) == 1: + return self.episodes[0] + # Cycle deterministically instead of pure random so tiny smoke runs see + # more than one episode while still being reproducible. + episode = self.episodes[self._next_episode % len(self.episodes)] + self._next_episode += 1 + return episode + + def reset( + self, + *, + seed: Optional[int] = None, + options: Optional[Mapping[str, Any]] = None, + ) -> Tuple[np.ndarray, dict[str, Any]]: + super().reset(seed=seed) + if seed is not None: + self._rng = np.random.default_rng(seed) + self.current_episode = self._select_episode(options) + self.raw_env = self._make_raw_env(self.current_episode) + self._single_entry_entered = False + self._pending_fixed_entry_reward = 0.0 + self._pending_fixed_entry_info = {} + observation, info = self.raw_env.reset(seed=seed, options=options) + if self.fixed_entry_exit_only: + observation, entry_reward, terminated, truncated, entry_info = self.raw_env.step(ACTION_MARKET_BUY) + self._single_entry_entered = True + self._pending_fixed_entry_reward = float(entry_reward) + self._pending_fixed_entry_info = dict(entry_info) + info = dict(entry_info) + info.update( + { + "fixed_entry": True, + "fixed_entry_reward": float(entry_reward), + "fixed_entry_terminated": bool(terminated), + "fixed_entry_truncated": bool(truncated), + "constraint_mode": "fixed_entry_exit_only", + "policy_action_space": {0: "hold", 1: "exit"}, + } + ) + return np.asarray(observation, dtype=np.float32), self._add_episode_info(info) + + def step(self, action: Any) -> Tuple[np.ndarray, float, bool, bool, dict[str, Any]]: + policy_action = int(np.asarray(action).item()) + if self.fixed_entry_exit_only: + return self._step_fixed_entry_exit_only(policy_action) + if self.single_entry_exit: + return self._step_single_entry_exit(policy_action) + executed_action = self._constrain_action(policy_action) + observation, reward, terminated, truncated, info = self.raw_env.step(executed_action) + action_remapped = executed_action != policy_action + info = dict(info) + info.update( + { + "policy_action": policy_action, + "policy_action_name": ACTION_NAMES.get(policy_action, str(policy_action)), + "executed_action": executed_action, + "executed_action_name": ACTION_NAMES.get(executed_action, str(executed_action)), + "action_remapped": action_remapped, + "invalid_action_prevented": action_remapped, + "constraint_mode": "hold_on_invalid" if self.constrain_invalid_actions else "none", + } + ) + return ( + np.asarray(observation, dtype=np.float32), + float(reward), + bool(terminated), + bool(truncated), + self._add_episode_info(info), + ) + + def _step_fixed_entry_exit_only(self, policy_action: int) -> Tuple[np.ndarray, float, bool, bool, dict[str, Any]]: + if policy_action not in {0, 1}: + raise ValueError(f"Invalid fixed-entry action {policy_action!r}; expected 0=hold or 1=exit.") + if self.raw_env.position: + executed_action = ACTION_HOLD if policy_action == 0 else ACTION_MARKET_EXIT + semantic_action_name = "hold" if policy_action == 0 else "exit" + else: + executed_action = ACTION_HOLD + semantic_action_name = "closed" + observation, reward, terminated, truncated, info = self.raw_env.step(executed_action) + pending_entry_reward = float(self._pending_fixed_entry_reward) + if pending_entry_reward: + reward = float(reward) + pending_entry_reward + self._pending_fixed_entry_reward = 0.0 + forced_terminal = bool(policy_action == 1 or not self.raw_env.position) + terminated = bool(terminated or forced_terminal) + info = dict(info) + info.update( + { + "policy_action": int(policy_action), + "policy_action_name": "hold" if policy_action == 0 else "exit", + "executed_action": int(executed_action), + "executed_action_name": ACTION_NAMES.get(executed_action, str(executed_action)), + "semantic_action_name": semantic_action_name, + "action_remapped": False, + "invalid_action_prevented": False, + "constraint_mode": "fixed_entry_exit_only", + "fixed_entry": True, + "fixed_entry_reward": pending_entry_reward, + "episode_closed_by_policy": bool(forced_terminal), + } + ) + return ( + np.asarray(observation, dtype=np.float32), + float(reward), + bool(terminated), + bool(truncated), + self._add_episode_info(info), + ) + + def _step_single_entry_exit(self, policy_action: int) -> Tuple[np.ndarray, float, bool, bool, dict[str, Any]]: + executed_action = int(policy_action) + semantic_action_name = ACTION_NAMES.get(policy_action, str(policy_action)) + action_remapped = False + forced_terminal = False + + if not self._single_entry_entered: + if policy_action == ACTION_MARKET_BUY: + executed_action = ACTION_MARKET_BUY + semantic_action_name = "enter" + self._single_entry_entered = True + elif policy_action == ACTION_HOLD: + executed_action = ACTION_HOLD + semantic_action_name = "skip" + forced_terminal = True + else: + executed_action = ACTION_HOLD + semantic_action_name = "skip_invalid_exit" + action_remapped = True + forced_terminal = True + elif self.raw_env.position: + if policy_action == ACTION_MARKET_EXIT: + executed_action = ACTION_MARKET_EXIT + semantic_action_name = "exit" + forced_terminal = True + elif policy_action == ACTION_HOLD: + executed_action = ACTION_HOLD + semantic_action_name = "hold" + else: + executed_action = ACTION_HOLD + semantic_action_name = "hold_invalid_enter" + action_remapped = True + else: + executed_action = ACTION_HOLD + semantic_action_name = "closed" + action_remapped = policy_action != ACTION_HOLD + forced_terminal = True + + observation, reward, terminated, truncated, info = self.raw_env.step(executed_action) + terminated = bool(terminated or forced_terminal) + info = dict(info) + info.update( + { + "policy_action": int(policy_action), + "policy_action_name": ACTION_NAMES.get(policy_action, str(policy_action)), + "executed_action": int(executed_action), + "executed_action_name": ACTION_NAMES.get(executed_action, str(executed_action)), + "semantic_action_name": semantic_action_name, + "action_remapped": bool(action_remapped), + "invalid_action_prevented": bool(action_remapped), + "constraint_mode": "single_entry_exit", + "single_entry_entered": bool(self._single_entry_entered), + "episode_closed_by_policy": bool(forced_terminal), + } + ) + return ( + np.asarray(observation, dtype=np.float32), + float(reward), + bool(terminated), + bool(truncated), + self._add_episode_info(info), + ) + + def _constrain_action(self, action: int) -> int: + if not self.constrain_invalid_actions: + return int(action) + if action == ACTION_MARKET_BUY and self.raw_env.position: + return ACTION_HOLD + if action == ACTION_MARKET_EXIT and not self.raw_env.position: + return ACTION_HOLD + return int(action) + + def _add_episode_info(self, info: Mapping[str, Any]) -> dict[str, Any]: + payload = dict(info) + payload.update( + { + "episode_id": self.current_episode.episode_id, + "symbol": self.current_episode.symbol, + "session": self.current_episode.session, + "environment": "StomOrderbookRlEnv", + } + ) + return payload + + def render(self) -> None: + return None + + def close(self) -> None: + return None + + +def make_orderbook_sb3_env( + episodes: Sequence[OrderbookEpisode], + *, + seed: int = 100, + lookback_window: int = 30, + cost_bps: float = 23.0, + slippage_bps: float = 0.0, + max_episode_steps: int = 120, + constrain_invalid_actions: bool = False, + single_entry_exit: bool = False, + fixed_entry_exit_only: bool = False, +) -> StomOrderbookGymEnv: + """Create a Gymnasium-compatible orderbook env for Stable-Baselines3.""" + + return StomOrderbookGymEnv( + episodes, + StomOrderbookRlEnvConfig( + lookback_window=lookback_window, + cost_bps=cost_bps, + slippage_bps=slippage_bps, + max_episode_steps=max_episode_steps, + ), + constrain_invalid_actions=constrain_invalid_actions, + single_entry_exit=single_entry_exit, + fixed_entry_exit_only=fixed_entry_exit_only, + ) + + +__all__ = ["OrderbookEpisode", "StomOrderbookGymEnv", "make_orderbook_sb3_env"] diff --git a/stom_rl/orderbook_sb3_smoke.py b/stom_rl/orderbook_sb3_smoke.py new file mode 100644 index 000000000..16c1d45f0 --- /dev/null +++ b/stom_rl/orderbook_sb3_smoke.py @@ -0,0 +1,886 @@ +"""DQN smoke training + OOS evaluator for STOM orderbook RL. + +The output is an honest candidate artifact, not a live-trading approval. It +uses the marketable-only orderbook environment and compares the learned DQN +policy against a same-decision-time ``ts_imb`` TP5/SL1/time rule. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import time +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .marketable_fill import simulate_rule_from_entry +from .orderbook_rl_env import ( + ACTION_NAMES, + DB_REQUIRED_COLUMNS, + StomOrderbookRlEnvConfig, + _connect_readonly, + _quote_ident, + _table_columns, + normalize_orderbook_frame, +) +from .orderbook_sb3_adapter import OrderbookEpisode, StomOrderbookGymEnv +from .rl_events import RlLiveEventWriter, summarize_live_event_file + + +DEFAULT_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_orderbook_dqn_smoke" +CHANGE_RATE_THRESHOLD = 2.0 +TRADE_STRENGTH_THRESHOLD = 100.0 +BOOK_IMBALANCE_THRESHOLD = 0.5 + + +@dataclass(frozen=True) +class OrderbookDqnSmokeConfig: + db_path: str = str(Path("_database") / "stock_tick_back.db") + output_dir: str = str(DEFAULT_OUTPUT_DIR) + max_scan_symbols: int = 300 + train_episodes: int = 32 + eval_episodes: int = 32 + lookback_window: int = 30 + max_episode_steps: int = 120 + total_timesteps: int = 512 + seed: int = 100 + cost_bps: float = 23.0 + slippage_bps: float = 0.0 + overtrade_penalty: float = 0.0 + constrain_invalid_actions: bool = False + single_entry_exit: bool = False + fixed_entry_exit_only: bool = False + device: str = "auto" + dqn_learning_starts: int = 32 + dqn_buffer_size: int = 4096 + dqn_batch_size: int = 32 + min_eval_episodes: int = 10 + min_baseline_delta_pct: float = 0.0 + write_artifacts: bool = True + write_live_events: bool = True + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=list(fieldnames), extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _sb3_imports(): + from stable_baselines3 import DQN + from stable_baselines3.common.env_checker import check_env + + return DQN, check_env + + +def _torch_runtime() -> Dict[str, Any]: + import torch + + return { + "torch_version": torch.__version__, + "cuda_available": bool(torch.cuda.is_available()), + "cuda_version": torch.version.cuda, + "cuda_device_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, + } + + +def _stock_tables(conn: Any, *, max_scan_symbols: int) -> List[str]: + rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name").fetchall() + tables = [str(row[0]) for row in rows if str(row[0]).isdigit()] + if max_scan_symbols and max_scan_symbols > 0: + return tables[: int(max_scan_symbols)] + return tables + + +def _candidate_sessions(conn: Any, table: str, *, per_table_limit: int = 16) -> List[Dict[str, Any]]: + qt = _quote_ident(table) + q = f""" + WITH ranked AS ( + SELECT + substr(CAST("index" AS TEXT), 1, 8) AS session, + "index" AS ts_key, + "현재가" AS price, + "등락율" AS change_rate, + "체결강도" AS trade_strength, + "매수총잔량" AS bid_tot, + "매도총잔량" AS ask_tot, + ROW_NUMBER() OVER ( + PARTITION BY substr(CAST("index" AS TEXT), 1, 8) + ORDER BY "index" + ) AS rn + FROM {qt} + WHERE substr(CAST("index" AS TEXT), 9, 6) >= '090000' + AND substr(CAST("index" AS TEXT), 9, 6) <= '092000' + AND "현재가" > 0 + ) + SELECT session, ts_key, price, change_rate, trade_strength, bid_tot, ask_tot + FROM ranked + WHERE rn = 1 + AND change_rate >= ? + AND trade_strength >= ? + AND (bid_tot + ask_tot) > 0 + AND (bid_tot * 1.0 / (bid_tot + ask_tot)) >= ? + ORDER BY session + LIMIT ? + """ + rows = conn.execute( + q, + ( + float(CHANGE_RATE_THRESHOLD), + float(TRADE_STRENGTH_THRESHOLD), + float(BOOK_IMBALANCE_THRESHOLD), + int(per_table_limit), + ), + ).fetchall() + out = [] + for session, ts_key, price, change_rate, trade_strength, bid_tot, ask_tot in rows: + out.append( + { + "symbol": table, + "session": str(session), + "timestamp_key": int(ts_key), + "entry_price": float(price), + "entry_change_rate": float(change_rate), + "entry_trade_strength": float(trade_strength), + "entry_book_imbalance": float(bid_tot) / (float(bid_tot) + float(ask_tot)), + } + ) + return out + + +def _episode_frame(conn: Any, table: str, session: str, *, max_episode_steps: int, lookback_window: int) -> pd.DataFrame: + columns = ["index", "등락율", *[col for col in DB_REQUIRED_COLUMNS if col != "index"]] + select_cols = ", ".join(_quote_ident(col) for col in columns) + qt = _quote_ident(table) + limit = max(int(lookback_window) + int(max_episode_steps) + 3, int(lookback_window) + 2) + q = f""" + SELECT {select_cols} + FROM {qt} + WHERE substr(CAST("index" AS TEXT), 1, 8) = ? + AND substr(CAST("index" AS TEXT), 9, 6) >= '090000' + AND substr(CAST("index" AS TEXT), 9, 6) <= '092000' + ORDER BY "index" + LIMIT ? + """ + return pd.read_sql_query(q, conn, params=(str(session), int(limit))) + + +def load_orderbook_ts_imb_episodes(config: OrderbookDqnSmokeConfig) -> Tuple[List[OrderbookEpisode], List[Dict[str, Any]]]: + """Extract bounded ts_imb opening episodes from the local STOM DB.""" + + conn = _connect_readonly(config.db_path) + candidates: List[Dict[str, Any]] = [] + episodes: List[OrderbookEpisode] = [] + try: + required = set(["등락율", *DB_REQUIRED_COLUMNS]) + target = int(config.train_episodes) + int(config.eval_episodes) + for table in _stock_tables(conn, max_scan_symbols=int(config.max_scan_symbols)): + columns = set(_table_columns(conn, table)) + if not required.issubset(columns): + continue + candidates.extend(_candidate_sessions(conn, table, per_table_limit=8)) + candidates = sorted(candidates, key=lambda row: (row["session"], row["symbol"])) + if len(candidates) >= target * 2: + break + + for row in candidates: + if len(episodes) >= target: + break + frame = _episode_frame( + conn, + row["symbol"], + row["session"], + max_episode_steps=int(config.max_episode_steps), + lookback_window=int(config.lookback_window), + ) + try: + normalized = normalize_orderbook_frame(frame) + except ValueError: + continue + if len(normalized) < int(config.lookback_window) + 2: + continue + episode = OrderbookEpisode( + episode_id=f"{row['symbol']}_{row['session']}", + symbol=str(row["symbol"]), + session=str(row["session"]), + frame=frame, + ) + episodes.append(episode) + finally: + conn.close() + return episodes, candidates + + +def _make_env(episodes: Sequence[OrderbookEpisode], config: OrderbookDqnSmokeConfig) -> StomOrderbookGymEnv: + return StomOrderbookGymEnv( + episodes, + StomOrderbookRlEnvConfig( + lookback_window=int(config.lookback_window), + cost_bps=float(config.cost_bps), + slippage_bps=float(config.slippage_bps), + overtrade_penalty=float(config.overtrade_penalty), + max_episode_steps=int(config.max_episode_steps), + force_close_on_done=True, + ), + constrain_invalid_actions=bool(config.constrain_invalid_actions), + single_entry_exit=bool(config.single_entry_exit), + fixed_entry_exit_only=bool(config.fixed_entry_exit_only), + ) + + +def _baseline_same_decision(episode: OrderbookEpisode, config: OrderbookDqnSmokeConfig) -> Dict[str, Any]: + frame = normalize_orderbook_frame(episode.frame) + entry_idx = min(max(0, int(config.lookback_window) - 1), len(frame) - 1) + exit_idx = min(len(frame) - 1, entry_idx + int(config.max_episode_steps)) + prices = [float(v) for v in frame["price"].tolist()] + bids = [float(v) for v in frame["bid1"].tolist()] + asks = [float(v) for v in frame["ask1"].tolist()] + secs = [int(v) for v in frame["sec"].tolist()] + result = simulate_rule_from_entry( + prices, + bids, + asks, + secs, + entry_idx, + tp_pct=5.0, + sl_pct=1.0, + time_exit_sec=secs[exit_idx], + cost_bps=float(config.cost_bps), + slippage_bps=float(config.slippage_bps), + ) + return { + "episode_id": episode.episode_id, + "symbol": episode.symbol, + "session": episode.session, + "baseline_policy": "ts_imb_same_decision_tp5_sl1_time", + "baseline_net_return_pct": float(result[0]), + "baseline_exit_reason": result[1], + } + + +def _train_dqn(train_episodes: Sequence[OrderbookEpisode], config: OrderbookDqnSmokeConfig, writer: Optional[RlLiveEventWriter]): + DQN, _ = _sb3_imports() + from stable_baselines3.common.callbacks import BaseCallback + + class EventCallback(BaseCallback): + def __init__(self, event_writer: Optional[RlLiveEventWriter]): + super().__init__(verbose=0) + self.event_writer = event_writer + + def _on_step(self) -> bool: + if self.event_writer is None: + return True + infos = self.locals.get("infos") or [] + rewards = self.locals.get("rewards") or [] + actions = self.locals.get("actions") or [] + info = dict(infos[0]) if len(infos) else {} + action = actions[0] if len(actions) else None + action_int = int(np.asarray(action).item()) if action is not None else None + event_action = info.get("executed_action", action_int) + event_action_int = int(event_action) if event_action is not None else None + reward = float(rewards[0]) if len(rewards) else None + self.event_writer.write_step( + algorithm="orderbook_dqn", + phase="train", + global_step=int(self.num_timesteps), + episode_id=info.get("episode_id"), + timestamp=info.get("timestamp"), + price=None, + action=event_action_int, + reward=reward, + position=info.get("position"), + equity=info.get("equity"), + exploration=getattr(self.model, "exploration_rate", None), + source="orderbook_sb3_smoke", + info={ + "symbol": info.get("symbol"), + "session": info.get("session"), + "invalid_action": info.get("invalid_action"), + "policy_action": info.get("policy_action"), + "executed_action": info.get("executed_action"), + "semantic_action_name": info.get("semantic_action_name"), + "action_remapped": info.get("action_remapped"), + }, + ) + return True + + env = _make_env(train_episodes, config) + try: + _, check_env = _sb3_imports() + check_env(env, warn=True, skip_render_check=True) + learning_starts = max(1, min(int(config.dqn_learning_starts), max(1, int(config.total_timesteps) // 4))) + model = DQN( + "MlpPolicy", + env, + seed=int(config.seed), + device=str(config.device), + verbose=0, + learning_starts=learning_starts, + buffer_size=max(int(config.dqn_buffer_size), int(config.total_timesteps) * 2, 128), + batch_size=max(2, min(int(config.dqn_batch_size), int(config.total_timesteps))), + train_freq=4, + gradient_steps=1, + target_update_interval=64, + exploration_fraction=0.5, + exploration_final_eps=0.05, + policy_kwargs={"net_arch": [64, 32]}, + ) + started = time.perf_counter() + model.learn( + total_timesteps=int(config.total_timesteps), + progress_bar=False, + callback=EventCallback(writer), + ) + elapsed = time.perf_counter() - started + return model, elapsed, { + "passed": True, + "observation_space": str(env.observation_space), + "action_space": str(env.action_space), + "feature_columns": list(env.raw_env.feature_columns), + } + finally: + env.close() + + +def _evaluate_dqn( + model: Any, + eval_episodes: Sequence[OrderbookEpisode], + config: OrderbookDqnSmokeConfig, + writer: Optional[RlLiveEventWriter], +) -> Dict[str, Any]: + episode_rows: List[Dict[str, Any]] = [] + action_rows: List[Dict[str, Any]] = [] + baseline_rows: List[Dict[str, Any]] = [] + global_step = 0 + for idx, episode in enumerate(eval_episodes): + env = _make_env([episode], config) + observation, info = env.reset(seed=int(config.seed) + idx) + terminated = False + truncated = False + steps = 0 + last_info = dict(info) + policy_invalid_attempts = 0 + action_remaps = 0 + while not (terminated or truncated): + action, _ = model.predict(observation, deterministic=True) + action_int = int(np.asarray(action).item()) + observation, reward, terminated, truncated, step_info = env.step(action_int) + steps += 1 + global_step += 1 + last_info = dict(step_info) + executed_action = int(step_info.get("executed_action", step_info.get("action", action_int))) + action_name = str(step_info.get("semantic_action_name") or ACTION_NAMES.get(action_int, str(action_int))) + executed_action_name = str( + step_info.get("executed_action_name") or ACTION_NAMES.get(executed_action, str(executed_action)) + ) + action_remapped = bool(step_info.get("action_remapped")) + raw_invalid_action = bool(step_info.get("invalid_action")) + if action_remapped: + action_remaps += 1 + if raw_invalid_action or action_remapped: + policy_invalid_attempts += 1 + price = float(env.raw_env._row(env.raw_env.current_idx).get("price") or 0.0) + action_row = { + "model": "orderbook_dqn_smoke", + "algorithm": "dqn", + "episode_id": episode.episode_id, + "symbol": episode.symbol, + "session": episode.session, + "step": steps, + "timestamp": step_info.get("timestamp"), + "price": price, + "action": action_int, + "action_name": action_name, + "policy_action": action_int, + "policy_action_name": step_info.get("policy_action_name") or ACTION_NAMES.get(action_int, str(action_int)), + "executed_action": executed_action, + "executed_action_name": executed_action_name, + "semantic_action_name": action_name, + "action_remapped": action_remapped, + "invalid_action_prevented": bool(step_info.get("invalid_action_prevented")), + "reward": float(reward), + "equity": step_info.get("equity"), + "position": step_info.get("position_after"), + "invalid_action": raw_invalid_action, + } + action_rows.append(action_row) + if writer is not None: + event_action_int = executed_action if config.fixed_entry_exit_only else action_int + writer.write_step( + algorithm="orderbook_dqn", + phase="eval", + global_step=global_step, + episode=idx, + episode_id=episode.episode_id, + timestamp=step_info.get("timestamp"), + price=price, + action=event_action_int, + reward=float(reward), + position=step_info.get("position_after"), + equity=step_info.get("equity"), + source="orderbook_sb3_smoke", + info={ + "symbol": episode.symbol, + "session": episode.session, + "policy_action": action_int, + "executed_action": executed_action, + "semantic_action_name": action_name, + "action_remapped": action_remapped, + "invalid_action": raw_invalid_action, + }, + ) + baseline = _baseline_same_decision(episode, config) + baseline_rows.append(baseline) + model_net_pct = float(last_info.get("realized_net_return") or 0.0) * 100.0 + episode_rows.append( + { + "model": "orderbook_dqn_smoke", + "algorithm": "dqn", + "episode_id": episode.episode_id, + "symbol": episode.symbol, + "session": episode.session, + "episode_return_pct": model_net_pct, + "baseline_net_return_pct": baseline["baseline_net_return_pct"], + "baseline_delta_pct": model_net_pct - float(baseline["baseline_net_return_pct"]), + "trade_count": int(last_info.get("trade_count") or 0), + "invalid_action_count": int(last_info.get("invalid_action_count") or 0), + "policy_invalid_action_count": int(policy_invalid_attempts), + "action_remap_count": int(action_remaps), + "steps": steps, + } + ) + env.close() + return {"episodes": episode_rows, "actions": action_rows, "baseline": baseline_rows} + + +def _mean(values: Sequence[float]) -> float: + return float(np.mean(np.asarray(values, dtype=np.float64))) if values else 0.0 + + +def _diagnose_policy_behavior( + evaluation: Mapping[str, Sequence[Mapping[str, Any]]], + *, + config: OrderbookDqnSmokeConfig, +) -> Dict[str, Any]: + actions = list(evaluation.get("actions", [])) + episodes = list(evaluation.get("episodes", [])) + action_counts: Dict[str, int] = {} + executed_action_counts: Dict[str, int] = {} + for row in actions: + name = str(row.get("action_name") or row.get("action") or "unknown") + action_counts[name] = action_counts.get(name, 0) + 1 + executed_name = str(row.get("executed_action_name") or row.get("executed_action") or name) + executed_action_counts[executed_name] = executed_action_counts.get(executed_name, 0) + 1 + total_actions = sum(action_counts.values()) + invalid_count = sum(1 for row in actions if str(row.get("invalid_action")).lower() == "true") + action_remap_count = sum(1 for row in actions if str(row.get("action_remapped")).lower() == "true") + policy_invalid_attempt_count = sum( + 1 + for row in actions + if str(row.get("invalid_action")).lower() == "true" or str(row.get("action_remapped")).lower() == "true" + ) + trade_counts = [int(row.get("trade_count") or 0) for row in episodes] + invalid_episode_counts = [int(row.get("invalid_action_count") or 0) for row in episodes] + policy_invalid_episode_counts = [int(row.get("policy_invalid_action_count") or 0) for row in episodes] + remap_episode_counts = [int(row.get("action_remap_count") or 0) for row in episodes] + deltas = [float(row.get("baseline_delta_pct") or 0.0) for row in episodes] + returns = [float(row.get("episode_return_pct") or 0.0) for row in episodes] + baseline_returns = [float(row.get("baseline_net_return_pct") or 0.0) for row in episodes] + worst = sorted(episodes, key=lambda row: float(row.get("baseline_delta_pct") or 0.0))[:5] + action_rates = { + key: (value / total_actions if total_actions else 0.0) + for key, value in sorted(action_counts.items()) + } + executed_action_rates = { + key: (value / total_actions if total_actions else 0.0) + for key, value in sorted(executed_action_counts.items()) + } + avg_trades = _mean([float(v) for v in trade_counts]) + invalid_rate = invalid_count / total_actions if total_actions else 0.0 + action_remap_rate = action_remap_count / total_actions if total_actions else 0.0 + policy_invalid_attempt_rate = policy_invalid_attempt_count / total_actions if total_actions else 0.0 + likely_causes: List[str] = [] + if avg_trades > 6: + likely_causes.append("overtrading") + if invalid_rate > 0.10: + likely_causes.append("environment_invalid_action_rate_high") + if policy_invalid_attempt_rate > 0.10: + likely_causes.append("policy_invalid_action_attempt_rate_high") + if _mean(deltas) < 0: + likely_causes.append("baseline_relative_underperformance") + if not likely_causes: + likely_causes.append("no_single_dominant_cause") + return { + "constrain_invalid_actions": bool(config.constrain_invalid_actions), + "single_entry_exit": bool(config.single_entry_exit), + "fixed_entry_exit_only": bool(config.fixed_entry_exit_only), + "action_counts": dict(sorted(action_counts.items())), + "action_rates": action_rates, + "executed_action_counts": dict(sorted(executed_action_counts.items())), + "executed_action_rates": executed_action_rates, + "total_actions": total_actions, + "invalid_action_count": invalid_count, + "invalid_action_rate": invalid_rate, + "policy_invalid_attempt_count": policy_invalid_attempt_count, + "policy_invalid_attempt_rate": policy_invalid_attempt_rate, + "action_remap_count": action_remap_count, + "action_remap_rate": action_remap_rate, + "avg_trade_count_per_episode": avg_trades, + "max_trade_count_per_episode": max(trade_counts) if trade_counts else 0, + "avg_invalid_actions_per_episode": _mean([float(v) for v in invalid_episode_counts]), + "avg_policy_invalid_attempts_per_episode": _mean([float(v) for v in policy_invalid_episode_counts]), + "avg_action_remaps_per_episode": _mean([float(v) for v in remap_episode_counts]), + "avg_episode_net_return_pct": _mean(returns), + "baseline_avg_episode_net_return_pct": _mean(baseline_returns), + "baseline_delta_mean_pct": _mean(deltas), + "worst_baseline_delta_episodes": [ + { + "episode_id": row.get("episode_id"), + "symbol": row.get("symbol"), + "session": row.get("session"), + "episode_return_pct": float(row.get("episode_return_pct") or 0.0), + "baseline_net_return_pct": float(row.get("baseline_net_return_pct") or 0.0), + "baseline_delta_pct": float(row.get("baseline_delta_pct") or 0.0), + "trade_count": int(row.get("trade_count") or 0), + "invalid_action_count": int(row.get("invalid_action_count") or 0), + "policy_invalid_action_count": int(row.get("policy_invalid_action_count") or 0), + "action_remap_count": int(row.get("action_remap_count") or 0), + } + for row in worst + ], + "likely_causes": likely_causes, + "smallest_fix_selected": "fixed_entry_exit_only" + if config.fixed_entry_exit_only + else "constrained_action_wrapper" + if config.constrain_invalid_actions or config.single_entry_exit + else "overtrade_penalty", + "smallest_fix_rationale": ( + "The ts_imb candidate is treated as the fixed entry decision, so the RL policy only chooses " + "whether to keep holding or exit. This removes repeated entries and focuses learning on exit timing." + if config.fixed_entry_exit_only + else + "Plain SB3 DQN does not natively consume action masks. A hold-on-invalid wrapper keeps the " + "marketable-only action contract intact while preventing impossible fills from reaching the " + "raw environment." + if config.constrain_invalid_actions or config.single_entry_exit + else "Plain SB3 DQN does not support action masks without a larger algorithm/wrapper change; " + "reward clipping changes all reward magnitudes. The existing environment already supports " + "a per-trade penalty, so exposing overtrade_penalty is the smallest reversible change." + ), + "applied_overtrade_penalty": float(config.overtrade_penalty), + } + + +def run_orderbook_dqn_smoke(config: OrderbookDqnSmokeConfig) -> Dict[str, Any]: + episodes, candidates = load_orderbook_ts_imb_episodes(config) + needed = int(config.train_episodes) + int(config.eval_episodes) + if len(episodes) < max(2, min(needed, int(config.min_eval_episodes) + 1)): + raise ValueError(f"Not enough ts_imb orderbook episodes: {len(episodes)}") + + episodes = sorted(episodes, key=lambda ep: (ep.session, ep.symbol)) + train_count = min(int(config.train_episodes), max(1, len(episodes) - int(config.min_eval_episodes))) + eval_count = min(int(config.eval_episodes), len(episodes) - train_count) + train_set = episodes[:train_count] + eval_set = episodes[train_count : train_count + eval_count] + if len(eval_set) < int(config.min_eval_episodes): + # Keep the split chronological but allow smaller explicit smoke configs. + if int(config.eval_episodes) >= int(config.min_eval_episodes): + raise ValueError(f"Need at least {config.min_eval_episodes} eval episodes, got {len(eval_set)}") + + output_dir = Path(config.output_dir) + live_events_path = output_dir / "rl_live_events.jsonl" + writer: Optional[RlLiveEventWriter] = None + if config.write_artifacts and config.write_live_events: + writer = RlLiveEventWriter(live_events_path, run_id=output_dir.name) + writer.reset() + + model, train_elapsed, check_env = _train_dqn(train_set, config, writer) + evaluation = _evaluate_dqn(model, eval_set, config, writer) + model_returns = [float(row["episode_return_pct"]) for row in evaluation["episodes"]] + baseline_returns = [float(row["baseline_net_return_pct"]) for row in evaluation["episodes"]] + deltas = [float(row["baseline_delta_pct"]) for row in evaluation["episodes"]] + model_mean = _mean(model_returns) + baseline_mean = _mean(baseline_returns) + delta_mean = _mean(deltas) + trade_count = sum(int(row["trade_count"]) for row in evaluation["episodes"]) + diagnostics = _diagnose_policy_behavior(evaluation, config=config) + verdict = ( + "GO_CANDIDATE" + if len(eval_set) >= int(config.min_eval_episodes) + and delta_mean > float(config.min_baseline_delta_pct) + and model_mean > 0.0 + and trade_count > 0 + else "NO-GO" + ) + + summary = { + "algorithm_count": 1, + "algorithms": ["dqn"], + "environment": "StomOrderbookRlEnv", + "artifact_subtype": "orderbook_dqn_smoke", + "check_env_passed": bool(check_env["passed"]), + "training_timesteps": int(config.total_timesteps), + "training_elapsed_seconds": float(train_elapsed), + "train_episode_count": len(train_set), + "eval_episode_count": len(eval_set), + "candidate_count": len(candidates), + "scanned_symbol_limit": int(config.max_scan_symbols), + "feature_columns": check_env["feature_columns"], + "best_algorithm_by_avg_episode_net": "dqn", + "best_model": "orderbook_dqn_smoke", + "avg_episode_net_return_pct": model_mean, + "baseline_avg_episode_net_return_pct": baseline_mean, + "baseline_delta_mean_pct": delta_mean, + "trade_count": trade_count, + "invalid_action_rate": diagnostics["invalid_action_rate"], + "policy_invalid_attempt_rate": diagnostics["policy_invalid_attempt_rate"], + "action_remap_rate": diagnostics["action_remap_rate"], + "avg_trade_count_per_episode": diagnostics["avg_trade_count_per_episode"], + "overtrade_penalty": float(config.overtrade_penalty), + "constrain_invalid_actions": bool(config.constrain_invalid_actions), + "single_entry_exit": bool(config.single_entry_exit), + "fixed_entry_exit_only": bool(config.fixed_entry_exit_only), + "passes_cost_gate": verdict == "GO_CANDIDATE", + "verdict": verdict, + "is_live_ready": False, + "is_profit_model": False, + "baseline_policy": "ts_imb_same_decision_tp5_sl1_time", + "safety_note": "DQN smoke/OOS evaluator only; not live-ready and not a profit guarantee.", + } + model_row = { + "algorithm": "dqn", + "model": "orderbook_dqn_smoke", + "policy": "stable_baselines3_dqn", + "eval_split": "chronological_oos", + "training_timesteps": int(config.total_timesteps), + "training_elapsed_seconds": float(train_elapsed), + "episode_count": len(eval_set), + "trade_count": trade_count, + "avg_episode_net_return_pct": model_mean, + "baseline_avg_episode_net_return_pct": baseline_mean, + "baseline_delta_mean_pct": delta_mean, + "passes_cost_gate": verdict == "GO_CANDIDATE", + "is_smoke": True, + "eval_only": False, + "cost_bps": float(config.cost_bps), + "slippage_bps": float(config.slippage_bps), + "overtrade_penalty": float(config.overtrade_penalty), + "constrain_invalid_actions": bool(config.constrain_invalid_actions), + "single_entry_exit": bool(config.single_entry_exit), + "fixed_entry_exit_only": bool(config.fixed_entry_exit_only), + "invalid_action_rate": diagnostics["invalid_action_rate"], + "policy_invalid_attempt_rate": diagnostics["policy_invalid_attempt_rate"], + "action_remap_rate": diagnostics["action_remap_rate"], + "verdict": verdict, + } + payload: Dict[str, Any] = { + "mode": "stom_orderbook_rl_sb3_smoke", + "artifact_type": "sb3_smoke", + "generated_at": datetime.now(timezone.utc).isoformat(), + "config": asdict(config), + "runtime": _torch_runtime(), + "check_env": check_env, + "summary": summary, + "models": [model_row], + "oos_verdict": { + "verdict": verdict, + "model_mean_pct": model_mean, + "baseline_mean_pct": baseline_mean, + "delta_mean_pct": delta_mean, + "pass_criteria": { + "eval_episode_count": len(eval_set), + "min_eval_episodes": int(config.min_eval_episodes), + "min_baseline_delta_pct": float(config.min_baseline_delta_pct), + "requires_positive_model_mean": True, + "requires_trade_count": True, + }, + }, + "diagnostics": diagnostics, + "artifacts": { + "output_dir": str(output_dir), + "summary_json": str(output_dir / "sb3_smoke_summary.json"), + "summary_csv": str(output_dir / "sb3_smoke_summary.csv"), + "episodes_csv": str(output_dir / "episodes.csv"), + "actions_csv": str(output_dir / "actions.csv"), + "baseline_csv": str(output_dir / "baseline.csv"), + "verdict_json": str(output_dir / "orderbook_oos_verdict.json"), + "diagnostics_json": str(output_dir / "orderbook_diagnostics.json"), + "model_files": {"dqn": str(output_dir / "dqn_model.zip")}, + "live_events_jsonl": str(live_events_path), + "live_summary_json": str(output_dir / "rl_live_summary.json"), + }, + } + + if config.write_artifacts: + output_dir.mkdir(parents=True, exist_ok=True) + model.save(output_dir / "dqn_model.zip") + if config.write_live_events: + live_summary = summarize_live_event_file(live_events_path) + payload["live_events"] = live_summary + payload["summary"]["live_event_count"] = live_summary["event_count"] + payload["summary"]["live_event_phases"] = live_summary["phases"] + _write_json(output_dir / "rl_live_summary.json", live_summary) + _write_json(output_dir / "sb3_smoke_summary.json", payload) + _write_json(output_dir / "orderbook_oos_verdict.json", payload["oos_verdict"]) + _write_json(output_dir / "orderbook_diagnostics.json", diagnostics) + _write_csv( + output_dir / "sb3_smoke_summary.csv", + [model_row], + [ + "algorithm", + "model", + "policy", + "eval_split", + "training_timesteps", + "training_elapsed_seconds", + "episode_count", + "trade_count", + "avg_episode_net_return_pct", + "baseline_avg_episode_net_return_pct", + "baseline_delta_mean_pct", + "passes_cost_gate", + "is_smoke", + "eval_only", + "cost_bps", + "slippage_bps", + "overtrade_penalty", + "constrain_invalid_actions", + "single_entry_exit", + "fixed_entry_exit_only", + "invalid_action_rate", + "policy_invalid_attempt_rate", + "action_remap_rate", + "verdict", + ], + ) + _write_csv( + output_dir / "episodes.csv", + evaluation["episodes"], + [ + "model", + "algorithm", + "episode_id", + "symbol", + "session", + "episode_return_pct", + "baseline_net_return_pct", + "baseline_delta_pct", + "trade_count", + "invalid_action_count", + "policy_invalid_action_count", + "action_remap_count", + "steps", + ], + ) + _write_csv( + output_dir / "actions.csv", + evaluation["actions"], + [ + "model", + "algorithm", + "episode_id", + "symbol", + "session", + "step", + "timestamp", + "price", + "action", + "action_name", + "policy_action", + "policy_action_name", + "executed_action", + "executed_action_name", + "semantic_action_name", + "action_remapped", + "invalid_action_prevented", + "reward", + "equity", + "position", + "invalid_action", + ], + ) + _write_csv( + output_dir / "baseline.csv", + evaluation["baseline"], + [ + "episode_id", + "symbol", + "session", + "baseline_policy", + "baseline_net_return_pct", + "baseline_exit_reason", + ], + ) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> OrderbookDqnSmokeConfig: + parser = argparse.ArgumentParser(description="Run orderbook DQN smoke + OOS ts_imb baseline evaluator.") + parser.add_argument("--db-path", "--db", default=str(Path("_database") / "stock_tick_back.db")) + parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR)) + parser.add_argument("--max-scan-symbols", type=int, default=300) + parser.add_argument("--train-episodes", type=int, default=32) + parser.add_argument("--eval-episodes", type=int, default=32) + parser.add_argument("--lookback-window", type=int, default=30) + parser.add_argument("--max-episode-steps", type=int, default=120) + parser.add_argument("--total-timesteps", type=int, default=512) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--cost-bps", type=float, default=23.0) + parser.add_argument("--slippage-bps", type=float, default=0.0) + parser.add_argument("--overtrade-penalty", type=float, default=0.0) + parser.add_argument("--constrain-invalid-actions", action="store_true") + parser.add_argument("--single-entry-exit", action="store_true") + parser.add_argument("--fixed-entry-exit-only", action="store_true") + parser.add_argument("--device", default="auto") + parser.add_argument("--min-eval-episodes", type=int, default=10) + parser.add_argument("--min-baseline-delta-pct", type=float, default=0.0) + parser.add_argument("--no-write", action="store_true") + parser.add_argument("--no-live-events", action="store_true") + args = parser.parse_args(argv) + return OrderbookDqnSmokeConfig( + db_path=args.db_path, + output_dir=args.output_dir, + max_scan_symbols=args.max_scan_symbols, + train_episodes=args.train_episodes, + eval_episodes=args.eval_episodes, + lookback_window=args.lookback_window, + max_episode_steps=args.max_episode_steps, + total_timesteps=args.total_timesteps, + seed=args.seed, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + overtrade_penalty=args.overtrade_penalty, + constrain_invalid_actions=bool(args.constrain_invalid_actions), + single_entry_exit=bool(args.single_entry_exit), + fixed_entry_exit_only=bool(args.fixed_entry_exit_only), + device=args.device, + min_eval_episodes=args.min_eval_episodes, + min_baseline_delta_pct=args.min_baseline_delta_pct, + write_artifacts=not args.no_write, + write_live_events=not args.no_live_events, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_orderbook_dqn_smoke(_parse_args(argv)) + print(json.dumps(payload["summary"], ensure_ascii=False, indent=2)) + if payload.get("artifacts"): + print(f"wrote -> {payload['artifacts']['summary_json']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/panel_join.py b/stom_rl/panel_join.py new file mode 100644 index 000000000..dfb505e65 --- /dev/null +++ b/stom_rl/panel_join.py @@ -0,0 +1,426 @@ +"""Page 7.5 — multi-symbol 1s time-sync panel join (the core leakage gate). + +This module aligns the 1-second canonical RL feature frames of *multiple* +symbols onto a single common ``timestamp`` grid. It is the blocking +prerequisite for Page 9 (candidate CSV generation) because every downstream +decision/fill calculation assumes a point-in-time-correct panel. + +Why a dedicated module (and why ``merge_asof`` only): + +* The single most dangerous failure for an RL trading lab is **look-ahead + leakage** — letting a value observed at a *future* second influence a row at + time ``T``. A naive ``reindex`` + forward/back-fill, an ``outer`` merge, or a + ``ffill`` that crosses gaps can all silently pull a future observation + backward. +* We therefore align every symbol with + :func:`pandas.merge_asof` using ``direction="backward"`` *only*. For grid + time ``T`` a symbol contributes the most recent observation **at or before** + ``T``. A symbol with *no* observation at-or-before ``T`` (e.g. it lists later + in the session, or is halted) yields ``NaN`` — never a future value. + + ``merge_asof(direction="backward")`` is the "as-of" join: stale-but-real is + acceptable (the value genuinely existed at-or-before ``T``); future is not. + The explicit limitation of this backward fill is that a long halt produces a + *stale* value that persists until the next real observation; callers that want + to treat long staleness as missing should pass ``tolerance`` (see below). + +Output is **long-format** ``timestamp, symbol, <14 canonical features>`` to keep +memory bounded — a wide panel of 2400+ symbols would be mostly NaN. Trading +halt / VI rows are excluded *before* the join and reported, so they can never +leak into the grid as a real observation. + +Memory precondition (Page 7.5 P2-1): before any large/full-universe run, callers +must prove a single day-chunk fits the budget via +:func:`estimate_panel_memory` / :func:`assert_panel_memory_budget`: +``max_symbols * max_rows_per_group * bytes_per_row <= budget`` (default 2 GB). +The join itself is per-day-chunk; this module never performs a full DB scan. +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple, Union + +import numpy as np +import pandas as pd + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from finetune.qlib_stom_pipeline import ( # noqa: E402 + STOM_RL_CANONICAL_FEATURES, + build_stom_rl_feature_frame, + read_stom_table_rl_source, + resample_stom_rl_source_frame, +) +from stom_tick_dataset import connect_readonly # noqa: E402 + +# Long-format key columns prepended to the 14 canonical feature columns. +PANEL_KEY_COLUMNS: List[str] = ["timestamp", "symbol"] +PANEL_LONG_COLUMNS: List[str] = PANEL_KEY_COLUMNS + list(STOM_RL_CANONICAL_FEATURES) + +# Default memory budget for the per-day-chunk precondition (2 GB). +DEFAULT_MEMORY_BUDGET_BYTES: int = 2 * 1024 * 1024 * 1024 +# float64 (8 bytes) per canonical feature; one row carries the 14-feature vector. +BYTES_PER_FEATURE: int = 8 + + +@dataclass +class SymbolFrame: + """A single symbol's keyed canonical-feature frame ready for the panel. + + ``frame`` columns: ``timestamp`` (datetime64), ``symbol`` (str) and the 14 + :data:`STOM_RL_CANONICAL_FEATURES`. Rows are sorted by ``timestamp`` and + free of trading-halt / VI observations (those are reported separately). + """ + + symbol: str + frame: pd.DataFrame + excluded_halt_rows: int = 0 + raw_rows: int = 0 + session: Optional[str] = None + + +@dataclass +class PanelJoinReport: + """Diagnostics for a panel join run (excluded rows, coverage, grid size).""" + + symbols: List[str] = field(default_factory=list) + grid_size: int = 0 + grid_start: Optional[str] = None + grid_end: Optional[str] = None + per_symbol: Dict[str, Dict[str, Any]] = field(default_factory=dict) + total_excluded_halt_rows: int = 0 + memory: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + return { + "symbols": list(self.symbols), + "grid_size": int(self.grid_size), + "grid_start": self.grid_start, + "grid_end": self.grid_end, + "per_symbol": self.per_symbol, + "total_excluded_halt_rows": int(self.total_excluded_halt_rows), + "memory": self.memory, + } + + +# --------------------------------------------------------------------------- +# Memory precondition (P2-1) +# --------------------------------------------------------------------------- +def estimate_panel_memory( + max_symbols: int, + max_rows_per_group: int, + bytes_per_row: int = BYTES_PER_FEATURE * len(STOM_RL_CANONICAL_FEATURES), +) -> Dict[str, Any]: + """Estimate the worst-case bytes for one day-chunk of the long panel. + + The long format stores ``max_symbols * max_rows_per_group`` rows, each + carrying the 14-feature float64 vector (``bytes_per_row``). Key columns add + a small constant overhead which we fold into a conservative 1.5x factor. + """ + + if max_symbols < 0 or max_rows_per_group < 0 or bytes_per_row < 0: + raise ValueError("memory estimate inputs must be non-negative") + feature_bytes = int(max_symbols) * int(max_rows_per_group) * int(bytes_per_row) + estimated_bytes = int(feature_bytes * 1.5) # key/index/object overhead headroom + return { + "max_symbols": int(max_symbols), + "max_rows_per_group": int(max_rows_per_group), + "bytes_per_row": int(bytes_per_row), + "feature_bytes": feature_bytes, + "estimated_bytes": estimated_bytes, + } + + +def assert_panel_memory_budget( + max_symbols: int, + max_rows_per_group: int, + budget_bytes: int = DEFAULT_MEMORY_BUDGET_BYTES, + bytes_per_row: int = BYTES_PER_FEATURE * len(STOM_RL_CANONICAL_FEATURES), +) -> Dict[str, Any]: + """Raise unless ``max_symbols * max_rows_per_group * bytes_per_row <= budget``. + + Call this once before any large/full-universe run to prove a single day-chunk + fits the configured budget (default 2 GB). + """ + + estimate = estimate_panel_memory(max_symbols, max_rows_per_group, bytes_per_row) + estimate["budget_bytes"] = int(budget_bytes) + estimate["within_budget"] = estimate["estimated_bytes"] <= int(budget_bytes) + if not estimate["within_budget"]: + raise MemoryError( + "Panel day-chunk exceeds memory budget: " + f"{estimate['estimated_bytes']} bytes estimated " + f"({max_symbols} symbols x {max_rows_per_group} rows) " + f"> budget {budget_bytes} bytes. Reduce max-symbols, shrink the time " + "window (max-rows-per-group), or split the day into smaller chunks." + ) + return estimate + + +# --------------------------------------------------------------------------- +# Halt / VI exclusion +# --------------------------------------------------------------------------- +def _exclude_halt_rows( + keyed: pd.DataFrame, + halt_mask: Optional[pd.Series] = None, +) -> Tuple[pd.DataFrame, int]: + """Drop trading-halt / VI rows and return ``(clean_frame, excluded_count)``. + + A halt / VI second has no genuine tradable price. We treat a row as + halt-like when its ``close`` is missing or non-positive, or when the caller + supplies an explicit boolean ``halt_mask`` (e.g. a VI flag from the source). + Excluding *before* the as-of join guarantees a halted second can never become + the "most recent observation at-or-before T" for the grid. + """ + + if keyed.empty: + return keyed, 0 + close = pd.to_numeric(keyed.get("close"), errors="coerce") + drop = close.isna() | (close <= 0.0) + if halt_mask is not None: + drop = drop | halt_mask.reindex(keyed.index, fill_value=False).astype(bool) + excluded = int(drop.sum()) + clean = keyed.loc[~drop].reset_index(drop=True) + return clean, excluded + + +def prepare_symbol_frame( + keyed: pd.DataFrame, + symbol: Optional[str] = None, + halt_mask: Optional[pd.Series] = None, +) -> SymbolFrame: + """Normalize one symbol's keyed canonical frame for the panel join. + + ``keyed`` must carry ``timestamp`` plus the 14 canonical features (and may + carry ``symbol``/``session``). The result is sorted by ``timestamp``, + de-duplicated (keeping the last observation per second), halt-filtered, and + reduced to long-panel columns. + """ + + raw_rows = int(len(keyed)) + if symbol is None: + if "symbol" in keyed.columns and len(keyed) > 0: + symbol = str(keyed["symbol"].iloc[0]) + else: + raise ValueError("symbol must be provided when frame lacks a 'symbol' column") + symbol = str(symbol) + + session: Optional[str] = None + if "session" in keyed.columns and len(keyed) > 0: + session = str(keyed["session"].iloc[0]) + + work = keyed.copy() + if "timestamp" not in work.columns: + raise ValueError(f"symbol {symbol}: keyed frame is missing 'timestamp'") + work["timestamp"] = pd.to_datetime(work["timestamp"], errors="coerce") + work = work.dropna(subset=["timestamp"]) + + missing_features = [c for c in STOM_RL_CANONICAL_FEATURES if c not in work.columns] + if missing_features: + raise ValueError( + f"symbol {symbol}: keyed frame is missing canonical features: {missing_features}" + ) + + clean, excluded = _exclude_halt_rows(work, halt_mask=halt_mask) + clean = clean.sort_values("timestamp", kind="mergesort") + clean = clean.drop_duplicates(subset=["timestamp"], keep="last").reset_index(drop=True) + + clean["symbol"] = symbol + panel_frame = clean[["timestamp", "symbol", *STOM_RL_CANONICAL_FEATURES]].reset_index(drop=True) + return SymbolFrame( + symbol=symbol, + frame=panel_frame, + excluded_halt_rows=excluded, + raw_rows=raw_rows, + session=session, + ) + + +# --------------------------------------------------------------------------- +# Common-grid as-of join +# --------------------------------------------------------------------------- +def _build_common_grid( + symbol_frames: Sequence[SymbolFrame], + grid: Optional[pd.DatetimeIndex] = None, +) -> pd.DatetimeIndex: + """Return the common 1s timestamp grid (union of observed seconds by default).""" + + if grid is not None: + return pd.DatetimeIndex(pd.to_datetime(grid)).dropna().sort_values().unique() + stamps: List[pd.Timestamp] = [] + for sf in symbol_frames: + if not sf.frame.empty: + stamps.append(sf.frame["timestamp"]) + if not stamps: + return pd.DatetimeIndex([], dtype="datetime64[ns]") + union = pd.concat(stamps, ignore_index=True) + return pd.DatetimeIndex(union).dropna().sort_values().unique() + + +def join_symbol_frames( + symbol_frames: Sequence[SymbolFrame], + grid: Optional[pd.DatetimeIndex] = None, + tolerance: Optional[pd.Timedelta] = None, +) -> Tuple[pd.DataFrame, PanelJoinReport]: + """As-of (backward) join multiple symbol frames onto one common grid. + + For every grid timestamp ``T`` and every symbol, the panel carries that + symbol's most recent observation **at or before** ``T`` (``merge_asof`` with + ``direction="backward"``). No observation at-or-before ``T`` -> ``NaN``. + With ``tolerance`` set, an observation older than ``tolerance`` is also + treated as missing (stale beyond the allowed staleness). + + Returns ``(long_panel, report)`` where ``long_panel`` has columns + :data:`PANEL_LONG_COLUMNS` sorted by ``(timestamp, symbol)``. + """ + + grid_index = _build_common_grid(symbol_frames, grid=grid) + report = PanelJoinReport( + symbols=[sf.symbol for sf in symbol_frames], + grid_size=int(len(grid_index)), + grid_start=str(grid_index[0]) if len(grid_index) else None, + grid_end=str(grid_index[-1]) if len(grid_index) else None, + ) + + if len(grid_index) == 0: + empty = pd.DataFrame(columns=PANEL_LONG_COLUMNS) + report.memory = estimate_panel_memory(len(symbol_frames), 0) + return empty, report + + grid_df = pd.DataFrame({"timestamp": pd.DatetimeIndex(grid_index)}) + max_rows = 0 + parts: List[pd.DataFrame] = [] + for sf in symbol_frames: + right = sf.frame.sort_values("timestamp", kind="mergesort").reset_index(drop=True) + max_rows = max(max_rows, int(len(right))) + feature_cols = list(STOM_RL_CANONICAL_FEATURES) + if right.empty: + # No observations at all -> entire column is NaN (never a future value). + joined = grid_df.copy() + joined["symbol"] = sf.symbol + for col in feature_cols: + joined[col] = np.nan + else: + # Backward as-of: each grid row gets the last obs at-or-before T. + joined = pd.merge_asof( + grid_df, + right[["timestamp", *feature_cols]], + on="timestamp", + direction="backward", + tolerance=tolerance, + ) + joined["symbol"] = sf.symbol + parts.append(joined[["timestamp", "symbol", *feature_cols]]) + + non_null = int(joined[feature_cols[0]].notna().sum()) if feature_cols else 0 + report.per_symbol[sf.symbol] = { + "observations": int(len(sf.frame)), + "raw_rows": int(sf.raw_rows), + "excluded_halt_rows": int(sf.excluded_halt_rows), + "grid_rows_with_value": non_null, + "grid_rows_nan": int(len(grid_index) - non_null), + "session": sf.session, + } + report.total_excluded_halt_rows += int(sf.excluded_halt_rows) + + panel = pd.concat(parts, ignore_index=True) + panel = panel.sort_values(["timestamp", "symbol"], kind="mergesort").reset_index(drop=True) + panel = panel[PANEL_LONG_COLUMNS] + + report.memory = estimate_panel_memory(len(symbol_frames), max_rows) + return panel, report + + +# --------------------------------------------------------------------------- +# DB-backed convenience (per-day-chunk; never a full scan) +# --------------------------------------------------------------------------- +def build_panel_from_db( + db_path: Union[str, os.PathLike], + tables: Sequence[str], + session: Optional[str] = None, + time_start: str = "090000", + time_end: str = "093000", + max_rows_per_group: int = 0, + tick_size: float = 1.0, + grid: Optional[pd.DatetimeIndex] = None, + tolerance: Optional[pd.Timedelta] = None, + memory_budget_bytes: int = DEFAULT_MEMORY_BUDGET_BYTES, + freq: str = "1s", +) -> Tuple[pd.DataFrame, PanelJoinReport]: + """Read several symbol tables for one day-window and as-of join them. + + This is a thin convenience wrapper over :func:`read_stom_table_rl_source` + + :func:`build_stom_rl_feature_frame` (Page 7 per-symbol logic) and + :func:`join_symbol_frames`. It is bounded by the time window and optional + ``max_rows_per_group`` so it never scans a whole table. The memory budget is + asserted up-front using the requested ``max_rows_per_group`` (a value of 0 + means "window-bounded"; supply a positive bound before a full-universe run). + + ``freq`` (default ``"1s"`` — the 1s path is byte-unchanged) selects the bar + grid. At ``"1min"`` the RL SOURCE frame is resampled by + :func:`resample_stom_rl_source_frame` BEFORE :func:`build_stom_rl_feature_frame` + so every derived feature (amount_delta, trailing means/slopes) recomputes on + the 1-minute bars — this is THE live feed path where ``freq`` threads. + """ + + if max_rows_per_group and max_rows_per_group > 0: + assert_panel_memory_budget( + max_symbols=len(tables), + max_rows_per_group=int(max_rows_per_group), + budget_bytes=memory_budget_bytes, + ) + + conn = connect_readonly(db_path) + symbol_frames: List[SymbolFrame] = [] + try: + for table in tables: + source_frame, _src_report = read_stom_table_rl_source( + conn, + table, + session=session, + time_start=time_start, + time_end=time_end, + max_rows=max_rows_per_group, + ) + if source_frame.empty: + symbol_frames.append( + SymbolFrame( + symbol=str(table), + frame=pd.DataFrame(columns=["timestamp", "symbol", *STOM_RL_CANONICAL_FEATURES]), + excluded_halt_rows=0, + raw_rows=0, + session=session, + ) + ) + continue + # Resample the 18-col RL SOURCE frame BEFORE the feature builder so + # derived features recompute on the requested bar grid (R1/R2). At + # freq="1s" this returns the frame unchanged (1s path untouched). + source_frame = resample_stom_rl_source_frame(source_frame, freq=freq) + # Story B1 SESSION bars are one row per (symbol, session): the causal + # trend features must compute *across the per-symbol session series* + # (group by symbol only), else each per-session group is a length-1 + # window and every trend feature collapses to a constant (which would + # trip V-NONDEGEN). 1s/1min keep the default [symbol, session]. + trend_group_keys = ["symbol"] if freq == "session" else None + features = build_stom_rl_feature_frame( + source_frame, tick_size=tick_size, trend_group_keys=trend_group_keys + ) + keyed = pd.concat( + [ + source_frame[["timestamp", "symbol", "session"]].reset_index(drop=True), + features.reset_index(drop=True), + ], + axis=1, + ) + symbol_frames.append(prepare_symbol_frame(keyed)) + finally: + conn.close() + + return join_symbol_frames(symbol_frames, grid=grid, tolerance=tolerance) diff --git a/stom_rl/paper_replay.py b/stom_rl/paper_replay.py new file mode 100644 index 000000000..726e478b1 --- /dev/null +++ b/stom_rl/paper_replay.py @@ -0,0 +1,303 @@ +"""Read-only portfolio paper replay. + +The replay consumes candidate rows and writes decision/NAV/risk logs only. It +does not integrate with broker, order, or execution APIs. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence + +from .portfolio_env import ACTION_HOLD, PortfolioEnv, PortfolioEnvConfig +from .risk_gate import RiskGate, RiskGateConfig, RiskState +from .symbol_norm import read_candidates_csv + + +DEFAULT_PAPER_REPLAY_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_portfolio_paper_replay" + + +@dataclass(frozen=True) +class PaperReplayConfig: + candidate_path: Optional[str] = None + output_dir: str = str(DEFAULT_PAPER_REPLAY_OUTPUT_DIR) + read_only: bool = True + top_k_candidates: int = 3 + max_positions: int = 2 + max_steps: int = 24 + seed: int = 100 + initial_cash: float = 1_000_000.0 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + max_drawdown_pct: float = 20.0 + max_consecutive_losses: int = 3 + max_daily_trades: int = 20 + max_position_weight: float = 0.50 + write_artifacts: bool = True + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _proposed_action(env: PortfolioEnv, info: Mapping[str, Any]) -> int: + mask = list(info["action_mask"]) + sell_offset = 1 + env.config.top_k_candidates + if int(info["current_step"]) % 5 == 4: + for action in range(sell_offset, len(mask)): + if mask[action]: + return action + for action in range(1, sell_offset): + if mask[action]: + return action + return ACTION_HOLD + + +def _action_target(env: PortfolioEnv, info: Mapping[str, Any], action: int) -> Dict[str, Any]: + """Resolve the symbol/slot a proposed ``action`` would touch. + + Read-only: looks up the current-bar candidate frame (buy) or the held + symbols (sell) without mutating any state. Returns ``slot`` and best-effort + ``symbol`` so blocked-action reason codes carry both. + """ + + decoded = env.decode_action(action) + action_type = str(decoded["type"]) + slot = decoded.get("slot") + symbol = "" + if action_type == "buy" and slot is not None: + candidates = env._current_candidates() + if int(slot) < len(candidates): + symbol = str(candidates.iloc[int(slot)]["symbol"]) + elif action_type == "sell" and slot is not None: + # ``info["positions"]`` is a list of position dicts already sorted by + # symbol, matching the env's slot ordering (``_holding_symbols()``). + holdings = [str(p["symbol"]) for p in info.get("positions", []) if "symbol" in p] + if int(slot) < len(holdings): + symbol = holdings[int(slot)] + return {"action_type": action_type, "slot": slot, "symbol": symbol} + + +def run_paper_replay(config: PaperReplayConfig) -> Dict[str, Any]: + if not config.read_only: + raise ValueError("Paper replay must run with read_only=True; write-mode order routing is not implemented.") + candidates = read_candidates_csv(config.candidate_path) if config.candidate_path else None + env = PortfolioEnv( + PortfolioEnvConfig( + candidate_path=config.candidate_path, + top_k_candidates=config.top_k_candidates, + max_positions=config.max_positions, + initial_cash=config.initial_cash, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + seed=config.seed, + ), + candidates=candidates, + ) + gate = RiskGate( + RiskGateConfig( + max_drawdown_pct=config.max_drawdown_pct, + max_consecutive_losses=config.max_consecutive_losses, + max_daily_trades=config.max_daily_trades, + max_position_weight=config.max_position_weight, + max_positions=config.max_positions, + ) + ) + _, info = env.reset(seed=config.seed) + risk_state = RiskState(peak_nav=float(info["nav"]), last_nav=float(info["nav"])) + decisions: List[Dict[str, Any]] = [] + risk_triggers: List[Dict[str, Any]] = [] + blocked_actions: List[Dict[str, Any]] = [] + terminated = False + truncated = False + steps = 0 + while not (terminated or truncated): + if config.max_steps and steps >= int(config.max_steps): + break + proposed = _proposed_action(env, info) + target = _action_target(env, info, proposed) + decoded = env.decode_action(proposed) + action_type = str(decoded["type"]) + proposed_weight = float(env.config.buy_fraction) if action_type == "buy" else 0.0 + risk = gate.evaluate( + action_type=action_type, + nav=float(info["nav"]), + position_count=len(info["positions"]), + proposed_weight=proposed_weight, + state=risk_state, + ) + # A pure HOLD never routes an order, so the risk gate's portfolio-level + # blocks (drawdown / consecutive_losses) only count as *blocked actions* + # when an order (buy/sell) was actually proposed. + risk_blocked = (not risk["allowed"]) and action_type != "hold" + action = ACTION_HOLD if risk_blocked else proposed + if risk_blocked: + blocked = { + "timestamp": info["timestamp"], + "source": "risk_gate", + "proposed_action": proposed, + "action_type": action_type, + "slot": target["slot"], + "symbol": target["symbol"], + "reason": risk["reason"], + "risk_state": risk["state"], + } + blocked_actions.append(blocked) + risk_triggers.append( + { + "timestamp": info["timestamp"], + "proposed_action": proposed, + "reason": risk["reason"], + "risk_state": risk["state"], + } + ) + nav_before = float(info["nav"]) + invalid_before = len(env.invalid_actions) + _, reward, terminated, truncated, next_info = env.step(action) + traded = int(next_info["trade_count"]) > int(info["trade_count"]) + gate.update_after_step(risk_state, nav_before=nav_before, nav_after=float(next_info["nav"]), traded=traded) + # The env masks structurally invalid orders (padding slot, already held, + # unfillable / no T+1). Surface those as blocked actions too, with the + # current risk state attached so every blocked row carries one. + env_blocked = not risk_blocked and len(env.invalid_actions) > invalid_before + env_reason = next_info.get("blocked_reason", "") if env_blocked else "" + if env_blocked: + blocked_actions.append( + { + "timestamp": info["timestamp"], + "source": "env_mask", + "proposed_action": proposed, + "action_type": action_type, + "slot": target["slot"], + "symbol": target["symbol"], + "reason": env_reason, + "risk_state": dict(risk["state"]), + } + ) + decisions.append( + { + "timestamp": info["timestamp"], + "proposed_action": proposed, + "executed_action": action, + "action_type": action_type, + "symbol": target["symbol"], + "blocked": risk_blocked or env_blocked, + "blocked_source": "risk_gate" if risk_blocked else ("env_mask" if env_blocked else ""), + "blocked_reason": risk["reason"] if risk_blocked else env_reason, + "env_blocked_reason": next_info.get("blocked_reason", ""), + "reward": float(reward), + "nav_after": float(next_info["nav"]), + "read_only": True, + } + ) + info = next_info + steps += 1 + + output_dir = Path(config.output_dir) + payload: Dict[str, Any] = { + "mode": "stom_rl_portfolio_paper_replay", + "config": asdict(config), + "summary": { + "read_only": True, + "steps": steps, + "final_nav": float(info["nav"]), + "trade_count": int(info["trade_count"]), + "blocked_action_count": len(blocked_actions), + "risk_blocked_count": sum(1 for b in blocked_actions if b["source"] == "risk_gate"), + "env_blocked_count": sum(1 for b in blocked_actions if b["source"] == "env_mask"), + "risk_trigger_count": len(risk_triggers), + "order_write_path": False, + }, + "artifacts": { + "output_dir": str(output_dir), + "summary_json": str(output_dir / "paper_replay_summary.json"), + "decisions_csv": str(output_dir / "decisions.csv"), + "nav_csv": str(output_dir / "nav.csv"), + "risk_triggers_json": str(output_dir / "risk_triggers.json"), + "blocked_actions_json": str(output_dir / "blocked_actions.json"), + }, + } + if config.write_artifacts: + _write_json(output_dir / "paper_replay_summary.json", payload) + _write_csv( + output_dir / "decisions.csv", + decisions, + [ + "timestamp", + "proposed_action", + "executed_action", + "action_type", + "symbol", + "blocked", + "blocked_source", + "blocked_reason", + "env_blocked_reason", + "reward", + "nav_after", + "read_only", + ], + ) + _write_csv(output_dir / "nav.csv", env.nav_log, ["timestamp", "step", "nav", "cash", "position_count"]) + _write_json(output_dir / "risk_triggers.json", {"risk_triggers": risk_triggers, "env_blocked_actions": env.invalid_actions}) + _write_json(output_dir / "blocked_actions.json", {"blocked_actions": blocked_actions}) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> PaperReplayConfig: + parser = argparse.ArgumentParser(description="Run read-only portfolio paper replay.") + parser.add_argument("--read-only", action="store_true", default=True) + parser.add_argument("--candidate-csv", default=None) + parser.add_argument("--output-dir", default=str(DEFAULT_PAPER_REPLAY_OUTPUT_DIR)) + parser.add_argument("--top-k-candidates", type=int, default=3) + parser.add_argument("--max-positions", type=int, default=2) + parser.add_argument("--max-steps", type=int, default=24) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--initial-cash", type=float, default=1_000_000.0) + parser.add_argument("--cost-bps", type=float, default=25.0) + parser.add_argument("--slippage-bps", type=float, default=0.0) + parser.add_argument("--max-drawdown-pct", type=float, default=20.0) + parser.add_argument("--max-consecutive-losses", type=int, default=3) + parser.add_argument("--max-daily-trades", type=int, default=20) + parser.add_argument("--max-position-weight", type=float, default=0.50) + parser.add_argument("--no-write", action="store_true") + args = parser.parse_args(argv) + return PaperReplayConfig( + candidate_path=args.candidate_csv, + output_dir=args.output_dir, + read_only=True, + top_k_candidates=args.top_k_candidates, + max_positions=args.max_positions, + max_steps=args.max_steps, + seed=args.seed, + initial_cash=args.initial_cash, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + max_drawdown_pct=args.max_drawdown_pct, + max_consecutive_losses=args.max_consecutive_losses, + max_daily_trades=args.max_daily_trades, + max_position_weight=args.max_position_weight, + write_artifacts=not args.no_write, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_paper_replay(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/paper_sim.py b/stom_rl/paper_sim.py new file mode 100644 index 000000000..315539293 --- /dev/null +++ b/stom_rl/paper_sim.py @@ -0,0 +1,302 @@ +"""Frozen-policy paper-trading replay for the 시초 갭상승 strategy (Page D). + +**RULE strategy, NOT reinforcement learning.** + +There is no live market feed in this environment, so a TRUE forward/paper run is +impossible. The closest honest proxy is a **frozen-policy replay**: take the +fully pre-registered rule (등락율>=2% + ts_imb + TP5/SL1/09:25) AND the Page A +operating policy (sizing f, top-K concurrency, consecutive-loss + monthly +de-risk, daily-loss halt) — change NOTHING — and walk it chronologically over a +recent holdout window as if it had been traded day by day. This is the first +time the SIZING rules touch an actual trade SEQUENCE (Page A was static +formulas), so it tests whether the operating policy produces a sane ACCOUNT +equity path, not just a per-trade %. + +Honest limits: same triggered-subset DB (not a live feed, not new data); the +rule has no fitted parameters so the whole history is effectively out-of-sample +for the rule, but a recent-window replay is NOT a substitute for real forward +observation. Within-day entries fire near-simultaneously at the open, so the +daily-loss halt is modelled by processing the day's top-K in signal order and +stopping further entries once the day's realized loss reaches the limit — a mild +approximation (real opens are simultaneous), not a precise intraday sequence. +The consecutive-loss halt is a CIRCUIT BREAKER: when the streak reaches the halt +tier the day is skipped and the streak resets (cooldown), so trading resumes — +this replay surfaced that the literal Page A "halt" (size 0 until a win) deadlocks, +since a halted account takes no trades and so can never win to reset the streak. +Note the reset re-arms FULL size the next day (a sawtooth that deploys MORE capital +into a sustained drawdown than the tiered ladder alone — a recovery convenience, +not extra safety). The daily-loss limit is measured against the start-of-day +account (not running intraday equity), and ``n_skipped_halt`` aggregates BOTH the +consecutive-loss circuit-breaker skips and the intraday daily-loss skips. + +Core is PURE (no I/O); the CLI reads the gitignored ``instances.json`` artifact. +""" + +from __future__ import annotations + +from itertools import groupby +from typing import Any, Dict, List, Optional, Sequence + +from stom_rl.gap_up_risk_sizing import ( + RiskConfig, + effective_fraction, + position_notional_won, + should_halt_day, +) + + +def simulate_paper_account( + trades: Sequence[Dict[str, Any]], + config: RiskConfig, + *, + initial_account_won: float, + compounding: bool = True, +) -> Dict[str, Any]: + """Replay the frozen rule+sizing policy over a chronological trade sequence. + + ``trades`` items: ``{"date": "YYYYMMDD", "strength": float, "sec_amount_won": + float|None, "net_pct": float}`` where ``net_pct`` is the rule's per-trade net + return (% of notional) for the primary TP5/SL1 cell. Each day the top-``K`` + (= ``config.max_concurrent``) candidates by ``strength`` are taken; each is + sized by ``effective_fraction`` (consecutive-loss + monthly de-risk) times the + sizing base (current equity if ``compounding`` else the initial account), + capped by liquidity (``sec_amount_won``). The daily-loss halt stops further + same-day entries once the day's realized loss reaches the limit. Returns the + account path and summary (final equity, total return %, max drawdown %, and + how many signals were taken / skipped by the K cap or a halt). + """ + + if initial_account_won <= 0: + raise ValueError("initial_account_won must be > 0") + k = config.max_concurrent + + ordered = sorted(trades, key=lambda t: str(t["date"])) + account = float(initial_account_won) + peak = account + max_dd_pct = 0.0 + streak = 0 + n_signals = 0 + n_taken = 0 + n_skipped_cap = 0 + n_skipped_halt = 0 + n_days = 0 + n_days_daily_limit_hit = 0 + month: Optional[str] = None + month_start_account = account + equity_curve: List[float] = [account] + + for date, group in groupby(ordered, key=lambda t: str(t["date"])): + day = sorted(group, key=lambda t: (t.get("strength") or 0.0), reverse=True) + n_signals += len(day) + n_days += 1 + + m = str(date)[:6] + if month is None or m != month: + month = m + month_start_account = account + month_return_pct = ( + (account / month_start_account - 1.0) * 100.0 + if month_start_account > 0 + else 0.0 + ) + f_eff = effective_fraction(config, streak, month_return_pct) + sizing_base = account if compounding else float(initial_account_won) + + taken_today = day[:k] + n_skipped_cap += max(0, len(day) - k) + + if f_eff <= 0.0: + # Consecutive-loss circuit breaker tripped (streak at the halt tier): + # sit the day out, then RESET the streak (cooldown complete) so trading + # resumes next day. Without this reset a halted account takes no + # trades, so it can never win to reset the streak and would freeze + # FOREVER — a deadlock this replay surfaced in the literal Page A policy. + n_skipped_halt += len(taken_today) + streak = 0 + equity_curve.append(account) + continue + + day_pnl = 0.0 + day_nets: List[float] = [] + halted = False + for t in taken_today: + if should_halt_day(day_pnl, account, config): + n_skipped_halt += 1 + halted = True + continue + notional = position_notional_won( + sizing_base, + config, + fraction=f_eff, + entry_liquidity_won=t.get("sec_amount_won"), + ) + pnl = notional * float(t["net_pct"]) / 100.0 + day_pnl += pnl + day_nets.append(float(t["net_pct"])) + n_taken += 1 + if halted: + n_days_daily_limit_hit += 1 + + account += day_pnl + # Update the consecutive-loss streak from the day's taken trades (the + # next day sizes off this). A win resets; a loss extends. + for net in day_nets: + streak = 0 if net > 0.0 else streak + 1 + + peak = max(peak, account) + if peak > 0: + dd_pct = (account / peak - 1.0) * 100.0 + max_dd_pct = min(max_dd_pct, dd_pct) + equity_curve.append(account) + + total_return_pct = (account / float(initial_account_won) - 1.0) * 100.0 + return { + "initial_account_won": float(initial_account_won), + "final_account_won": account, + "total_return_pct": total_return_pct, + "max_drawdown_pct": max_dd_pct, + "n_days": n_days, + "n_signals": n_signals, + "n_taken": n_taken, + "n_skipped_cap": n_skipped_cap, + "n_skipped_halt": n_skipped_halt, + "n_days_daily_limit_hit": n_days_daily_limit_hit, + "compounding": compounding, + "equity_curve": equity_curve, + } + + +# --------------------------------------------------------------------------- +# CLI: read instances.json (ts_imb) -> frozen-policy replay (full + holdout). +# --------------------------------------------------------------------------- +def _load_trades( + instances_path: str, + *, + net_pct_key: str = "tp5_sl1_net_pct", + sec_amount_unit_won: float = 1_000_000.0, +) -> List[Dict[str, Any]]: + import json + from pathlib import Path + + # utf-8-sig tolerates a BOM if a sibling writer added one (plain utf-8 would + # leave  on the first key and break json.loads). + rows = json.loads(Path(instances_path).read_text(encoding="utf-8-sig")) + out: List[Dict[str, Any]] = [] + for r in rows: + if not r.get("pass_ts_imb"): + continue + net = r.get(net_pct_key) + if net is None: + continue + sa = r.get("entry_sec_amount") + out.append( + { + "date": str(r["session"]), + "strength": r.get("entry_trade_strength"), + "sec_amount_won": (float(sa) * sec_amount_unit_won) if sa is not None else None, + "net_pct": float(net), + } + ) + return out + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + project_root = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser( + description="Frozen-policy paper replay (RULE NOT RL) - Page D." + ) + parser.add_argument( + "--instances", + default=str(project_root / ".omx" / "artifacts" / "gap_up_full" / "instances.json"), + ) + parser.add_argument("--net-pct-key", default="tp5_sl1_net_pct") + parser.add_argument("--initial-account-won", type=float, default=100_000_000.0) + parser.add_argument( + "--holdout-start", + default="20250901", + help="YYYYMMDD; trades on/after this date form the recent forward-proxy holdout.", + ) + parser.add_argument( + "--no-compounding", action="store_true", help="Size off the initial account (fixed)." + ) + parser.add_argument( + "--json-out", + default=str(project_root / ".omx" / "artifacts" / "paper_sim" / "summary.json"), + ) + args = parser.parse_args(argv) + + trades = _load_trades(args.instances, net_pct_key=args.net_pct_key) + holdout = [t for t in trades if t["date"] >= args.holdout_start] + config = RiskConfig() + compounding = not args.no_compounding + + def run(label: str, ts: List[Dict[str, Any]]) -> Dict[str, Any]: + if not ts: + print(f"-- {label}: no trades --") + return {"label": label, "n_signals": 0} + s = simulate_paper_account( + ts, config, initial_account_won=args.initial_account_won, compounding=compounding + ) + dmin = min(t["date"] for t in ts) + dmax = max(t["date"] for t in ts) + print(f"-- {label} dates {dmin}->{dmax} --") + print( + f" signals={s['n_signals']} taken={s['n_taken']} " + f"skipped(cap/halt)={s['n_skipped_cap']}/{s['n_skipped_halt']} " + f"days={s['n_days']} daily_limit_days={s['n_days_daily_limit_hit']}" + ) + print( + f" account {s['initial_account_won']:,.0f} -> {s['final_account_won']:,.0f} " + f"return={s['total_return_pct']:+.1f}% maxDD={s['max_drawdown_pct']:+.1f}% " + f"(compounding={s['compounding']})" + ) + s.pop("equity_curve", None) # keep JSON small + s["label"] = label + return s + + print("=== frozen-policy paper replay (RULE strategy, NOT RL) - Page D ===") + print( + f"filter=ts_imb f={config.per_trade_fraction:g} K={config.max_concurrent} " + f"daily_limit={config.daily_loss_limit_pct:g}% net_pct_key={args.net_pct_key} " + f"(NOT a live feed; frozen-policy replay on a fixed DB)" + ) + results = [run("FULL", trades), run(f"HOLDOUT(>={args.holdout_start})", holdout)] + + out_path = Path(args.json_out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text( + json.dumps( + { + "instances": args.instances, + "net_pct_key": args.net_pct_key, + "initial_account_won": args.initial_account_won, + "holdout_start": args.holdout_start, + "compounding": compounding, + "runs": results, + }, + ensure_ascii=False, + indent=2, + ), + encoding="utf-8", + ) + print( + "\nread: a sane holdout account path (positive return, drawdown within the " + "Page A envelope, most signals actually taken) supports moving toward a " + "REAL forward/paper run (still required before any live order)." + ) + print(f"wrote summary -> {out_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/participant_pressure_contract.py b/stom_rl/participant_pressure_contract.py new file mode 100644 index 000000000..2f08dfd0f --- /dev/null +++ b/stom_rl/participant_pressure_contract.py @@ -0,0 +1,113 @@ +"""Participant proxy feature contract for opening-window research.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Final, Literal, TypedDict + + +COL_TRADE_STRENGTH: Final[str] = "\uccb4\uacb0\uac15\ub3c4" +COL_BUY_AMOUNT: Final[str] = "\ucd08\ub2f9\ub9e4\uc218\uae08\uc561" +COL_SELL_AMOUNT: Final[str] = "\ucd08\ub2f9\ub9e4\ub3c4\uae08\uc561" +COL_TRANSACTION_VALUE: Final[str] = "\ucd08\ub2f9\uac70\ub798\ub300\uae08" +COL_BID_TOTAL: Final[str] = "\ub9e4\uc218\ucd1d\uc794\ub7c9" +COL_ASK_TOTAL: Final[str] = "\ub9e4\ub3c4\ucd1d\uc794\ub7c9" +COL_FOREIGN_NET_BUY: Final[str] = "\uc678\uad6d\uc778\uc21c\ub9e4\uc218" +COL_INSTITUTION_NET_BUY: Final[str] = "\uae30\uad00\uc21c\ub9e4\uc218" +COL_PROGRAM_NET_BUY: Final[str] = "\ud504\ub85c\uadf8\ub7a8\uc21c\ub9e4\uc218" + +Availability = Literal["available", "missing"] +DecisionAvailability = Literal["required", "optional"] +MissingPolicy = Literal["fail_closed", "not_causal_at_decision"] + + +class ParticipantFeatureSpecPayload(TypedDict): + name: str + feature_group: str + source_column: str + available_at_decision_second: DecisionAvailability + lookback: int + missing_policy: MissingPolicy + + +class ParticipantComputedFeatures(TypedDict): + rows_used: int + transaction_value_sum: float + transaction_value_surge: float + signed_amount_ratio: float + signed_amount_persistence: float + trade_strength: float + bid_depth_imbalance: float + participant_proxy_pressure: float + foreign_net_buy: float | None + institution_net_buy: float | None + program_net_buy: float | None + + +class ParticipantStrategyContext(TypedDict): + line: str + label: str + is_reinforcement_learning: bool + is_live_ready: bool + is_profit_model: bool + guardrail: str + + +class ParticipantReadinessPayload(TypedDict): + artifact_type: str + mode: str + participant_context_version: str + decision_second: int + feature_schema: list[ParticipantFeatureSpecPayload] + proxy_availability: dict[str, Availability] + missing_proxy_columns: list[str] + computed_features: ParticipantComputedFeatures + strategy_context: ParticipantStrategyContext + artifacts: dict[str, str] + + +@dataclass(frozen=True, slots=True) +class ParticipantPressureError(ValueError): + """Raised when participant proxy inputs violate causal feature contracts.""" + + reason: str + + def __str__(self) -> str: + return self.reason + + +@dataclass(frozen=True, slots=True) +class ParticipantProxyFeature: + """Participant proxy schema item with causal source availability metadata.""" + + name: str + feature_group: str + source_column: str + required_columns: tuple[str, ...] + available_at_decision_second: DecisionAvailability + lookback: int + missing_policy: MissingPolicy + + def to_payload(self) -> ParticipantFeatureSpecPayload: + return { + "name": self.name, + "feature_group": self.feature_group, + "source_column": self.source_column, + "available_at_decision_second": self.available_at_decision_second, + "lookback": int(self.lookback), + "missing_policy": self.missing_policy, + } + + +FEATURE_SPECS: Final[tuple[ParticipantProxyFeature, ...]] = ( + ParticipantProxyFeature("transaction_value_sum", "participant_pressure", COL_TRANSACTION_VALUE, (COL_TRANSACTION_VALUE,), "required", 30, "fail_closed"), + ParticipantProxyFeature("signed_amount_ratio", "participant_pressure", f"{COL_BUY_AMOUNT}-{COL_SELL_AMOUNT}", (COL_BUY_AMOUNT, COL_SELL_AMOUNT), "required", 30, "fail_closed"), + ParticipantProxyFeature("trade_strength", "participant_pressure", COL_TRADE_STRENGTH, (COL_TRADE_STRENGTH,), "required", 1, "fail_closed"), + ParticipantProxyFeature("bid_depth_imbalance", "orderbook_persistence", f"{COL_BID_TOTAL}/{COL_ASK_TOTAL}", (COL_BID_TOTAL, COL_ASK_TOTAL), "required", 1, "fail_closed"), + ParticipantProxyFeature("participant_proxy_pressure", "participant_pressure", "trade_strength+signed_amount_ratio+bid_depth_imbalance", (COL_TRADE_STRENGTH, COL_BUY_AMOUNT, COL_SELL_AMOUNT, COL_BID_TOTAL, COL_ASK_TOTAL), "required", 30, "fail_closed"), + ParticipantProxyFeature("transaction_value_surge", "participant_pressure", COL_TRANSACTION_VALUE, (COL_TRANSACTION_VALUE,), "required", 30, "fail_closed"), + ParticipantProxyFeature("signed_amount_persistence", "participant_pressure", f"{COL_BUY_AMOUNT}-{COL_SELL_AMOUNT}", (COL_BUY_AMOUNT, COL_SELL_AMOUNT), "required", 30, "fail_closed"), + ParticipantProxyFeature("foreign_net_buy", "participant_flow_optional", COL_FOREIGN_NET_BUY, (COL_FOREIGN_NET_BUY,), "optional", 1, "not_causal_at_decision"), + ParticipantProxyFeature("institution_net_buy", "participant_flow_optional", COL_INSTITUTION_NET_BUY, (COL_INSTITUTION_NET_BUY,), "optional", 1, "not_causal_at_decision"), + ParticipantProxyFeature("program_net_buy", "participant_flow_optional", COL_PROGRAM_NET_BUY, (COL_PROGRAM_NET_BUY,), "optional", 1, "not_causal_at_decision"), +) diff --git a/stom_rl/participant_pressure_features.py b/stom_rl/participant_pressure_features.py new file mode 100644 index 000000000..4fd813b7f --- /dev/null +++ b/stom_rl/participant_pressure_features.py @@ -0,0 +1,175 @@ +"""Causal market-participant proxy features for opening-window research.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd # noqa: PANDAS_OK - STOM tick/orderbook frames are pandas-based + +from .participant_pressure_contract import ( + COL_ASK_TOTAL, + COL_BID_TOTAL, + COL_BUY_AMOUNT, + COL_FOREIGN_NET_BUY, + COL_INSTITUTION_NET_BUY, + COL_PROGRAM_NET_BUY, + COL_SELL_AMOUNT, + COL_TRADE_STRENGTH, + COL_TRANSACTION_VALUE, + FEATURE_SPECS, + Availability, + ParticipantComputedFeatures, + ParticipantFeatureSpecPayload, + ParticipantPressureError, + ParticipantProxyFeature, + ParticipantReadinessPayload, +) + +def _causal_rows(frame: pd.DataFrame, decision_second: int) -> pd.DataFrame: + if frame.empty: + raise ParticipantPressureError("participant proxy frame must not be empty") + if decision_second < 0 or decision_second >= len(frame): + raise ParticipantPressureError("decision_second out of range for participant proxy frame") + return frame.iloc[: decision_second + 1] + + +def _require_columns(frame: pd.DataFrame, specs: tuple[ParticipantProxyFeature, ...]) -> None: + required = [spec for spec in specs if spec.available_at_decision_second == "required"] + missing = [ + column + for spec in required + for column in spec.required_columns + if column not in frame.columns + ] + if missing: + unique_missing = ", ".join(dict.fromkeys(missing)) + raise ParticipantPressureError(f"missing required participant proxy columns: {unique_missing}") + + +def _last_float(rows: pd.DataFrame, column: str) -> float: + return float(rows[column].iloc[-1]) + + +def _sum_float(rows: pd.DataFrame, column: str) -> float: + return float(rows[column].astype(float).sum()) + + +def _optional_last_float(rows: pd.DataFrame, column: str) -> float | None: + if column not in rows.columns: + return None + value = rows[column].iloc[-1] + return None if pd.isna(value) else float(value) + + +def _signed_amount_ratio(rows: pd.DataFrame) -> float: + buy_sum = _sum_float(rows, COL_BUY_AMOUNT) + sell_sum = _sum_float(rows, COL_SELL_AMOUNT) + denominator = buy_sum + sell_sum + return (buy_sum - sell_sum) / denominator if denominator > 0.0 else 0.0 + + +def _bid_depth_imbalance(rows: pd.DataFrame) -> float: + latest = rows.iloc[-1] + bid_total = float(latest[COL_BID_TOTAL]) + ask_total = float(latest[COL_ASK_TOTAL]) + denominator = bid_total + ask_total + return bid_total / denominator if denominator > 0.0 else 0.0 + + +def _clip01(value: float) -> float: + return max(0.0, min(1.0, value)) + + +def _participant_proxy_pressure(trade_strength: float, signed_amount_ratio: float, bid_depth_imbalance: float) -> float: + trade_component = _clip01(trade_strength / 150.0) + signed_component = _clip01((signed_amount_ratio + 1.0) / 2.0) + bid_component = _clip01(bid_depth_imbalance) + return (trade_component + signed_component + bid_component) / 3.0 + + +def _feature_schema() -> list[ParticipantFeatureSpecPayload]: + return [spec.to_payload() for spec in FEATURE_SPECS] + + +def _proxy_availability(frame: pd.DataFrame) -> dict[str, Availability]: + return { + spec.name: ( + "available" + if all(column in frame.columns for column in spec.required_columns) + else "missing" + ) + for spec in FEATURE_SPECS + } + + +def _missing_proxy_columns(frame: pd.DataFrame) -> list[str]: + return [ + spec.source_column + for spec in FEATURE_SPECS + if spec.available_at_decision_second == "optional" + and any(column not in frame.columns for column in spec.required_columns) + ] + + +def compute_participant_pressure_features( + frame: pd.DataFrame, + *, + decision_second: int, +) -> ParticipantComputedFeatures: + """Compute participant proxy features using rows up to the decision second only.""" + + _require_columns(frame, FEATURE_SPECS) + rows = _causal_rows(frame, decision_second) + transaction_value_sum = _sum_float(rows, COL_TRANSACTION_VALUE) + signed_amount_ratio = _signed_amount_ratio(rows) + trade_strength = _last_float(rows, COL_TRADE_STRENGTH) + bid_depth_imbalance = _bid_depth_imbalance(rows) + return { + "rows_used": int(len(rows)), + "transaction_value_sum": transaction_value_sum, + "transaction_value_surge": transaction_value_sum, + "signed_amount_ratio": signed_amount_ratio, + "signed_amount_persistence": signed_amount_ratio, + "trade_strength": trade_strength, + "bid_depth_imbalance": bid_depth_imbalance, + "participant_proxy_pressure": _participant_proxy_pressure(trade_strength, signed_amount_ratio, bid_depth_imbalance), + "foreign_net_buy": _optional_last_float(rows, COL_FOREIGN_NET_BUY), + "institution_net_buy": _optional_last_float(rows, COL_INSTITUTION_NET_BUY), + "program_net_buy": _optional_last_float(rows, COL_PROGRAM_NET_BUY), + } + + +def build_participant_pressure_readiness( + frame: pd.DataFrame, + *, + output_dir: Path, + decision_second: int, +) -> ParticipantReadinessPayload: + """Write participant proxy readiness metadata for the opening RL workflow.""" + + computed = compute_participant_pressure_features(frame, decision_second=decision_second) + output_dir.mkdir(parents=True, exist_ok=True) + summary_path = output_dir / "participant_pressure_readiness_summary.json" + payload: ParticipantReadinessPayload = { + "artifact_type": "participant_pressure_readiness", + "mode": "opening_30m_rl_participant_proxy_audit", + "participant_context_version": "market_participant_proxy_v1", + "decision_second": int(decision_second), + "feature_schema": _feature_schema(), + "proxy_availability": _proxy_availability(frame), + "missing_proxy_columns": _missing_proxy_columns(frame), + "computed_features": computed, + "strategy_context": { + "line": "participant_proxy_evidence", + "label": "MARKET PARTICIPANT PROXY", + "is_reinforcement_learning": False, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": "Proxy evidence only; does not identify real investor actors.", + }, + "artifacts": {"summary_json": str(summary_path)}, + } + content = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + summary_path.write_text(content, encoding="utf-8") + return payload diff --git a/stom_rl/performance_leaderboard.py b/stom_rl/performance_leaderboard.py new file mode 100644 index 000000000..67bd36ffc --- /dev/null +++ b/stom_rl/performance_leaderboard.py @@ -0,0 +1,406 @@ +"""Aggregate STOM RL baseline and learned-policy artifacts into one leaderboard.""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + + +DEFAULT_BASELINE_REPORT = ( + Path("webui") / "rl_runs" / "stom_1s_2025_baseline_leaderboard_full_test" / "leaderboard_report.json" +) +DEFAULT_CONTEXTUAL_BANDIT_REPORT = ( + Path("webui") / "rl_runs" / "stom_1s_2025_contextual_bandit_full_test" / "eval_summary.json" +) +DEFAULT_SB3_SMOKE_REPORT = Path("webui") / "rl_runs" / "stom_1s_2025_sb3_smoke" / "sb3_smoke_summary.json" +DEFAULT_RL_RUNS_DIR = Path("webui") / "rl_runs" +AUTO_SB3_REPORTS = "__auto__" +DEFAULT_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_1s_2025_performance_leaderboard_full_test" + + +@dataclass(frozen=True) +class PerformanceLeaderboardConfig: + baseline_report: str = str(DEFAULT_BASELINE_REPORT) + contextual_bandit_report: str = str(DEFAULT_CONTEXTUAL_BANDIT_REPORT) + sb3_smoke_reports: Tuple[str, ...] = (AUTO_SB3_REPORTS,) + sb3_report_root: str = str(DEFAULT_RL_RUNS_DIR) + output_dir: str = str(DEFAULT_OUTPUT_DIR) + target_cost_bps: float = 25.0 + target_slippage_bps: float = 0.0 + write_artifacts: bool = True + + +def _read_json(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _float_or_zero(value: Any) -> float: + try: + if value is None: + return 0.0 + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _bool_value(value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.strip().lower() in {"true", "1", "yes", "y"} + return bool(value) + + +def discover_sb3_summary_reports(root: Path = DEFAULT_RL_RUNS_DIR) -> Tuple[str, ...]: + """Discover SB3 smoke/mini/short training summaries under the RL run root.""" + + if not root.is_dir(): + return (str(DEFAULT_SB3_SMOKE_REPORT),) if DEFAULT_SB3_SMOKE_REPORT.is_file() else () + reports = sorted( + { + path + for path in root.glob("stom_1s_2025_sb3*/sb3_smoke_summary.json") + if path.is_file() + }, + key=lambda path: (path.parent.name, path.name), + ) + return tuple(str(path) for path in reports) + + +def _resolve_sb3_reports(config: PerformanceLeaderboardConfig) -> Tuple[Tuple[str, ...], str]: + raw_reports = tuple(part.strip() for part in config.sb3_smoke_reports if str(part).strip()) + if any(part.lower() in {AUTO_SB3_REPORTS, "auto", "*"} for part in raw_reports): + reports = discover_sb3_summary_reports(Path(config.sb3_report_root)) + return reports, "auto" + return raw_reports, "explicit" + + +def _sb3_model_name(row: Mapping[str, Any]) -> str: + if _bool_value(row.get("eval_only")): + return str(row.get("model")) + algorithm = str(row.get("algorithm") or row.get("model") or "sb3").lower() + explicit = str(row.get("model") or f"{algorithm}_smoke") + timesteps = int(_float_or_zero(row.get("training_timesteps"))) + if timesteps >= 1000: + suffix = f"{timesteps // 1000}k" if timesteps % 1000 == 0 else str(timesteps) + return f"{algorithm}_{suffix}" + return explicit + + +def _sb3_run_category(row: Mapping[str, Any]) -> str: + timesteps = int(_float_or_zero(row.get("training_timesteps"))) + if timesteps <= 1000: + return "smoke" + if timesteps <= 10_000: + return "mini" + if timesteps <= 100_000: + return "short" + return "long" + + +def _baseline_rows(payload: Mapping[str, Any], config: PerformanceLeaderboardConfig) -> List[Dict[str, Any]]: + rows = payload.get("summary", {}).get("target_rows") or [] + normalized = [] + for row in rows: + normalized.append( + { + "source": "baseline", + "run_name": Path(config.baseline_report).parent.name, + "model": str(row.get("policy") or "baseline"), + "policy": str(row.get("policy") or "baseline"), + "split": "test", + "cost_bps": _float_or_zero(row.get("cost_bps", config.target_cost_bps)), + "slippage_bps": _float_or_zero(row.get("slippage_bps", config.target_slippage_bps)), + "episode_count": int(_float_or_zero(row.get("episode_count"))), + "trade_count": int(_float_or_zero(row.get("trade_count"))), + "trades_per_episode": _float_or_zero(row.get("trades_per_episode")), + "avg_episode_net_return_pct": _float_or_zero(row.get("avg_episode_net_return_pct")), + "median_episode_net_return_pct": _float_or_zero(row.get("median_episode_net_return_pct")), + "compounded_return_pct": _float_or_zero(row.get("compounded_return_pct")), + "avg_trade_net_return_pct": _float_or_zero(row.get("avg_trade_net_return_pct")), + "hit_rate": _float_or_zero(row.get("hit_rate")), + "max_drawdown_pct": _float_or_zero(row.get("max_drawdown_pct")), + "positive_session_rate": _float_or_zero(row.get("positive_session_rate")), + "passes_cost_gate": False, + "is_smoke": False, + } + ) + return normalized + + +def _contextual_bandit_row(payload: Mapping[str, Any], config: PerformanceLeaderboardConfig) -> Dict[str, Any]: + summary = payload.get("eval_summary", {}).get("summary") or payload.get("summary") or payload.get("eval_summary") or {} + return { + "source": "rl_model", + "run_name": Path(config.contextual_bandit_report).parent.name, + "model": "contextual_bandit", + "policy": str(summary.get("policy") or "contextual_bandit"), + "split": str(summary.get("eval_split") or "test"), + "cost_bps": _float_or_zero(summary.get("cost_bps", config.target_cost_bps)), + "slippage_bps": _float_or_zero(summary.get("slippage_bps", config.target_slippage_bps)), + "episode_count": int(_float_or_zero(summary.get("episode_count"))), + "trade_count": int(_float_or_zero(summary.get("trade_count"))), + "trades_per_episode": _float_or_zero(summary.get("trades_per_episode")), + "avg_episode_net_return_pct": _float_or_zero(summary.get("avg_episode_net_return_pct")), + "median_episode_net_return_pct": _float_or_zero(summary.get("median_episode_net_return_pct")), + "compounded_return_pct": _float_or_zero(summary.get("compounded_return_pct")), + "avg_trade_net_return_pct": _float_or_zero(summary.get("avg_trade_net_return_pct")), + "hit_rate": _float_or_zero(summary.get("hit_rate")), + "max_drawdown_pct": _float_or_zero(summary.get("max_drawdown_pct")), + "positive_session_rate": None, + "passes_cost_gate": _bool_value(summary.get("passes_cost_gate")), + "is_smoke": False, + } + + +def _sb3_smoke_rows( + payload: Mapping[str, Any], + report_path: Path, + config: PerformanceLeaderboardConfig, +) -> List[Dict[str, Any]]: + rows = payload.get("models") or payload.get("model_summaries") or [] + normalized = [] + for row in rows: + algorithm = str(row.get("algorithm") or row.get("model") or "sb3") + run_category = _sb3_run_category(row) + eval_only = _bool_value(row.get("eval_only")) + normalized.append( + { + "source": "rl_model", + "run_name": report_path.parent.name, + "model": _sb3_model_name(row), + "base_model": str(row.get("model") or f"{algorithm}_smoke"), + "policy": str(row.get("policy") or f"stable_baselines3_{algorithm}"), + "split": str(row.get("eval_split") or row.get("split") or "test"), + "training_timesteps": int(_float_or_zero(row.get("training_timesteps"))), + "run_category": run_category, + "cost_bps": _float_or_zero(row.get("cost_bps", config.target_cost_bps)), + "slippage_bps": _float_or_zero(row.get("slippage_bps", config.target_slippage_bps)), + "episode_count": int(_float_or_zero(row.get("episode_count"))), + "trade_count": int(_float_or_zero(row.get("trade_count"))), + "trades_per_episode": _float_or_zero(row.get("trades_per_episode")), + "avg_episode_net_return_pct": _float_or_zero(row.get("avg_episode_net_return_pct")), + "median_episode_net_return_pct": _float_or_zero(row.get("median_episode_net_return_pct")), + "compounded_return_pct": _float_or_zero(row.get("compounded_return_pct")), + "avg_trade_net_return_pct": _float_or_zero(row.get("avg_trade_net_return_pct")), + "hit_rate": _float_or_zero(row.get("hit_rate")), + "max_drawdown_pct": _float_or_zero(row.get("max_drawdown_pct")), + "positive_session_rate": None, + "passes_cost_gate": _bool_value(row.get("passes_cost_gate")), + "is_smoke": (run_category == "smoke") and not eval_only, + "eval_only": eval_only, + "source_run": row.get("source_run"), + "source_model": row.get("source_model"), + "eval_episode_count": int(_float_or_zero(row.get("eval_episode_count"))), + } + ) + return normalized + + +def _decision(row: Mapping[str, Any], *, no_trade_return: float, buy_and_hold_return: float) -> Dict[str, Any]: + avg_return = _float_or_zero(row.get("avg_episode_net_return_pct")) + mdd = _float_or_zero(row.get("max_drawdown_pct")) + beats_no_trade = avg_return > no_trade_return + beats_buy_and_hold = avg_return > buy_and_hold_return + passes_cost_gate = _bool_value(row.get("passes_cost_gate")) + if row.get("source") == "baseline": + label = "baseline" + reason = "비교 기준선" + elif beats_buy_and_hold and passes_cost_gate: + label = "candidate" + reason = "25bp 비용 기준에서 buy-and-hold와 cost gate를 모두 통과" + elif beats_no_trade: + label = "watch" + reason = "no-trade보다 낫지만 buy-and-hold 또는 cost gate 기준은 미달" + else: + label = "hold" + reason = "비용 반영 후 no-trade 기준도 충분히 넘지 못함" + if mdd <= -50.0 and label == "candidate": + label = "watch" + reason = "수익 기준은 통과했지만 MDD가 과도함" + return { + "beats_no_trade": beats_no_trade, + "beats_buy_and_hold": beats_buy_and_hold, + "usability": label, + "decision_reason": reason, + } + + +def build_performance_leaderboard(config: PerformanceLeaderboardConfig) -> Dict[str, Any]: + baseline_payload = _read_json(Path(config.baseline_report)) + contextual_payload = _read_json(Path(config.contextual_bandit_report)) + rows = _baseline_rows(baseline_payload, config) + [_contextual_bandit_row(contextual_payload, config)] + missing_optional_reports = [] + sb3_report_paths, sb3_report_source = _resolve_sb3_reports(config) + for raw_report_path in sb3_report_paths: + report_path = Path(raw_report_path) + if not report_path.is_file(): + missing_optional_reports.append(str(report_path)) + continue + rows.extend(_sb3_smoke_rows(_read_json(report_path), report_path, config)) + + no_trade_return = next( + (_float_or_zero(row.get("avg_episode_net_return_pct")) for row in rows if row.get("policy") == "no_trade"), + 0.0, + ) + buy_and_hold_return = next( + (_float_or_zero(row.get("avg_episode_net_return_pct")) for row in rows if row.get("policy") == "buy_and_hold"), + 0.0, + ) + for row in rows: + row.update(_decision(row, no_trade_return=no_trade_return, buy_and_hold_return=buy_and_hold_return)) + + rows.sort(key=lambda row: _float_or_zero(row.get("avg_episode_net_return_pct")), reverse=True) + for rank, row in enumerate(rows, start=1): + row["rank"] = rank + + model_rows = [row for row in rows if row.get("source") == "rl_model"] + sb3_training_rows = [ + row + for row in model_rows + if not row.get("is_smoke") + and not row.get("eval_only") + and int(_float_or_zero(row.get("training_timesteps"))) > 0 + ] + best_model = model_rows[0] if model_rows else None + payload = { + "mode": "stom_rl_performance_leaderboard", + "config": { + **asdict(config), + "sb3_smoke_reports": list(sb3_report_paths), + "sb3_smoke_reports_source": sb3_report_source, + }, + "summary": { + "row_count": len(rows), + "target_cost_bps": config.target_cost_bps, + "target_slippage_bps": config.target_slippage_bps, + "best_policy": rows[0]["policy"] if rows else None, + "best_rl_model": best_model["model"] if best_model else None, + "best_rl_usability": best_model["usability"] if best_model else None, + "buy_and_hold_avg_episode_net_return_pct": buy_and_hold_return, + "no_trade_avg_episode_net_return_pct": no_trade_return, + "rl_models_beating_buy_and_hold": [ + row["model"] for row in model_rows if row.get("beats_buy_and_hold") + ], + "rl_models_passing_cost_gate": [ + row["model"] for row in model_rows if row.get("passes_cost_gate") + ], + "rl_smoke_models": [ + row["model"] for row in model_rows if row.get("is_smoke") + ], + "rl_eval_only_models": [ + row["model"] for row in model_rows if row.get("eval_only") + ], + "rl_training_models": [ + row["model"] for row in sb3_training_rows + ], + "max_sb3_training_timesteps": max( + [int(_float_or_zero(row.get("training_timesteps"))) for row in model_rows], + default=0, + ), + "missing_optional_reports": missing_optional_reports, + }, + "leaderboard": rows, + "artifacts": { + "output_dir": str(Path(config.output_dir)), + "leaderboard_json": str(Path(config.output_dir) / "performance_leaderboard.json"), + "leaderboard_csv": str(Path(config.output_dir) / "performance_leaderboard.csv"), + }, + } + if config.write_artifacts: + output_dir = Path(config.output_dir) + _write_json(output_dir / "performance_leaderboard.json", payload) + _write_csv( + output_dir / "performance_leaderboard.csv", + rows, + [ + "rank", + "source", + "run_name", + "model", + "base_model", + "policy", + "split", + "training_timesteps", + "run_category", + "cost_bps", + "slippage_bps", + "episode_count", + "trade_count", + "trades_per_episode", + "avg_episode_net_return_pct", + "median_episode_net_return_pct", + "compounded_return_pct", + "avg_trade_net_return_pct", + "hit_rate", + "max_drawdown_pct", + "positive_session_rate", + "passes_cost_gate", + "is_smoke", + "eval_only", + "source_run", + "source_model", + "beats_no_trade", + "beats_buy_and_hold", + "usability", + "decision_reason", + ], + ) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> PerformanceLeaderboardConfig: + parser = argparse.ArgumentParser(description="Aggregate STOM RL full-test performance leaderboard artifacts.") + parser.add_argument("--baseline-report", default=str(DEFAULT_BASELINE_REPORT)) + parser.add_argument("--contextual-bandit-report", default=str(DEFAULT_CONTEXTUAL_BANDIT_REPORT)) + parser.add_argument( + "--sb3-smoke-reports", + default="auto", + help=( + "Comma-separated optional SB3 summary JSON paths. " + "Use 'auto' to discover webui/rl_runs/stom_1s_2025_sb3*/sb3_smoke_summary.json." + ), + ) + parser.add_argument("--sb3-report-root", default=str(DEFAULT_RL_RUNS_DIR)) + parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR)) + parser.add_argument("--target-cost-bps", type=float, default=25.0) + parser.add_argument("--target-slippage-bps", type=float, default=0.0) + parser.add_argument("--no-write", action="store_true") + args = parser.parse_args(argv) + return PerformanceLeaderboardConfig( + baseline_report=args.baseline_report, + contextual_bandit_report=args.contextual_bandit_report, + sb3_smoke_reports=tuple(part.strip() for part in args.sb3_smoke_reports.split(",") if part.strip()), + sb3_report_root=args.sb3_report_root, + output_dir=args.output_dir, + target_cost_bps=args.target_cost_bps, + target_slippage_bps=args.target_slippage_bps, + write_artifacts=not args.no_write, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = build_performance_leaderboard(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/portfolio_env.py b/stom_rl/portfolio_env.py new file mode 100644 index 000000000..c3fdc21e1 --- /dev/null +++ b/stom_rl/portfolio_env.py @@ -0,0 +1,508 @@ +"""Fixed-shape portfolio environment for STOM RL candidates. + +The first portfolio action contract is deliberately discrete and slot-based: + +* ``0``: hold +* ``1..top_k_candidates``: buy the candidate slot +* ``top_k_candidates+1..top_k_candidates+max_positions``: sell the holding slot + +Candidate and holding masks make padded slots explicit. Invalid actions are +logged with reason codes and penalized instead of silently mutating the action. +""" + +from __future__ import annotations + +import warnings +from dataclasses import asdict, dataclass +from typing import Any, ClassVar, Dict, List, Mapping, Optional, Tuple + +import numpy as np +import pandas as pd + +from .accounting import FLOAT_TOLERANCE, PortfolioAccount +from .symbol_norm import normalize_symbol_series, read_candidates_csv +from .trading_env import BoxSpace, DiscreteSpace + + +ACTION_HOLD = 0 + + +def _float_or_zero(value: Any) -> float: + try: + if pd.isna(value): + return 0.0 + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _as_bool(value: Any) -> bool: + """Coerce CSV/JSON truthy markers (``True``/``"true"``/``1``) to ``bool``.""" + + if isinstance(value, str): + return value.strip().lower() in {"true", "1", "yes"} + try: + if pd.isna(value): + return False + except (TypeError, ValueError): + pass + return bool(value) + + +@dataclass(frozen=True) +class PortfolioEnvConfig: + candidate_path: Optional[str] = None + top_k_candidates: int = 3 + max_positions: int = 2 + initial_cash: float = 1_000_000.0 + buy_fraction: float = 0.25 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + invalid_action_penalty: float = 0.001 + # Cost-aware reward shaping (Page 17 / track B): when ``> 0`` the per-step + # reward gets an *additive* turnover-cost penalty so a learned/tuned policy + # is taught to trade only when the move is worth its execution cost. The + # default ``0.0`` is a strict no-op, so the legacy NAV-change reward (and + # every existing test) is unchanged. See ``step`` for the exact formula. + turnover_penalty_lambda: float = 0.0 + seed: int = 100 + feature_columns: Optional[Tuple[str, ...]] = None + + +def synthetic_candidates() -> pd.DataFrame: + """Small deterministic fixture used by smoke commands and contract tests.""" + + base = pd.Timestamp("2025-01-03 09:00:00") + rows: List[Dict[str, Any]] = [] + symbols = ["000001", "000002", "000003"] + for t in range(8): + for rank, symbol in enumerate(symbols): + rows.append( + { + "timestamp": (base + pd.Timedelta(seconds=t)).isoformat(), + "symbol": symbol, + "condition_id": "synthetic_momentum", + "passed": True, + "rank_score": float(3 - rank + t * 0.01), + "price": float(100 + rank * 5 + t * (rank + 1)), + "feature_momentum": float(t - rank), + "feature_liquidity": float(1000 - rank * 100), + } + ) + return pd.DataFrame(rows) + + +class PortfolioEnv: + """Dependency-light portfolio RL environment over condition candidates.""" + + metadata: ClassVar[Dict[str, Any]] = {"render_modes": []} + + def __init__( + self, + config: Optional[PortfolioEnvConfig] = None, + *, + candidates: Optional[pd.DataFrame] = None, + **overrides: Any, + ) -> None: + if config is not None and overrides: + raise ValueError("Pass either config or keyword overrides, not both.") + self.config = config or PortfolioEnvConfig(**overrides) + if self.config.top_k_candidates <= 0: + raise ValueError("top_k_candidates must be positive") + if self.config.max_positions <= 0: + raise ValueError("max_positions must be positive") + if not (0 < self.config.buy_fraction <= 1): + raise ValueError("buy_fraction must be in (0, 1]") + + self.candidates = self._load_candidates(candidates) + self.feature_columns = list(self.config.feature_columns or self._infer_feature_columns(self.candidates)) + self.candidate_width = 3 + len(self.feature_columns) + self.holding_width = 4 + self.account_width = 3 + obs_width = ( + self.config.top_k_candidates * self.candidate_width + + self.config.max_positions * self.holding_width + + self.account_width + ) + self.observation_space = BoxSpace((obs_width,), dtype=np.float32) + self.action_space = DiscreteSpace(1 + self.config.top_k_candidates + self.config.max_positions) + self._rng = np.random.default_rng(self.config.seed) + self.timestamps: List[pd.Timestamp] = [] + self.current_step = 0 + self.account = PortfolioAccount( + initial_cash=self.config.initial_cash, + cost_bps=self.config.cost_bps, + slippage_bps=self.config.slippage_bps, + ) + self.last_prices: Dict[str, float] = {} + self.peak_nav = float(self.config.initial_cash) + self.invalid_actions: List[Dict[str, Any]] = [] + self.trade_log: List[Dict[str, Any]] = [] + self.nav_log: List[Dict[str, Any]] = [] + self.action_log: List[Dict[str, Any]] = [] + + def reset( + self, + *, + seed: Optional[int] = None, + options: Optional[Mapping[str, Any]] = None, + ) -> Tuple[np.ndarray, Dict[str, Any]]: + if seed is not None: + self._rng = np.random.default_rng(seed) + del options + self.timestamps = sorted(pd.Timestamp(ts) for ts in self.candidates["timestamp"].dropna().unique()) + if not self.timestamps: + raise ValueError("candidate data has no timestamps") + self.current_step = 0 + self.account = PortfolioAccount( + initial_cash=self.config.initial_cash, + cost_bps=self.config.cost_bps, + slippage_bps=self.config.slippage_bps, + ) + self.last_prices = {} + self.peak_nav = float(self.config.initial_cash) + self.invalid_actions = [] + self.trade_log = [] + self.nav_log = [] + self.action_log = [] + self._update_last_prices(self._current_candidates()) + info = self._info(event="reset") + self.nav_log.append(self._nav_row(info)) + return self._observation(), info + + def step(self, action: int) -> Tuple[np.ndarray, float, bool, bool, Dict[str, Any]]: + if not self.action_space.contains(action): + raise ValueError(f"Invalid action {action!r}; expected action < {self.action_space.n}.") + if not self.timestamps: + raise RuntimeError("Call reset() before step().") + if self.current_step >= len(self.timestamps): + raise RuntimeError("Episode is already terminated; call reset().") + + action = int(action) + candidates = self._current_candidates() + self._update_last_prices(candidates) + prices_before = self._mark_prices(candidates) + nav_before = self.account.nav(prices_before) + action_mask = self.action_mask(candidates) + invalid = not bool(action_mask[action]) + blocked_reason = "" if not invalid else self._blocked_reason(action, candidates) + fill = None + decoded = self.decode_action(action) + + if not invalid and action != ACTION_HOLD: + # T+1 fill contract: the decision uses the close at T (observation / + # `prices_before`), but the order fills at the next-bar `fill_price`. + if decoded["type"] == "buy": + row = candidates.iloc[int(decoded["slot"])] + symbol = str(row["symbol"]) + # The mask only enables fillable buy slots, so a real T+1 price + # exists; guard with the decision price purely as a type floor. + fill_price = self._fill_price_for(symbol, row) + if fill_price is None: + fill_price = float(row["price"]) + max_notional = float(self.account.cash or 0.0) / (1.0 + self.account.cost_pct) + notional = min(nav_before * float(self.config.buy_fraction), max_notional) + fill = self.account.buy( + symbol=symbol, + price=fill_price, + notional=notional, + timestamp=self._timestamp().isoformat(), + ) + elif decoded["type"] == "sell": + holdings = self._holding_symbols() + symbol = holdings[int(decoded["slot"])] + fill_price = self._fill_price_for(symbol, self._candidate_row_for(symbol, candidates)) + if fill_price is None: + # No T+1 available for the held symbol at this bar; fall back + # to the latest mark so we never fabricate a future price. + fill_price = float(prices_before[symbol]) + fill = self.account.sell( + symbol=symbol, + price=fill_price, + timestamp=self._timestamp().isoformat(), + ) + if fill: + self.trade_log.append(fill.to_dict()) + elif invalid: + event = { + "timestamp": self._timestamp().isoformat(), + "action": action, + "reason": blocked_reason, + "decoded": decoded, + } + self.invalid_actions.append(event) + + self.current_step += 1 + terminated = self.current_step >= len(self.timestamps) + next_candidates = self._current_candidates() if not terminated else candidates + self._update_last_prices(next_candidates) + prices_after = self._mark_prices(next_candidates) + nav_after = self.account.nav(prices_after) + self.peak_nav = max(self.peak_nav, nav_after) + reward = (nav_after - nav_before) / max(nav_before, FLOAT_TOLERANCE) + # Cost-aware reward shaping (opt-in, ``turnover_penalty_lambda > 0``): + # reward = Δnav_pct − λ · (execution_cost_this_step / nav_before) + # The execution cost is the broker cost already booked on this step's + # fill (T+1 fill ``cost`` from the account), normalised to the same + # NAV-fraction scale as the Δnav reward so λ is unit-free. HOLD / no + # fill incurs no penalty. λ == 0 leaves the legacy reward untouched. + turnover_lambda = float(self.config.turnover_penalty_lambda) + if turnover_lambda > 0.0 and fill is not None: + fill_cost = float(getattr(fill, "cost", 0.0) or 0.0) + reward -= turnover_lambda * (fill_cost / max(nav_before, FLOAT_TOLERANCE)) + if invalid: + reward -= float(self.config.invalid_action_penalty) + info = self._info( + event="step", + action=action, + decoded=decoded, + invalid_action=invalid, + blocked_reason=blocked_reason, + reward=reward, + nav_before=nav_before, + nav_after=nav_after, + terminated=terminated, + ) + self.nav_log.append(self._nav_row(info)) + self.action_log.append( + { + "timestamp": info["timestamp"], + "action": action, + "action_type": decoded["type"], + "slot": decoded.get("slot"), + "invalid_action": invalid, + "blocked_reason": blocked_reason, + "reward": float(reward), + "nav_after": nav_after, + } + ) + return self._observation(), float(reward), terminated, False, info + + def decode_action(self, action: int) -> Dict[str, Any]: + if action == ACTION_HOLD: + return {"type": "hold"} + buy_end = self.config.top_k_candidates + if 1 <= action <= buy_end: + return {"type": "buy", "slot": action - 1} + return {"type": "sell", "slot": action - buy_end - 1} + + def action_mask(self, candidates: Optional[pd.DataFrame] = None) -> np.ndarray: + candidates = self._current_candidates() if candidates is None else candidates + mask = np.zeros(self.action_space.n, dtype=np.int8) + mask[ACTION_HOLD] = 1 + can_add_position = len(self.account.positions) < self.config.max_positions + buy_cash = float(self.account.cash or 0.0) > FLOAT_TOLERANCE + for slot in range(self.config.top_k_candidates): + if slot < len(candidates): + row = candidates.iloc[slot] + symbol = str(row["symbol"]) + # Only enable a buy slot that has a real T+1 fill price; an + # unfillable candidate (last bar, no next bar) cannot execute. + fillable = self._fill_price_for(symbol, row) is not None + if can_add_position and buy_cash and fillable and symbol not in self.account.positions: + mask[1 + slot] = 1 + holdings = self._holding_symbols() + sell_offset = 1 + self.config.top_k_candidates + for slot in range(min(len(holdings), self.config.max_positions)): + mask[sell_offset + slot] = 1 + return mask + + def _load_candidates(self, candidates: Optional[pd.DataFrame]) -> pd.DataFrame: + if candidates is None: + if self.config.candidate_path: + candidates = read_candidates_csv(self.config.candidate_path) + else: + candidates = synthetic_candidates() + required = {"timestamp", "symbol", "rank_score", "price"} + missing = sorted(required - set(candidates.columns)) + if missing: + raise ValueError(f"Portfolio candidates missing required columns: {missing}") + frame = candidates.copy() + frame["timestamp"] = pd.to_datetime(frame["timestamp"], errors="coerce") + # Canonical symbol form (6-digit zero-pad for all-digit Korean codes; + # non-numeric synthetic symbols left unchanged) so the holding key, + # sell-lookup and mask all match regardless of whether candidates came + # from a CSV (int-stripped) or in-memory. + frame["symbol"] = normalize_symbol_series(frame["symbol"]) + frame["rank_score"] = pd.to_numeric(frame["rank_score"], errors="coerce").fillna(0.0) + frame["price"] = pd.to_numeric(frame["price"], errors="coerce") + # T+1 fill contract (Page 9/10): `price` is the decision-bar close at T; + # trades fill at `fill_price` (the next-bar close). Real candidate CSVs + # carry `fill_price`/`fillable`; legacy/synthetic frames lack them, so we + # fall back to `price` with a one-time warning and mark every row fillable + # to preserve backward compatibility (no lookahead is introduced because + # the synthetic fixture has no T+1 distinction). + if "fill_price" in frame.columns: + frame["fill_price"] = pd.to_numeric(frame["fill_price"], errors="coerce") + if "fillable" in frame.columns: + frame["fillable"] = frame["fillable"].map(_as_bool).astype(bool) + else: + frame["fillable"] = frame["fill_price"].notna() + # Unfillable rows have no real T+1 price; never fabricate one. + frame.loc[~frame["fillable"], "fill_price"] = np.nan + else: + warnings.warn( + "Portfolio candidates lack a 'fill_price' column; falling back to " + "decision-bar 'price' for fills (no T+1 contract). Provide a Page 9 " + "candidate CSV with 'fill_price' for the real T+1 fill timing.", + RuntimeWarning, + stacklevel=2, + ) + frame["fill_price"] = frame["price"] + frame["fillable"] = frame["price"] > 0 + frame = frame.dropna(subset=["timestamp", "symbol", "price"]) + frame = frame[frame["price"] > 0].sort_values(["timestamp", "rank_score", "symbol"], ascending=[True, False, True]) + if frame.empty: + raise ValueError("Portfolio candidates contain no valid rows") + return frame.reset_index(drop=True) + + def _infer_feature_columns(self, frame: pd.DataFrame) -> Tuple[str, ...]: + features = [column for column in frame.columns if column.startswith("feature_")] + if "rank_score" not in features: + features.insert(0, "rank_score") + return tuple(features) + + def _timestamp(self) -> pd.Timestamp: + idx = min(self.current_step, len(self.timestamps) - 1) + return self.timestamps[idx] + + def _current_candidates(self) -> pd.DataFrame: + if not self.timestamps: + return pd.DataFrame(columns=self.candidates.columns) + timestamp = self._timestamp() + rows = self.candidates[self.candidates["timestamp"] == timestamp] + return rows.sort_values(["rank_score", "symbol"], ascending=[False, True]).head(self.config.top_k_candidates).reset_index(drop=True) + + def _candidate_row_for(self, symbol: str, candidates: pd.DataFrame) -> Optional[pd.Series]: + """Return the current-timestamp candidate row for ``symbol`` if present.""" + + if candidates.empty: + return None + matches = candidates[candidates["symbol"].astype(str) == str(symbol)] + if matches.empty: + return None + return matches.iloc[0] + + def _fill_price_for(self, symbol: str, row: Optional[pd.Series]) -> Optional[float]: + """T+1 fill price for ``symbol`` from a candidate ``row``. + + Returns the row's ``fill_price`` when present and fillable, otherwise + ``None`` so callers can fall back to a mark price without fabricating a + future bar. + """ + + del symbol # kept for call-site readability; lookup is row-scoped + if row is None: + return None + if "fillable" in row.index and not _as_bool(row.get("fillable", True)): + return None + fill_value = row.get("fill_price") if "fill_price" in row.index else None + if fill_value is None or pd.isna(fill_value): + return None + fill_price = float(fill_value) + return fill_price if fill_price > 0 else None + + def _update_last_prices(self, candidates: pd.DataFrame) -> None: + for _, row in candidates.iterrows(): + self.last_prices[str(row["symbol"])] = float(row["price"]) + + def _mark_prices(self, candidates: pd.DataFrame) -> Dict[str, float]: + prices = dict(self.last_prices) + for _, row in candidates.iterrows(): + prices[str(row["symbol"])] = float(row["price"]) + for symbol, position in self.account.positions.items(): + prices.setdefault(symbol, position.average_price) + return prices + + def _holding_symbols(self) -> List[str]: + return sorted(self.account.positions) + + def _blocked_reason(self, action: int, candidates: pd.DataFrame) -> str: + decoded = self.decode_action(action) + if decoded["type"] == "buy": + slot = int(decoded["slot"]) + if slot >= len(candidates): + return "candidate_padding_slot" + row = candidates.iloc[slot] + symbol = str(row["symbol"]) + if symbol in self.account.positions: + return "already_holding_symbol" + if len(self.account.positions) >= self.config.max_positions: + return "max_positions_reached" + if float(self.account.cash or 0.0) <= FLOAT_TOLERANCE: + return "insufficient_cash" + if self._fill_price_for(symbol, row) is None: + return "unfillable_no_t1" + if decoded["type"] == "sell": + slot = int(decoded["slot"]) + if slot >= len(self._holding_symbols()): + return "holding_padding_slot" + return "masked_action" + + def _observation(self) -> np.ndarray: + candidates = self._current_candidates() + prices = self._mark_prices(candidates) + candidate_values: List[float] = [] + for slot in range(self.config.top_k_candidates): + if slot < len(candidates): + row = candidates.iloc[slot] + price = float(row["price"]) + candidate_values.extend([1.0, price / 100_000.0, float(row["rank_score"])]) + for column in self.feature_columns: + candidate_values.append(_float_or_zero(row.get(column, 0.0))) + else: + candidate_values.extend([0.0] * self.candidate_width) + + holding_values: List[float] = [] + nav = self.account.nav(prices) + for slot in range(self.config.max_positions): + holdings = self._holding_symbols() + if slot < len(holdings): + symbol = holdings[slot] + position = self.account.positions[symbol] + price = float(prices[symbol]) + market_value = position.market_value(price) + unrealized = (price - position.average_price) / position.average_price if position.average_price else 0.0 + holding_values.extend([1.0, position.quantity, unrealized, market_value / max(nav, FLOAT_TOLERANCE)]) + else: + holding_values.extend([0.0] * self.holding_width) + account_values = [ + float(self.account.cash or 0.0) / float(self.config.initial_cash), + nav / float(self.config.initial_cash), + (nav / max(self.peak_nav, FLOAT_TOLERANCE)) - 1.0, + ] + obs = np.asarray(candidate_values + holding_values + account_values, dtype=np.float32) + return np.nan_to_num(obs, nan=0.0, posinf=10.0, neginf=-10.0) + + def _info(self, *, event: str, **extra: Any) -> Dict[str, Any]: + candidates = self._current_candidates() + prices = self._mark_prices(candidates) + candidate_mask = [1 if slot < len(candidates) else 0 for slot in range(self.config.top_k_candidates)] + holding_count = len(self._holding_symbols()) + holding_mask = [1 if slot < holding_count else 0 for slot in range(self.config.max_positions)] + nav = self.account.nav(prices) + info: Dict[str, Any] = { + "event": event, + "timestamp": self._timestamp().isoformat(), + "current_step": int(self.current_step), + "config": asdict(self.config), + "candidate_mask": candidate_mask, + "holding_mask": holding_mask, + "action_mask": self.action_mask(candidates).tolist(), + "nav": nav, + "cash": float(self.account.cash or 0.0), + "positions": self.account.snapshot(prices)["positions"], + "trade_count": int(self.account.trade_count), + "invalid_action_count": len(self.invalid_actions), + } + info.update(extra) + return info + + def _nav_row(self, info: Mapping[str, Any]) -> Dict[str, Any]: + return { + "timestamp": info["timestamp"], + "step": info["current_step"], + "nav": info["nav"], + "cash": info["cash"], + "position_count": len(info["positions"]), + } diff --git a/stom_rl/portfolio_run_publish.py b/stom_rl/portfolio_run_publish.py new file mode 100644 index 000000000..57914fb26 --- /dev/null +++ b/stom_rl/portfolio_run_publish.py @@ -0,0 +1,170 @@ +"""Publish a consolidated portfolio run into ``webui/rl_runs``. + +Pages 10/11/12 emit portfolio artifacts (train smoke, expanding-window walk +forward, read-only paper replay) under ``.omx/artifacts``. The v2 dashboard +serves runs strictly from ``webui/rl_runs`` through the existing ``/api/rl/*`` +routes, so this module copies the *already produced* portfolio artifacts into a +single run directory that the read-only dashboard recognises. + +It does not run any model, broker, or order code. It only reads existing CSV / +JSON artifacts and re-emits them under the run-directory layout the dashboard +understands, plus a ``portfolio_paper_summary.json`` signature file that marks +the directory as a portfolio run. +""" + +from __future__ import annotations + +import argparse +import json +import shutil +from pathlib import Path +from typing import Any, Dict, Mapping, Optional + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_RUN_NAME = "stom_1s_2025_portfolio_paper" +DEFAULT_OUTPUT_DIR = REPO_ROOT / "webui" / "rl_runs" / DEFAULT_RUN_NAME + +DEFAULT_PAPER_DIR = REPO_ROOT / ".omx" / "artifacts" / "page12_paper" +DEFAULT_WALK_FORWARD_DIR = REPO_ROOT / ".omx" / "artifacts" / "page11_walk_forward" +DEFAULT_TRAIN_DIR = REPO_ROOT / ".omx" / "artifacts" / "page10_train" + +# Signature file the dashboard uses to detect a portfolio paper run. +SIGNATURE_FILE = "portfolio_paper_summary.json" + + +def _read_json(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _copy_if_exists(src: Path, dst: Path) -> bool: + if src.is_file(): + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(src, dst) + return True + return False + + +def publish_portfolio_run( + *, + output_dir: Path = DEFAULT_OUTPUT_DIR, + paper_dir: Path = DEFAULT_PAPER_DIR, + walk_forward_dir: Path = DEFAULT_WALK_FORWARD_DIR, + train_dir: Path = DEFAULT_TRAIN_DIR, +) -> Dict[str, Any]: + """Consolidate portfolio artifacts into ``output_dir`` for the dashboard. + + Returns the signature payload that was written. Raises ``FileNotFoundError`` + if the required paper-replay summary is missing. + """ + + output_dir = Path(output_dir) + paper_summary_path = Path(paper_dir) / "paper_replay_summary.json" + if not paper_summary_path.is_file(): + raise FileNotFoundError( + f"paper_replay_summary.json not found at {paper_summary_path}. " + "Run the Page 12 paper replay first." + ) + + output_dir.mkdir(parents=True, exist_ok=True) + + paper_summary = _read_json(paper_summary_path) + copied: Dict[str, bool] = {} + + # Paper replay artifacts (NAV curve, decisions/positions, risk + blocked logs). + copied["nav_csv"] = _copy_if_exists(Path(paper_dir) / "nav.csv", output_dir / "nav.csv") + copied["decisions_csv"] = _copy_if_exists( + Path(paper_dir) / "decisions.csv", output_dir / "decisions.csv" + ) + copied["candidates_csv"] = _copy_if_exists( + Path(paper_dir) / "candidates.csv", output_dir / "candidates.csv" + ) + copied["risk_triggers_json"] = _copy_if_exists( + Path(paper_dir) / "risk_triggers.json", output_dir / "risk_triggers.json" + ) + copied["blocked_actions_json"] = _copy_if_exists( + Path(paper_dir) / "blocked_actions.json", output_dir / "blocked_actions.json" + ) + + # Walk-forward fold summary (Page 11). + wf_report_path = Path(walk_forward_dir) / "portfolio_walk_forward_report.json" + copied["walk_forward_folds_csv"] = _copy_if_exists( + Path(walk_forward_dir) / "portfolio_walk_forward_folds.csv", + output_dir / "portfolio_walk_forward_folds.csv", + ) + copied["walk_forward_report_json"] = _copy_if_exists( + wf_report_path, output_dir / "portfolio_walk_forward_report.json" + ) + + # Train smoke trades (Page 10) — gives the trades table a real source. + copied["trades_csv"] = _copy_if_exists(Path(train_dir) / "trades.csv", output_dir / "trades.csv") + + # Live step events (Page 14) — the train smoke now emits per-step + # ``rl_live_events.jsonl`` (NAV mapped to equity) so the dashboard's + # realtime follow/replay view streams the portfolio run like a live + # training session. The existing ``/table/events`` route serves whichever + # of these files is present, so copying them is all the wiring needed. + copied["live_events_jsonl"] = _copy_if_exists( + Path(train_dir) / "rl_live_events.jsonl", output_dir / "rl_live_events.jsonl" + ) + copied["live_summary_json"] = _copy_if_exists( + Path(train_dir) / "rl_live_summary.json", output_dir / "rl_live_summary.json" + ) + + walk_forward_summary: Dict[str, Any] = {} + if wf_report_path.is_file(): + try: + walk_forward_summary = dict(_read_json(wf_report_path).get("summary", {})) + except (ValueError, OSError): + walk_forward_summary = {} + + summary = dict(paper_summary.get("summary", {})) + summary.setdefault("read_only", True) + summary["walk_forward_n_folds"] = walk_forward_summary.get("n_folds") + summary["walk_forward_best_policy"] = walk_forward_summary.get("best_policy_by_return") + summary["walk_forward_holdout"] = walk_forward_summary.get("holdout") + + signature: Dict[str, Any] = { + "mode": "stom_rl_portfolio_paper_run", + "run_name": output_dir.name, + "config": paper_summary.get("config", {}), + "summary": summary, + "walk_forward_summary": walk_forward_summary, + "sources": { + "paper_dir": str(Path(paper_dir)), + "walk_forward_dir": str(Path(walk_forward_dir)), + "train_dir": str(Path(train_dir)), + }, + "copied_artifacts": copied, + } + _write_json(output_dir / SIGNATURE_FILE, signature) + return signature + + +def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR)) + parser.add_argument("--paper-dir", default=str(DEFAULT_PAPER_DIR)) + parser.add_argument("--walk-forward-dir", default=str(DEFAULT_WALK_FORWARD_DIR)) + parser.add_argument("--train-dir", default=str(DEFAULT_TRAIN_DIR)) + return parser.parse_args(argv) + + +def main(argv: Optional[list[str]] = None) -> int: + args = _parse_args(argv) + signature = publish_portfolio_run( + output_dir=Path(args.output_dir), + paper_dir=Path(args.paper_dir), + walk_forward_dir=Path(args.walk_forward_dir), + train_dir=Path(args.train_dir), + ) + print(json.dumps(signature, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/portfolio_sb3_adapter.py b/stom_rl/portfolio_sb3_adapter.py new file mode 100644 index 000000000..b6e5b72ac --- /dev/null +++ b/stom_rl/portfolio_sb3_adapter.py @@ -0,0 +1,138 @@ +"""Gymnasium/Stable-Baselines3 adapter for the STOM portfolio env. + +:class:`PortfolioEnv` stays dependency-light (it uses the in-house +``BoxSpace``/``DiscreteSpace`` and already returns the Gymnasium 5-tuple from +``step``). This wrapper owns the real Gymnasium spaces/inheritance that +Stable-Baselines3 requires, while delegating every trading decision to the base +env. It is a *faithful passthrough*: no extra observation normalization is +applied beyond what ``PortfolioEnv._observation`` already does (price/100k, +cash & NAV / initial_cash, ``nan_to_num`` clipping). + +It also exposes ``action_masks()`` (plural) wrapping ``PortfolioEnv.action_mask`` +(singular) so the gated sb3-contrib MaskablePPO path in Stage B can read the +per-step action mask. Plain PPO/DQN ignore it. ``sb3-contrib`` is intentionally +NOT a dependency here. +""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Tuple + +import gymnasium as gym +import numpy as np +from gymnasium import spaces + +from .portfolio_env import PortfolioEnv, PortfolioEnvConfig + + +class PortfolioSb3GymEnv(gym.Env): + """Thin Gymnasium wrapper around :class:`PortfolioEnv`. + + The wrapped env exposes the SAME logical spaces as the underlying env: a + ``Discrete(1 + top_k + max_positions)`` action space and a ``Box`` of the + underlying observation width. ``reset``/``step`` are pure passthroughs that + return the Gymnasium 5-tuple ``(obs, reward, terminated, truncated, info)``. + """ + + metadata = {"render_modes": []} + + def __init__( + self, + config: Optional[PortfolioEnvConfig] = None, + *, + candidates: Optional[Any] = None, + **overrides: Any, + ) -> None: + if config is not None and overrides: + raise ValueError("Pass either config or keyword overrides, not both.") + self.raw_env = PortfolioEnv(config, candidates=candidates, **overrides) + self.action_space = spaces.Discrete(self.raw_env.action_space.n) + self.observation_space = spaces.Box( + low=-np.inf, + high=np.inf, + shape=self.raw_env.observation_space.shape, + dtype=np.float32, + ) + + @property + def config(self) -> PortfolioEnvConfig: + return self.raw_env.config + + def reset( + self, + *, + seed: Optional[int] = None, + options: Optional[Mapping[str, Any]] = None, + ) -> Tuple[np.ndarray, dict[str, Any]]: + super().reset(seed=seed) + observation, info = self.raw_env.reset(seed=seed, options=options) + return np.asarray(observation, dtype=np.float32), dict(info) + + def step(self, action: Any) -> Tuple[np.ndarray, float, bool, bool, dict[str, Any]]: + action_int = int(np.asarray(action).item()) + observation, reward, terminated, truncated, info = self.raw_env.step(action_int) + return ( + np.asarray(observation, dtype=np.float32), + float(reward), + bool(terminated), + bool(truncated), + dict(info), + ) + + def action_masks(self) -> np.ndarray: + """Per-step action mask for sb3-contrib MaskablePPO (Stage B, gated). + + Wraps the underlying singular ``action_mask()`` and returns an int8 + array of length ``action_space.n``. Plain PPO/DQN ignore this method. + """ + + return np.asarray(self.raw_env.action_mask(), dtype=np.int8) + + def render(self) -> None: + return None + + def close(self) -> None: + return None + + +def make_portfolio_sb3_env( + candidate_path: Optional[str] = None, + *, + candidates: Optional[Any] = None, + top_k_candidates: int = 3, + max_positions: int = 2, + initial_cash: float = 1_000_000.0, + buy_fraction: float = 0.25, + cost_bps: float = 25.0, + slippage_bps: float = 0.0, + invalid_action_penalty: float = 0.001, + turnover_penalty_lambda: float = 0.0, + seed: int = 100, + feature_columns: Optional[Tuple[str, ...]] = None, +) -> PortfolioSb3GymEnv: + """Create a Gymnasium-compatible STOM portfolio env for SB3 training/eval. + + Mirrors :func:`stom_rl.sb3_adapter.make_sb3_env`. Pass an in-memory + ``candidates`` frame (e.g. ``synthetic_candidates()``) or a ``candidate_path`` + CSV; the factory threads the remaining knobs through ``PortfolioEnvConfig``. + """ + + return PortfolioSb3GymEnv( + PortfolioEnvConfig( + candidate_path=candidate_path, + top_k_candidates=top_k_candidates, + max_positions=max_positions, + initial_cash=initial_cash, + buy_fraction=buy_fraction, + cost_bps=cost_bps, + slippage_bps=slippage_bps, + invalid_action_penalty=invalid_action_penalty, + turnover_penalty_lambda=turnover_penalty_lambda, + seed=seed, + feature_columns=feature_columns, + ), + candidates=candidates, + ) + + +__all__ = ["PortfolioSb3GymEnv", "make_portfolio_sb3_env"] diff --git a/stom_rl/portfolio_sb3_train.py b/stom_rl/portfolio_sb3_train.py new file mode 100644 index 000000000..db123013e --- /dev/null +++ b/stom_rl/portfolio_sb3_train.py @@ -0,0 +1,802 @@ +"""Deterministic SB3 training for the STOM portfolio env (Stage B). + +This is the Stage-B trainer that turns the Stage-A Gymnasium adapter +(:func:`stom_rl.portfolio_sb3_adapter.make_portfolio_sb3_env`) into a *trained*, +deterministic SB3 ``PPO``/``DQN`` policy, then exposes that policy as a +``PolicyFn`` for the ``portfolio_walk_forward._fit_policy`` seam (the +``trained_ppo`` baseline). + +REUSED from ``stom_rl.sb3_smoke`` (cited): + * ``_sb3_imports`` (:79) -> :func:`_sb3_imports` here (same import shim). + * ``_torch_runtime`` (:86) -> determinism pins + runtime probe. + * ``_check_env`` (:111) -> the ``check_env`` invocation pattern. + * ``_train_model`` (:130) -> the PPO/DQN construction + ``model.learn`` loop + and the bounded ``n_steps``/``batch_size`` clamping. + * ``_evaluate_model`` (:354) -> the ``model.predict(deterministic=True)`` eval + pattern (here folded into the masked obs-decode ``PolicyFn``). + +NET-NEW here (not in ``sb3_smoke``): + * Determinism HARDENING: ``torch.use_deterministic_algorithms(True)``, + ``torch.set_num_threads(1)``, ``device="cpu"``, all RNGs seeded, plus a + reproducibility assertion within an explicit ``atol=1e-6, rtol=1e-5`` on + eval metrics (sb3_smoke never asserts byte/metric reproducibility). + * A *portfolio* obs-decode ``PolicyFn`` (:func:`make_trained_policy_fn`) that + respects the multi-asset ``action_mask`` by selecting the best *valid* + action — sb3_smoke's single-symbol eval has no portfolio masking. + * An eval **invalid-action rate** measurement feeding the MaskablePPO trigger + (penalty-PPO-first; ``sb3-contrib`` is NOT installed here — only the trigger + is recorded per the plan's Option-C decision). + +Determinism is enforced, not hoped for: SB3+torch is not bit-reproducible by +default, so the pins above are applied at import-of-torch time inside +:func:`apply_determinism`. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple + +import numpy as np + +from .portfolio_env import ACTION_HOLD, PortfolioEnv +from .portfolio_sb3_adapter import PortfolioSb3GymEnv, make_portfolio_sb3_env +from .rl_events import RlLiveEventWriter, summarize_live_event_file + + +# Live-event semantics for the SB3 *training loop* (Story A): the trainer streams +# per-rollout telemetry into the SAME ``rl_live_events.jsonl`` schema the +# deterministic ``portfolio_train`` smoke emits, so the dashboard's realtime +# follow/replay view watches the model learn. ``algorithm`` is the per-algorithm +# label the dashboard groups by; ``phase`` is always "train" for this stream. +PORTFOLIO_TRAIN_LIVE_ALGORITHM = {"ppo": "portfolio_ppo", "dqn": "portfolio_dqn"} +PORTFOLIO_TRAIN_LIVE_PHASE = "train" +# Dashboard signature file written into the train run dir so the existing +# ``iter_run_dirs``/``_detect_artifact_type`` recognises the directory as a run +# and serves the ``rl_live_events.jsonl`` through ``/table/events``. +SB3_SMOKE_SIGNATURE_FILE = "sb3_smoke_summary.json" + + +# Stage-B trigger threshold (Section 1 Decision, Option C "penalty-PPO-first"): +# adopt sb3-contrib MaskablePPO ONLY if the eval invalid-action rate exceeds this +# OR training reward fails to beat no_trade across >=2 seeds. We never install +# sb3-contrib here; we only RECORD whether the trigger fired. +MASKABLE_PPO_INVALID_ACTION_TRIGGER: float = 0.05 + +DEFAULT_DEEP_RL_TRAIN_OUTPUT_DIR = Path(".omx") / "artifacts" / "deep_rl" / "stageB_train" + + +@dataclass(frozen=True) +class PortfolioSb3TrainConfig: + """Bounded, deterministic SB3 training config for the portfolio env.""" + + candidate_path: Optional[str] = None + output_dir: str = str(DEFAULT_DEEP_RL_TRAIN_OUTPUT_DIR) + algorithm: str = "ppo" # "ppo" | "dqn" + total_timesteps: int = 5_000 + top_k_candidates: int = 3 + max_positions: int = 2 + initial_cash: float = 1_000_000.0 + buy_fraction: float = 0.25 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + invalid_action_penalty: float = 0.001 + # Cost-aware reward: must be > 0 for Stage B (the env teaches the policy to + # trade only when worth the execution cost). + turnover_penalty_lambda: float = 1.0 + seed: int = 100 + device: str = "cpu" # pinned for determinism (see apply_determinism) + ppo_n_steps: int = 256 + ppo_batch_size: int = 64 + ppo_n_epochs: int = 4 + dqn_learning_starts: int = 64 + dqn_buffer_size: int = 4_096 + dqn_batch_size: int = 64 + max_eval_steps: int = 64 + write_artifacts: bool = True + # Story A: stream per-rollout training telemetry into ``rl_live_events.jsonl`` + # so the dashboard can watch the model learn in real time. Default-on, but + # write-only (a pure side-effect): the callback never touches the env, RNG, or + # the gradient step, so determinism (V4) is preserved. Events are only written + # when this flag is on AND ``write_artifacts`` yields a real output path. + write_training_events: bool = True + + +# --------------------------------------------------------------------------- # +# REUSE: sb3_smoke import shim + runtime probe. +# --------------------------------------------------------------------------- # +def _sb3_imports(): + """Mirror ``sb3_smoke._sb3_imports`` (:79): lazy SB3 import shim.""" + + from stable_baselines3 import DQN, PPO + from stable_baselines3.common.env_checker import check_env + + return DQN, PPO, check_env + + +def apply_determinism(seed: int, *, device: str = "cpu") -> Dict[str, Any]: + """Pin every RNG + torch flag so the same seed reproduces eval metrics. + + NET-NEW vs sb3_smoke (which never pins these). Applies, in order: + * ``random.seed`` / ``np.random.seed`` / ``torch.manual_seed``. + * ``torch.use_deterministic_algorithms(True)`` (raises on nondeterministic + kernels rather than silently diverging). + * ``torch.set_num_threads(1)`` (multi-thread reductions are nondeterministic). + * ``device="cpu"`` for the determinism rerun (CUDA is not bit-reproducible). + + Returns the runtime probe (mirrors ``sb3_smoke._torch_runtime`` :86) plus the + pins applied, for evidence logging. + """ + + import random as _random + + import torch + + _random.seed(int(seed)) + np.random.seed(int(seed)) + torch.manual_seed(int(seed)) + if torch.cuda.is_available(): # pragma: no cover - CPU CI has no CUDA + torch.cuda.manual_seed_all(int(seed)) + torch.use_deterministic_algorithms(True) + torch.set_num_threads(1) + return { + "torch_version": torch.__version__, + "cuda_available": bool(torch.cuda.is_available()), + "device_pinned": device, + "use_deterministic_algorithms": True, + "num_threads": 1, + "seed": int(seed), + } + + +def _bounded(value: int, *, lo: int, hi: int) -> int: + return max(lo, min(int(value), hi)) + + +def _make_training_callback( + algorithm: str, + *, + event_writer: RlLiveEventWriter, +) -> Any: + """Build an SB3 ``BaseCallback`` that streams per-rollout training telemetry. + + Story A (NET-NEW): ``model.learn`` runs silently by default. This callback + reads the rollout stats SB3 already maintains and appends one ``RlLiveEvent`` + per rollout (and a final flush at training end) so the dashboard's realtime + follow/replay view watches the model learn — the "watch it learn" experience. + + Stats emitted per event: + * ``global_step`` = ``self.num_timesteps`` (monotonic across rollouts). + * ``reward`` = mean episode reward over ``ep_info_buffer`` (the rolling + window SB3 logs as ``rollout/ep_rew_mean``); falls back to the latest + logged value, then 0.0 if no episode has finished yet. + * ``loss`` = the most recent ``train/loss`` (PPO) / + ``train/loss``-equivalent from ``logger.name_to_value`` if the optimiser + has stepped, else ``None`` (first rollout hasn't trained yet). + * ``equity`` = OMITTED — no cheap mid-train NAV is available without a + full eval rollout, which would perturb determinism; left ``None``. + * ``info`` = ``{timesteps, iterations, ep_count}`` for context. + + The callback is WRITE-ONLY: it never reads/writes the env, RNG, or gradient, + so a fixed seed still reproduces eval metrics (determinism V4 preserved). + """ + + from stable_baselines3.common.callbacks import BaseCallback + + live_algorithm = PORTFOLIO_TRAIN_LIVE_ALGORITHM.get( + algorithm.lower(), f"portfolio_{algorithm.lower()}" + ) + + class RlLiveEventTrainingCallback(BaseCallback): + """Append-only RL live-event emitter driven by SB3 rollout hooks.""" + + def __init__(self) -> None: + super().__init__(verbose=0) + self._iterations = 0 + + def _mean_episode_reward(self) -> Optional[float]: + buffer = getattr(self.model, "ep_info_buffer", None) + if buffer: + rewards = [ep.get("r") for ep in buffer if ep.get("r") is not None] + if rewards: + return float(np.mean(rewards)) + logged = self.logger.name_to_value.get("rollout/ep_rew_mean") if self.logger else None + return float(logged) if logged is not None else None + + def _latest_loss(self) -> Optional[float]: + if not self.logger: + return None + values = self.logger.name_to_value + for key in ("train/loss", "train/value_loss", "train/policy_gradient_loss"): + value = values.get(key) + if value is not None: + try: + candidate = float(value) + except (TypeError, ValueError): + continue + if np.isfinite(candidate): + return candidate + return None + + def _ep_count(self) -> int: + buffer = getattr(self.model, "ep_info_buffer", None) + return int(len(buffer)) if buffer else 0 + + def _emit(self) -> None: + reward = self._mean_episode_reward() + event_writer.write_step( + algorithm=live_algorithm, + phase=PORTFOLIO_TRAIN_LIVE_PHASE, + global_step=int(self.num_timesteps), + reward=reward if reward is not None else 0.0, + loss=self._latest_loss(), + source="portfolio_sb3_train", + info={ + "timesteps": int(self.num_timesteps), + "iterations": int(self._iterations), + "ep_count": self._ep_count(), + }, + ) + + def _on_step(self) -> bool: # noqa: D401 - SB3 hook + return True + + def _on_rollout_end(self) -> None: # noqa: D401 - SB3 hook + self._iterations += 1 + self._emit() + + def _on_training_end(self) -> None: # noqa: D401 - SB3 hook + # Final flush so short runs (no completed rollout boundary) still + # produce at least one terminal event carrying the last stats. + self._emit() + + return RlLiveEventTrainingCallback() + + +def _make_train_env(config: PortfolioSb3TrainConfig) -> PortfolioSb3GymEnv: + """Build the Stage-A Gym env for training (cost-aware reward via λ>0).""" + + return make_portfolio_sb3_env( + candidate_path=config.candidate_path, + top_k_candidates=config.top_k_candidates, + max_positions=config.max_positions, + initial_cash=config.initial_cash, + buy_fraction=config.buy_fraction, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + invalid_action_penalty=config.invalid_action_penalty, + turnover_penalty_lambda=config.turnover_penalty_lambda, + seed=config.seed, + ) + + +def check_train_env(config: PortfolioSb3TrainConfig) -> Dict[str, Any]: + """REUSE: sb3_smoke ``_check_env`` (:111) pattern on the portfolio adapter.""" + + _, _, check_env = _sb3_imports() + env = _make_train_env(config) + try: + check_env(env, warn=True, skip_render_check=True) + return { + "passed": True, + "observation_space": str(env.observation_space), + "action_space": str(env.action_space), + } + finally: + env.close() + + +def train_portfolio_model( + config: PortfolioSb3TrainConfig, + *, + live_events_path: Optional[Path] = None, +) -> Tuple[Any, Dict[str, Any]]: + """Train a deterministic SB3 PPO/DQN model on the Stage-A env. + + REUSE: the PPO/DQN construction + bounded ``n_steps``/``batch_size`` clamping + and the ``model.learn`` loop mirror ``sb3_smoke._train_model`` (:130). + NET-NEW: determinism pins applied BEFORE model construction; ``device`` pinned. + + Story A: when ``live_events_path`` is given AND ``config.write_training_events`` + is on, a write-only ``RlLiveEventTrainingCallback`` streams per-rollout + telemetry into ``live_events_path`` during ``model.learn``. The callback is a + pure side-effect (no env/RNG/gradient interaction), so determinism (V4) holds. + Passing no path (the default, used by ``assert_reproducible``) emits nothing. + """ + + runtime = apply_determinism(config.seed, device=config.device) + DQN, PPO, _ = _sb3_imports() + env = _make_train_env(config) + policy_kwargs = {"net_arch": [64, 32]} + algorithm = config.algorithm.lower() + try: + if algorithm == "dqn": + learning_starts = _bounded( + config.dqn_learning_starts, lo=1, hi=max(1, config.total_timesteps // 4) + ) + model = DQN( + "MlpPolicy", + env, + seed=config.seed, + device=config.device, + verbose=0, + learning_starts=learning_starts, + buffer_size=max(int(config.dqn_buffer_size), int(config.total_timesteps), 64), + batch_size=_bounded(config.dqn_batch_size, lo=2, hi=max(2, config.total_timesteps)), + train_freq=4, + gradient_steps=1, + target_update_interval=64, + exploration_fraction=0.4, + exploration_final_eps=0.05, + policy_kwargs=policy_kwargs, + ) + elif algorithm == "ppo": + n_steps = _bounded(config.ppo_n_steps, lo=8, hi=max(8, config.total_timesteps)) + model = PPO( + "MlpPolicy", + env, + seed=config.seed, + device=config.device, + verbose=0, + n_steps=n_steps, + batch_size=_bounded(config.ppo_batch_size, lo=2, hi=n_steps), + n_epochs=max(1, int(config.ppo_n_epochs)), + policy_kwargs=policy_kwargs, + ) + else: + raise ValueError(f"Unknown algorithm: {config.algorithm!r}; expected 'ppo' or 'dqn'.") + + callback = None + if config.write_training_events and live_events_path is not None: + event_writer = RlLiveEventWriter(live_events_path, run_id=Path(live_events_path).parent.name) + event_writer.reset() + callback = _make_training_callback(algorithm, event_writer=event_writer) + + model.learn( + total_timesteps=int(config.total_timesteps), + progress_bar=False, + callback=callback, + ) + return model, runtime + finally: + env.close() + + +def _predict_action(model: Any, observation: np.ndarray) -> int: + """REUSE: sb3_smoke ``model.predict(deterministic=True)`` (:354) pattern.""" + + action, _ = model.predict(observation, deterministic=True) + return int(np.asarray(action).item()) + + +def _best_valid_action(predicted: int, mask: Sequence[int]) -> int: + """Pick the model's action if valid, else the best valid fallback. + + NET-NEW (no sb3_smoke analog): the portfolio env masks invalid actions, so a + plain (non-Maskable) PPO/DQN can emit a masked action. Penalty-PPO learns to + avoid them via ``invalid_action_penalty``, but at eval we still must NOT + execute an invalid action. Fallback order is deterministic: prefer the + predicted action, else the lowest-index valid non-HOLD action, else HOLD. + """ + + if 0 <= predicted < len(mask) and mask[predicted]: + return predicted + for action in range(1, len(mask)): + if mask[action]: + return action + return ACTION_HOLD + + +def make_trained_policy_fn(model: Any) -> Callable[[PortfolioEnv, Mapping[str, Any]], int]: + """Wrap a trained SB3 model as a ``portfolio_walk_forward.PolicyFn``. + + The returned closure decodes the *current* env observation, runs the model + deterministically, and maps the prediction onto the best *valid* action via + the per-step ``action_mask`` from ``info``. This is the obs-decode bridge the + ``_fit_policy`` ``trained_ppo`` seam consumes (NET-NEW). + """ + + def _policy(env: PortfolioEnv, info: Mapping[str, Any]) -> int: + observation = env._observation() # noqa: SLF001 - read-only obs snapshot + predicted = _predict_action(model, observation) + mask = list(info["action_mask"]) + return _best_valid_action(predicted, mask) + + return _policy + + +def measure_invalid_action_rate( + model: Any, + config: PortfolioSb3TrainConfig, + *, + use_masked_policy: bool = False, +) -> Dict[str, Any]: + """Run a bounded deterministic eval and measure the invalid-action rate. + + ``use_masked_policy=False`` measures the RAW model (no fallback) so the rate + reflects how often the *unmasked* policy would have chosen an invalid action + — the quantity the MaskablePPO trigger keys on. ``use_masked_policy=True`` + confirms the masked obs-decode ``PolicyFn`` emits zero invalid actions. + """ + + env = make_portfolio_sb3_env( + candidate_path=config.candidate_path, + top_k_candidates=config.top_k_candidates, + max_positions=config.max_positions, + initial_cash=config.initial_cash, + buy_fraction=config.buy_fraction, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + invalid_action_penalty=config.invalid_action_penalty, + turnover_penalty_lambda=config.turnover_penalty_lambda, + seed=config.seed, + ) + raw_env = env.raw_env + observation, info = env.reset(seed=config.seed) + steps = 0 + invalid = 0 + try: + terminated = False + truncated = False + while not (terminated or truncated): + if steps >= int(config.max_eval_steps): + break + mask = list(info["action_mask"]) + predicted = _predict_action(model, observation) + action = _best_valid_action(predicted, mask) if use_masked_policy else predicted + if not (0 <= action < len(mask)) or not mask[action]: + invalid += 1 + observation, _reward, terminated, truncated, info = env.step(int(action)) + steps += 1 + finally: + env.close() + rate = float(invalid) / float(steps) if steps else 0.0 + return { + "steps": steps, + "invalid_action_count": invalid, + "invalid_action_rate": rate, + "engine_invalid_action_count": int(raw_env.invalid_actions and len(raw_env.invalid_actions) or 0), + "use_masked_policy": bool(use_masked_policy), + "trigger_threshold": MASKABLE_PPO_INVALID_ACTION_TRIGGER, + "maskable_ppo_trigger_fired": bool(rate > MASKABLE_PPO_INVALID_ACTION_TRIGGER), + } + + +def train_and_save(config: PortfolioSb3TrainConfig) -> Dict[str, Any]: + """Train, save the model, measure invalid-action rate; return a summary. + + Story A: when ``write_artifacts`` AND ``write_training_events`` are on, the + train run dir gains an ``rl_live_events.jsonl`` (the per-rollout telemetry + stream), an ``rl_live_summary.json`` rollup, and an ``sb3_smoke_summary.json`` + signature so the existing dashboard recognises the directory as a run and + serves the live events through ``/table/events`` (follow/replay). + """ + + check_result = check_train_env(config) + + output_dir = Path(config.output_dir) + emit_events = bool(config.write_artifacts and config.write_training_events) + live_events_path = output_dir / "rl_live_events.jsonl" + if emit_events: + output_dir.mkdir(parents=True, exist_ok=True) + + model, runtime = train_portfolio_model( + config, live_events_path=live_events_path if emit_events else None + ) + raw_rate = measure_invalid_action_rate(model, config, use_masked_policy=False) + masked_rate = measure_invalid_action_rate(model, config, use_masked_policy=True) + + model_path: Optional[str] = None + if config.write_artifacts: + output_dir.mkdir(parents=True, exist_ok=True) + model_path = str(output_dir / f"portfolio_{config.algorithm}_model.zip") + model.save(model_path) + + live_summary: Optional[Dict[str, Any]] = None + if emit_events and live_events_path.is_file(): + live_summary = summarize_live_event_file(live_events_path) + + summary: Dict[str, Any] = { + "mode": "stom_rl_portfolio_sb3_train", + "config": asdict(config), + "runtime": runtime, + "check_env": check_result, + "raw_invalid_action": raw_rate, + "masked_invalid_action": masked_rate, + "maskable_ppo_trigger": { + "threshold": MASKABLE_PPO_INVALID_ACTION_TRIGGER, + "raw_rate": raw_rate["invalid_action_rate"], + "fired_on_rate": bool(raw_rate["maskable_ppo_trigger_fired"]), + "recommendation": ( + "ESCALATE: record sb3-contrib MaskablePPO recommendation " + "(NOT installed in Stage B)" + if raw_rate["maskable_ppo_trigger_fired"] + else "no escalation: penalty-PPO invalid-action rate within budget" + ), + }, + "model_path": model_path, + } + if live_summary is not None: + summary["live_events"] = live_summary + if config.write_artifacts: + (output_dir / "portfolio_sb3_train_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8-sig" + ) + if live_summary is not None: + _write_train_run_signature(output_dir, config=config, live_summary=live_summary) + (output_dir / "rl_live_summary.json").write_text( + json.dumps(live_summary, ensure_ascii=False, indent=2), encoding="utf-8-sig" + ) + return summary + + +def _write_train_run_signature( + output_dir: Path, + *, + config: PortfolioSb3TrainConfig, + live_summary: Mapping[str, Any], +) -> None: + """Write the dashboard signature so the train run is follow/replay-visible. + + The dashboard's ``_detect_artifact_type`` recognises a run by a signature + file; ``sb3_smoke_summary.json`` is one of them and its ``live_events`` block + is read by ``load_rl_run``. Writing it here makes the SB3 *training* stream + appear in the same realtime view the deterministic smoke run uses — without a + separate publish step or any new ``/api/*`` route. + """ + + live_algorithm = PORTFOLIO_TRAIN_LIVE_ALGORITHM.get( + config.algorithm.lower(), f"portfolio_{config.algorithm.lower()}" + ) + signature = { + "mode": "stom_rl_portfolio_sb3_train", + "run_name": output_dir.name, + "config": asdict(config), + "summary": { + "algorithm": config.algorithm, + "best_model": live_algorithm, + "total_timesteps": int(config.total_timesteps), + "live_event_count": int(live_summary.get("event_count", 0)), + "feature_columns": [], + }, + "models": [ + { + "model": live_algorithm, + "algorithm": config.algorithm, + "total_timesteps": int(config.total_timesteps), + } + ], + "live_events": dict(live_summary), + } + (output_dir / SB3_SMOKE_SIGNATURE_FILE).write_text( + json.dumps(signature, ensure_ascii=False, indent=2), encoding="utf-8-sig" + ) + + +def load_trained_model(model_path: str, *, algorithm: str = "ppo") -> Any: + """Load a saved SB3 model from ``model_path`` (PPO or DQN).""" + + DQN, PPO, _ = _sb3_imports() + cls = DQN if algorithm.lower() == "dqn" else PPO + return cls.load(model_path, device="cpu") + + +def trained_eval_metrics(model: Any, candidate_path: Optional[str], *, n_folds: int = 2) -> List[Dict[str, Any]]: + """Run the trained policy through the walk-forward holdout and return folds. + + Helper used by the determinism reproducibility assertion: two trains with the + same seed must agree within ``atol=1e-6, rtol=1e-5`` on per-fold metrics. + """ + + from . import portfolio_walk_forward as pwf + + factory_key = "__determinism_probe__" + pwf.register_trained_policy_factory(factory_key, lambda **_: make_trained_policy_fn(model)) + try: + cfg = pwf.PortfolioWalkForwardConfig( + candidate_path=candidate_path, + n_folds=n_folds, + baselines=(factory_key,), + write_artifacts=False, + ) + payload = pwf.run_portfolio_walk_forward(cfg) + return [ + { + "fold_index": row["fold_index"], + "return_pct": float(row["return_pct"]), + "max_drawdown_pct": float(row["max_drawdown_pct"]), + "turnover": float(row["turnover"]), + } + for row in payload["folds"] + ] + finally: + pwf.unregister_trained_policy_factory(factory_key) + + +def assert_reproducible( + config: PortfolioSb3TrainConfig, + *, + atol: float = 1e-6, + rtol: float = 1e-5, +) -> Dict[str, Any]: + """Train twice with the same seed/data and assert eval metrics agree. + + Determinism gate (V4): without the pins in :func:`apply_determinism` this is + untestable, so the pins are applied at every train. Compares per-fold + ``return_pct``/``max_drawdown_pct``/``turnover`` within the explicit tolerance. + """ + + model_a, _ = train_portfolio_model(config) + folds_a = trained_eval_metrics(model_a, config.candidate_path, n_folds=2) + model_b, _ = train_portfolio_model(config) + folds_b = trained_eval_metrics(model_b, config.candidate_path, n_folds=2) + + assert len(folds_a) == len(folds_b), "fold count diverged across reruns" + for fa, fb in zip(folds_a, folds_b): + for key in ("return_pct", "max_drawdown_pct", "turnover"): + np.testing.assert_allclose( + fa[key], fb[key], atol=atol, rtol=rtol, + err_msg=f"determinism: fold {fa['fold_index']} {key} diverged", + ) + return {"reproducible": True, "atol": atol, "rtol": rtol, "folds_compared": len(folds_a)} + + +def run_stage_b_smoke(config: PortfolioSb3TrainConfig, *, n_folds: int = 2) -> Dict[str, Any]: + """Bounded Stage-B smoke: pre-register, train, then advisory holdout compare. + + Honesty contract: on the 3-symbol universe ``n_folds`` can only be 2, so per + the plan's P0-1 power floor this is ADVISORY-ONLY — NO alpha may be claimed. + The summary records the pre-registered primary config and ``M`` (configs + tried) BEFORE the holdout comparison, and labels the whole result advisory. + """ + + from . import portfolio_walk_forward as pwf + + # --- Pre-registration (P0-1): written BEFORE any test-fold metric. --- + pre_registration = { + "primary_config": { + "algorithm": config.algorithm, + "turnover_penalty_lambda": config.turnover_penalty_lambda, + "top_k_candidates": config.top_k_candidates, + "seed_set": [config.seed], + "cost_bps": config.cost_bps, + }, + "candidate_config_set": [ + {"algorithm": config.algorithm, "lambda": config.turnover_penalty_lambda, + "top_k": config.top_k_candidates, "seed": config.seed}, + ], + "M_configs_tried": 1, + "n_folds": n_folds, + "advisory_only": True, + "advisory_reason": ( + f"n_folds={n_folds} < 5 power floor (P0-1); 3-symbol universe. " + "NO alpha claim. Real alpha verdict deferred to Stage E (n_folds>=5)." + ), + } + + train_summary = train_and_save(config) + model = load_trained_model(train_summary["model_path"], algorithm=config.algorithm) \ + if train_summary["model_path"] else train_portfolio_model(config)[0] + + pwf.register_trained_policy_factory( + "trained_ppo", lambda **_: make_trained_policy_fn(model) + ) + try: + wf_cfg = pwf.PortfolioWalkForwardConfig( + candidate_path=config.candidate_path, + output_dir=str(Path(config.output_dir) / "walk_forward"), + n_folds=n_folds, + baselines=("no_trade", "equal_weight_candidate", "supervised_ranker", "trained_ppo"), + top_k_candidates=config.top_k_candidates, + max_positions=config.max_positions, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + write_artifacts=config.write_artifacts, + ) + wf_payload = pwf.run_portfolio_walk_forward(wf_cfg) + finally: + pwf.unregister_trained_policy_factory("trained_ppo") + + # Advisory comparison: trained_ppo vs no_trade / equal_weight / supervised_ranker. + by_policy: Dict[str, List[float]] = {} + for row in wf_payload["folds"]: + by_policy.setdefault(row["policy"], []).append(float(row["return_pct"])) + mean_ret = {p: (sum(v) / len(v) if v else 0.0) for p, v in by_policy.items()} + trained = mean_ret.get("trained_ppo", 0.0) + ranker = mean_ret.get("supervised_ranker", 0.0) + advisory = { + "mean_return_pct_by_policy": mean_ret, + "cost_bps": config.cost_bps, + "rl_vs_ranker": "RL_<=_ranker" if trained <= ranker else "RL_>_ranker", + "ranker_floor_verdict": ( + "RECOMMEND ABANDONING RL (trained_ppo <= supervised_ranker on holdout)" + if trained <= ranker + else "RL clears the ranker floor (advisory, n_folds<5)" + ), + "alpha_claim": "FORBIDDEN (advisory-only, n_folds<5 per P0-1)", + } + + summary = { + "mode": "stom_rl_portfolio_sb3_stage_b_smoke", + "pre_registration": pre_registration, + "train": train_summary, + "walk_forward_folds": wf_payload["folds"], + "advisory_comparison": advisory, + } + if config.write_artifacts: + out = Path(config.output_dir) + out.mkdir(parents=True, exist_ok=True) + (out / "stage_b_smoke_summary.json").write_text( + json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8-sig" + ) + return summary + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> Tuple[PortfolioSb3TrainConfig, int]: + import argparse + + parser = argparse.ArgumentParser(description="Stage-B deterministic SB3 portfolio train + advisory holdout.") + parser.add_argument("--candidate-csv", default=None) + parser.add_argument("--output-dir", default=str(DEFAULT_DEEP_RL_TRAIN_OUTPUT_DIR)) + parser.add_argument("--algorithm", default="ppo", choices=("ppo", "dqn")) + parser.add_argument("--total-timesteps", type=int, default=5_000) + parser.add_argument("--top-k-candidates", type=int, default=3) + parser.add_argument("--max-positions", type=int, default=2) + parser.add_argument("--cost-bps", type=float, default=25.0) + parser.add_argument("--turnover-penalty-lambda", type=float, default=1.0) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--n-folds", type=int, default=2) + parser.add_argument("--no-write", action="store_true") + parser.add_argument( + "--no-training-events", + action="store_true", + help="Disable the live training-event stream (rl_live_events.jsonl).", + ) + args = parser.parse_args(argv) + config = PortfolioSb3TrainConfig( + candidate_path=args.candidate_csv, + output_dir=args.output_dir, + algorithm=args.algorithm, + total_timesteps=args.total_timesteps, + top_k_candidates=args.top_k_candidates, + max_positions=args.max_positions, + cost_bps=args.cost_bps, + turnover_penalty_lambda=args.turnover_penalty_lambda, + seed=args.seed, + write_artifacts=not args.no_write, + write_training_events=not args.no_training_events, + ) + return config, args.n_folds + + +def main(argv: Optional[Sequence[str]] = None) -> int: + config, n_folds = _parse_args(argv) + summary = run_stage_b_smoke(config, n_folds=n_folds) + print(json.dumps(summary, ensure_ascii=False, indent=2)) + return 0 + + +__all__ = [ + "PortfolioSb3TrainConfig", + "MASKABLE_PPO_INVALID_ACTION_TRIGGER", + "PORTFOLIO_TRAIN_LIVE_ALGORITHM", + "PORTFOLIO_TRAIN_LIVE_PHASE", + "SB3_SMOKE_SIGNATURE_FILE", + "apply_determinism", + "check_train_env", + "train_portfolio_model", + "make_trained_policy_fn", + "measure_invalid_action_rate", + "train_and_save", + "load_trained_model", + "trained_eval_metrics", + "assert_reproducible", + "run_stage_b_smoke", + "main", +] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/portfolio_train.py b/stom_rl/portfolio_train.py new file mode 100644 index 000000000..705738f09 --- /dev/null +++ b/stom_rl/portfolio_train.py @@ -0,0 +1,261 @@ +"""Portfolio RL smoke runner. + +This page-4 runner proves the portfolio environment contract and artifact +schema without requiring a heavy RL dependency. The deterministic smoke policy +uses the same fixed Discrete action layout that an RL agent will consume. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence + +from .portfolio_env import ACTION_HOLD, PortfolioEnv, PortfolioEnvConfig +from .rl_events import RlLiveEventWriter, summarize_live_event_file +from .symbol_norm import read_candidates_csv + + +DEFAULT_PORTFOLIO_TRAIN_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_portfolio_smoke" + +# Live-event semantics for the portfolio runner (shared with the dashboard's +# realtime view via the same JSONL schema sb3_smoke emits): +# algorithm = "portfolio", phase = "train" +# equity = NAV, reward = step reward, action = discrete action index +# position = number of held positions, price = best-candidate decision price +PORTFOLIO_LIVE_ALGORITHM = "portfolio" +PORTFOLIO_LIVE_PHASE = "train" + + +@dataclass(frozen=True) +class PortfolioTrainConfig: + candidate_path: Optional[str] = None + output_dir: str = str(DEFAULT_PORTFOLIO_TRAIN_OUTPUT_DIR) + top_k_candidates: int = 3 + max_positions: int = 2 + max_steps: int = 12 + seed: int = 100 + initial_cash: float = 1_000_000.0 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + write_artifacts: bool = True + write_live_events: bool = True + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _representative_price(env: PortfolioEnv) -> Optional[float]: + """Best-candidate decision-bar price for the current step. + + The portfolio holds many symbols, so the single-symbol ``price`` field of a + live event is filled with the top-ranked candidate's decision-bar close (the + same bar the observation is built from). Returns ``None`` when no candidate + is present so the optional field is simply omitted rather than fabricated. + """ + + candidates = env._current_candidates() + if candidates.empty: + return None + try: + return float(candidates.iloc[0]["price"]) + except (KeyError, IndexError, TypeError, ValueError): + return None + + +def _emit_live_event( + writer: RlLiveEventWriter, + *, + global_step: int, + action: int, + info: Mapping[str, Any], + reward: float, + price: Optional[float], +) -> None: + """Write one portfolio step as an ``RlLiveEvent`` (NAV mapped to equity).""" + + nav = float(info["nav"]) + cash = float(info["cash"]) + positions = info.get("positions") or [] + bar_timestamp = info.get("timestamp") + writer.write_step( + algorithm=PORTFOLIO_LIVE_ALGORITHM, + phase=PORTFOLIO_LIVE_PHASE, + global_step=global_step, + timestamp=bar_timestamp, + # Pin the event wall-clock to the deterministic bar timestamp so a fixed + # seed yields byte-identical events (the dataclass otherwise defaults to + # the non-deterministic ``utc_now_iso``). + timestamp_utc=bar_timestamp, + price=price, + action=int(action), + reward=float(reward), + position=float(len(positions)), + equity=nav, + info={ + "cash": cash, + "holdings_value": nav - cash, + "nav": nav, + "trade_count": int(info.get("trade_count", 0)), + "candidate_count": int(sum(info.get("candidate_mask") or [])), + "invalid_action": bool(info.get("invalid_action", False)), + }, + ) + + +def _deterministic_action(env: PortfolioEnv, info: Mapping[str, Any]) -> int: + mask = list(info["action_mask"]) + sell_offset = 1 + env.config.top_k_candidates + if int(info["current_step"]) % 4 == 3: + for action in range(sell_offset, len(mask)): + if mask[action]: + return action + for action in range(1, sell_offset): + if mask[action]: + return action + return ACTION_HOLD + + +def run_portfolio_smoke(config: PortfolioTrainConfig) -> Dict[str, Any]: + candidates = None + if config.candidate_path: + candidates = read_candidates_csv(config.candidate_path) + env = PortfolioEnv( + PortfolioEnvConfig( + candidate_path=config.candidate_path, + top_k_candidates=config.top_k_candidates, + max_positions=config.max_positions, + initial_cash=config.initial_cash, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + seed=config.seed, + ), + candidates=candidates, + ) + observation, info = env.reset(seed=config.seed) + + output_dir = Path(config.output_dir) + live_events_path = output_dir / "rl_live_events.jsonl" + event_writer: Optional[RlLiveEventWriter] = None + if config.write_artifacts and config.write_live_events: + event_writer = RlLiveEventWriter(live_events_path, run_id=output_dir.name) + event_writer.reset() + + terminated = False + truncated = False + rewards: List[float] = [] + steps = 0 + while not (terminated or truncated): + if config.max_steps and steps >= int(config.max_steps): + break + action = _deterministic_action(env, info) + # Capture the decision-bar price before stepping so the event price + # matches the bar the action was taken on (no lookahead). + decision_price = _representative_price(env) + observation, reward, terminated, truncated, info = env.step(action) + rewards.append(float(reward)) + steps += 1 + if event_writer is not None: + _emit_live_event( + event_writer, + global_step=steps, + action=action, + info=info, + reward=reward, + price=decision_price, + ) + + payload: Dict[str, Any] = { + "mode": "stom_rl_portfolio_train_smoke", + "config": asdict(config), + "summary": { + "steps": steps, + "final_nav": float(info["nav"]), + "total_reward": float(sum(rewards)), + "trade_count": int(info["trade_count"]), + "invalid_action_count": int(info["invalid_action_count"]), + "observation_shape": list(observation.shape), + "action_space_n": int(env.action_space.n), + "candidate_mask_last": info["candidate_mask"], + "holding_mask_last": info["holding_mask"], + }, + "artifacts": { + "output_dir": str(output_dir), + "summary_json": str(output_dir / "portfolio_train_summary.json"), + "actions_csv": str(output_dir / "actions.csv"), + "trades_csv": str(output_dir / "trades.csv"), + "nav_csv": str(output_dir / "nav.csv"), + "blocked_actions_json": str(output_dir / "blocked_actions.json"), + "live_events_jsonl": str(live_events_path), + "live_summary_json": str(output_dir / "rl_live_summary.json"), + }, + } + if event_writer is not None: + live_summary = summarize_live_event_file(live_events_path) + payload["live_events"] = live_summary + payload["summary"]["live_event_count"] = live_summary["event_count"] + _write_json(output_dir / "rl_live_summary.json", live_summary) + if config.write_artifacts: + _write_json(output_dir / "portfolio_train_summary.json", payload) + _write_csv( + output_dir / "actions.csv", + env.action_log, + ["timestamp", "action", "action_type", "slot", "invalid_action", "blocked_reason", "reward", "nav_after"], + ) + _write_csv(output_dir / "trades.csv", env.trade_log, ["timestamp", "symbol", "side", "price", "quantity", "gross_value", "cost", "cash_after", "realized_pnl"]) + _write_csv(output_dir / "nav.csv", env.nav_log, ["timestamp", "step", "nav", "cash", "position_count"]) + _write_json(output_dir / "blocked_actions.json", {"blocked_actions": env.invalid_actions}) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> PortfolioTrainConfig: + parser = argparse.ArgumentParser(description="Run portfolio environment smoke training.") + parser.add_argument("--smoke", action="store_true", help="Use deterministic synthetic candidates when no CSV is provided.") + parser.add_argument("--candidate-csv", default=None) + parser.add_argument("--output-dir", default=str(DEFAULT_PORTFOLIO_TRAIN_OUTPUT_DIR)) + parser.add_argument("--top-k-candidates", type=int, default=3) + parser.add_argument("--max-positions", type=int, default=2) + parser.add_argument("--max-steps", type=int, default=12) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--initial-cash", type=float, default=1_000_000.0) + parser.add_argument("--cost-bps", type=float, default=25.0) + parser.add_argument("--slippage-bps", type=float, default=0.0) + parser.add_argument("--no-write", action="store_true") + parser.add_argument("--no-live-events", action="store_true") + args = parser.parse_args(argv) + return PortfolioTrainConfig( + candidate_path=args.candidate_csv, + output_dir=args.output_dir, + top_k_candidates=args.top_k_candidates, + max_positions=args.max_positions, + max_steps=args.max_steps, + seed=args.seed, + initial_cash=args.initial_cash, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + write_artifacts=not args.no_write, + write_live_events=not args.no_live_events, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_portfolio_smoke(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/portfolio_walk_forward.py b/stom_rl/portfolio_walk_forward.py new file mode 100644 index 000000000..76742e7da --- /dev/null +++ b/stom_rl/portfolio_walk_forward.py @@ -0,0 +1,867 @@ +"""Portfolio walk-forward validation with an expanding-window train/test holdout. + +Page 11 upgrades the smoke runner into a real *out-of-sample* validation: + +* Timestamps are split into contiguous, time-ordered **segments**. +* Fold ``N`` *trains/fits* on the expanding window of segments ``0..N`` and is + *evaluated* on the **strictly later, disjoint** segment ``N+1``. +* The deterministic stand-in policy has nothing to fit, but the split mechanics + and the eval-on-held-out-later-segment are real, so a trained policy can slot + into ``_fit_policy`` later without touching the holdout machinery. + +The fold report carries each fold's train range, test range, and the five +baselines + policy metrics (return, MDD, turnover, trade count, cost) computed +on the **test** segment, with ``cost_bps`` kept explicit. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from dataclasses import asdict, dataclass, replace +from pathlib import Path +from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .portfolio_env import ACTION_HOLD, PortfolioEnv, PortfolioEnvConfig, synthetic_candidates +from .symbol_norm import read_candidates_csv + + +DEFAULT_PORTFOLIO_WALK_FORWARD_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_portfolio_walk_forward" +# "rl_baseline" is the deterministic RL stand-in (no trained model yet). +# "cost_aware" is the Page 17 cost-aware policy: parameters (rank_score +# threshold + min-hold) are *fit on the TRAIN segment* and frozen for the +# disjoint, strictly-later TEST eval (see ``_fit_cost_aware_policy``). +DEFAULT_BASELINES = ( + "no_trade", + "equal_weight_candidate", + "buy_and_hold", + "rule_baseline", + "rl_baseline", + "cost_aware", +) + +# P0-2 — trained/learned baseline keys. These are NOT in DEFAULT_BASELINES (the +# deterministic stand-ins) because they require an out-of-band fit/model handoff +# that ``_fit_policy`` performs via ``TRAINED_POLICY_FACTORIES`` (below). Keeping +# them in a separate set keeps the diff small AND lets ``_parse_baselines`` accept +# them WITHOUT relaxing the strict DEFAULT_BASELINES membership check that guards +# typos. ``supervised_ranker`` (§7a) is a built-in factory; ``trained_ppo`` is +# registered at runtime by the trainer once a model is loaded. +TRAINED_BASELINES = ( + "trained_ppo", + "supervised_ranker", +) + +# Model-handoff seam (P0-2(b)): a module-level registry the runner consults in +# ``_fit_policy`` BEFORE the ``del train_frame, config, fold_index`` line, so a +# trained model (or a TRAIN-fit ranker) can be handed to the holdout machinery +# without changing ``_fit_policy``'s signature. Each factory receives the TRAIN +# segment + config + fold_index and returns a ``PolicyFn`` evaluated on the +# disjoint, strictly-later TEST segment. +TrainedPolicyFactory = Callable[..., "PolicyFn"] +TRAINED_POLICY_FACTORIES: Dict[str, "TrainedPolicyFactory"] = {} + + +def register_trained_policy_factory(key: str, factory: "TrainedPolicyFactory") -> None: + """Register a trained-policy factory under ``key`` for the ``_fit_policy`` seam. + + The factory is called as ``factory(train_frame=..., config=..., fold_index=...)`` + and must return a ``PolicyFn``. ``trained_ppo`` (SB3 model handoff) and any + ad-hoc trained baseline register here; ``supervised_ranker`` is built in. + """ + + if not isinstance(key, str) or not key: + raise ValueError("trained policy factory key must be a non-empty string") + TRAINED_POLICY_FACTORIES[key] = factory + + +def unregister_trained_policy_factory(key: str) -> None: + """Remove a registered trained-policy factory (no error if absent).""" + + TRAINED_POLICY_FACTORIES.pop(key, None) + +# Bounded TRAIN tuning grid for the cost-aware policy (no open-ended search). +# ``score_quantile``: only buy a candidate whose rank_score is at/above this +# quantile of the TRAIN rank_score distribution (higher ⇒ more selective). +# ``min_hold_steps``: suppress churn by forbidding a sell for N steps post-buy. +COST_AWARE_SCORE_QUANTILES: Tuple[float, ...] = (0.0, 0.5, 0.75, 0.9) +COST_AWARE_MIN_HOLD_STEPS: Tuple[int, ...] = (1, 2, 4, 8) +# Turnover-penalty λ used *only* while scoring TRAIN candidates so the tuner +# prefers low-churn params; the held-out TEST eval reports the unshaped costed +# return (no λ) so the comparison table stays honest vs the other baselines. +COST_AWARE_TRAIN_LAMBDA: float = 1.0 + + +@dataclass(frozen=True) +class PortfolioWalkForwardConfig: + candidate_path: Optional[str] = None + output_dir: str = str(DEFAULT_PORTFOLIO_WALK_FORWARD_OUTPUT_DIR) + n_folds: int = 2 + baselines: Tuple[str, ...] = DEFAULT_BASELINES + top_k_candidates: int = 3 + max_positions: int = 2 + max_steps_per_fold: int = 24 + seed: int = 100 + initial_cash: float = 1_000_000.0 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + # Turnover-cost penalty λ threaded into the env reward (Page 17). Default + # ``0.0`` ⇒ legacy NAV-change reward. The cost-aware tuner overrides this + # to ``COST_AWARE_TRAIN_LAMBDA`` *for TRAIN scoring only*; the held-out TEST + # eval always runs with λ unchanged from this config (kept 0 for honesty). + turnover_penalty_lambda: float = 0.0 + write_artifacts: bool = True + # Mandatory shuffle/permutation sanity check (plan §7). When ``shuffle_signal`` + # is True the loaded candidate frame has its ``rank_score`` + ``feature_*`` + # columns permuted within each timestamp (selection signal destroyed, fills + # untouched) before folds are built — used to prove a real edge vanishes under + # shuffle. ``shuffle_seed`` makes the permutation deterministic/auditable. + shuffle_signal: bool = False + shuffle_seed: int = 0 + + +@dataclass(frozen=True) +class FoldSplit: + """One expanding-window fold: train on ``0..N``, evaluate on ``N+1``.""" + + fold_index: int + train_frame: pd.DataFrame + test_frame: pd.DataFrame + train_start: str + train_end: str + test_start: str + test_end: str + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _load_candidates(path: Optional[str]) -> pd.DataFrame: + if path: + frame = read_candidates_csv(path) + else: + frame = synthetic_candidates() + frame = frame.copy() + frame["timestamp"] = pd.to_datetime(frame["timestamp"], errors="coerce") + return frame.dropna(subset=["timestamp"]).sort_values(["timestamp", "symbol"]).reset_index(drop=True) + + +def shuffle_candidate_signal(frame: pd.DataFrame, seed: int = 0) -> pd.DataFrame: + """Destroy the per-timestamp *selection* signal while keeping accounting identical. + + Mandatory shuffle/permutation sanity check (plan §7, V8/V8b). Within EACH + timestamp group, the values of ``rank_score`` AND every ``feature_*`` column + are permuted across that timestamp's rows. Crucially this OVERWRITES the + ``rank_score`` values (not merely the row order): :meth:`PortfolioEnv._current_candidates` + re-sorts by ``rank_score`` before ``head(top_k)``, so reordering rows alone is + a no-op — only overwriting the score values changes which candidates the env + selects. ``price`` / ``fill_price`` / ``symbol`` / ``timestamp`` / ``fillable`` + are left untouched so the realised fills and cost accounting are byte-for-byte + identical to the real run; ONLY the selection signal is scrambled. + + Each column is permuted *independently* per timestamp (its own draw from the + shared deterministic ``numpy`` generator), so no row retains its original + feature/score vector — the joint signal is fully destroyed. Singleton + timestamps (one row) are unchanged by construction (a permutation of one + element is the identity); the shuffle therefore only perturbs timestamps with + >=2 candidates, which is exactly where selection happens. + + Returns a NEW frame (input is not mutated). + """ + + work = frame.copy() + work["timestamp"] = pd.to_datetime(work["timestamp"], errors="coerce") + signal_cols = [c for c in work.columns if str(c).startswith("feature_")] + if "rank_score" in work.columns: + signal_cols.insert(0, "rank_score") + if not signal_cols: + return work.reset_index(drop=True) + + rng = np.random.default_rng(int(seed)) + # Permute within each timestamp group, in a deterministic timestamp order so a + # fixed seed yields a reproducible shuffle (audit + V8b unit test). + for _ts, idx in sorted( + work.groupby("timestamp").groups.items(), key=lambda kv: kv[0] + ): + positions = list(idx) + if len(positions) < 2: + continue + for col in signal_cols: + values = work.loc[positions, col].to_numpy() + work.loc[positions, col] = values[rng.permutation(len(positions))] + return work.reset_index(drop=True) + + +def _segment_timestamps(timestamps: Sequence[pd.Timestamp], n_segments: int) -> List[List[pd.Timestamp]]: + """Split sorted unique timestamps into contiguous, time-ordered segments. + + The split is deterministic: ``np.array_split`` over the sorted timestamps + yields the same boundaries for the same input. Empty buckets are dropped. + """ + + if not timestamps: + raise ValueError("No timestamps available for portfolio walk-forward") + n_segments = min(max(1, int(n_segments)), len(timestamps)) + buckets = np.array_split(np.asarray(timestamps, dtype=object), n_segments) + segments: List[List[pd.Timestamp]] = [] + for bucket in buckets: + block = [pd.Timestamp(ts) for ts in bucket] + if block: + segments.append(block) + return segments + + +def build_expanding_window_folds(frame: pd.DataFrame, n_folds: int) -> List[FoldSplit]: + """Build expanding-window folds with a disjoint, strictly-later test segment. + + ``n_folds`` is the number of *evaluated* folds. To produce ``n_folds`` test + segments we need ``n_folds + 1`` time segments: fold ``N`` trains on segments + ``0..N`` and evaluates on segment ``N+1``. When there are too few distinct + timestamps to honour the request, the fold count is reduced (and a fold needs + at least two segments to exist at all). + """ + + timestamps = sorted(pd.Timestamp(ts) for ts in frame["timestamp"].dropna().unique()) + if not timestamps: + raise ValueError("No timestamps available for portfolio walk-forward") + + requested = max(1, int(n_folds)) + n_segments = min(requested + 1, len(timestamps)) + segments = _segment_timestamps(timestamps, n_segments) + if len(segments) < 2: + raise ValueError( + "Walk-forward holdout requires at least two time segments " + f"(got {len(segments)} from {len(timestamps)} distinct timestamps); " + "supply more candidate timestamps for a meaningful train/test split." + ) + + folds: List[FoldSplit] = [] + for fold_index in range(len(segments) - 1): + train_ts = [ts for seg in segments[: fold_index + 1] for ts in seg] + test_ts = segments[fold_index + 1] + train_frame = frame[frame["timestamp"].isin(train_ts)].copy() + test_frame = frame[frame["timestamp"].isin(test_ts)].copy() + folds.append( + FoldSplit( + fold_index=fold_index, + train_frame=train_frame, + test_frame=test_frame, + train_start=train_ts[0].isoformat(), + train_end=train_ts[-1].isoformat(), + test_start=test_ts[0].isoformat(), + test_end=test_ts[-1].isoformat(), + ) + ) + return folds + + +def _make_env(candidates: pd.DataFrame, config: PortfolioWalkForwardConfig, fold_index: int) -> PortfolioEnv: + return PortfolioEnv( + PortfolioEnvConfig( + top_k_candidates=config.top_k_candidates, + max_positions=config.max_positions, + initial_cash=config.initial_cash, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + turnover_penalty_lambda=config.turnover_penalty_lambda, + seed=config.seed + fold_index, + ), + candidates=candidates, + ) + + +# A policy maps (env, info) -> discrete action. The deterministic stand-in is +# closed over the policy name; a trained model would expose the same signature. +PolicyFn = Callable[[PortfolioEnv, Mapping[str, Any]], int] + + +def _action_for_policy(policy: str, env: PortfolioEnv, info: Mapping[str, Any]) -> int: + mask = list(info["action_mask"]) + sell_offset = 1 + env.config.top_k_candidates + if policy == "no_trade": + return ACTION_HOLD + if policy == "buy_and_hold": + for action in range(1, sell_offset): + if mask[action]: + return action + return ACTION_HOLD + if policy in {"rule_baseline", "rl_baseline"} and int(info["current_step"]) % 4 == 3: + for action in range(sell_offset, len(mask)): + if mask[action]: + return action + for action in range(1, sell_offset): + if mask[action]: + return action + for action in range(sell_offset, len(mask)): + if policy in {"rule_baseline", "rl_baseline"} and mask[action]: + return action + return ACTION_HOLD + + +class _CostAwarePolicy: + """Selective, low-churn policy with TRAIN-fit, frozen parameters. + + The policy only buys a candidate whose ``rank_score`` is at/above a fitted + ``score_threshold`` (an absolute score derived from a TRAIN-segment quantile) + and refuses to sell a holding until it has been held ``min_hold_steps`` bars, + directly attacking the turnover-cost loss Page 14 identified. Both params + are *fit on the TRAIN segment only* (:func:`_fit_cost_aware_policy`) and + frozen here, so applying the callable to the disjoint, strictly-later TEST + segment introduces no leakage. The callable is stateful across a single + episode (it tracks per-symbol entry steps); it is re-instantiated per eval. + """ + + def __init__(self, score_threshold: float, min_hold_steps: int) -> None: + self.score_threshold = float(score_threshold) + self.min_hold_steps = int(min_hold_steps) + self._entry_step: Dict[str, int] = {} + + def __call__(self, env: PortfolioEnv, info: Mapping[str, Any]) -> int: + mask = list(info["action_mask"]) + step = int(info["current_step"]) + sell_offset = 1 + env.config.top_k_candidates + candidates = env._current_candidates() # noqa: SLF001 - read-only view of current bar + held = set(env.account.positions) + # Forget entry steps for symbols no longer held (closed elsewhere). + for symbol in list(self._entry_step): + if symbol not in held: + self._entry_step.pop(symbol, None) + + # 1) Sell only holdings that have satisfied the min-hold (anti-churn). + holdings = sorted(held) + for slot in range(min(len(holdings), env.config.max_positions)): + action = sell_offset + slot + if not mask[action]: + continue + symbol = holdings[slot] + entered = self._entry_step.get(symbol, step) + if step - entered >= self.min_hold_steps: + self._entry_step.pop(symbol, None) + return action + + # 2) Buy the best fillable candidate whose rank_score clears the + # fitted threshold (selective entry suppresses low-conviction churn). + for slot in range(1, sell_offset): + if not mask[slot]: + continue + cand_idx = slot - 1 + if cand_idx >= len(candidates): + continue + row = candidates.iloc[cand_idx] + if float(row["rank_score"]) >= self.score_threshold: + self._entry_step[str(row["symbol"])] = step + return slot + return ACTION_HOLD + + +def _score_quantile_threshold(train_frame: pd.DataFrame, quantile: float) -> float: + scores = pd.to_numeric(train_frame.get("rank_score"), errors="coerce").dropna() + if scores.empty: + return float("-inf") # no scores ⇒ threshold never blocks + return float(scores.quantile(float(quantile))) + + +def _score_train_params( + *, + score_threshold: float, + min_hold_steps: int, + train_frame: pd.DataFrame, + config: PortfolioWalkForwardConfig, + fold_index: int, +) -> float: + """Cost-adjusted TRAIN return for one (threshold, min_hold) candidate. + + Runs the cost-aware policy on the TRAIN segment with a turnover-penalty λ so + the tuner prefers params that trade only when worth the cost. Returns the + final-NAV return percent (higher is better); used purely to *rank* params. + """ + + train_config = replace( + config, + turnover_penalty_lambda=COST_AWARE_TRAIN_LAMBDA, + write_artifacts=False, + ) + env = _make_env(train_frame, train_config, fold_index) + _, info = env.reset(seed=config.seed + fold_index) + policy = _CostAwarePolicy(score_threshold, min_hold_steps) + terminated = False + truncated = False + steps = 0 + while not (terminated or truncated): + if config.max_steps_per_fold and steps >= int(config.max_steps_per_fold): + break + action = policy(env, info) + _, _, terminated, truncated, info = env.step(action) + steps += 1 + final_nav = float(info["nav"]) + return (final_nav / float(config.initial_cash) - 1.0) * 100.0 + + +def _fit_cost_aware_policy( + train_frame: pd.DataFrame, + config: PortfolioWalkForwardConfig, + fold_index: int, +) -> PolicyFn: + """Grid-search the cost-aware policy params on the TRAIN segment only. + + Iterates the bounded ``(score_quantile × min_hold)`` grid, scoring each on + the TRAIN segment with a turnover penalty, and freezes the best-scoring + params. The returned callable applies those frozen params; the surrounding + holdout machinery then evaluates it on the disjoint, strictly-later TEST + segment with *no* further fitting (no leakage). Ties break deterministically + toward the more selective / longer-hold (lower-churn) configuration. + """ + + best_key: Optional[Tuple[float, int, int]] = None + best_threshold = float("-inf") + best_min_hold = COST_AWARE_MIN_HOLD_STEPS[0] + for quantile in COST_AWARE_SCORE_QUANTILES: + threshold = _score_quantile_threshold(train_frame, quantile) + for min_hold in COST_AWARE_MIN_HOLD_STEPS: + train_return = _score_train_params( + score_threshold=threshold, + min_hold_steps=min_hold, + train_frame=train_frame, + config=config, + fold_index=fold_index, + ) + # Maximise TRAIN cost-adjusted return; on ties prefer higher + # quantile then longer hold (both reduce turnover) for determinism. + key = (round(train_return, 10), float(quantile), int(min_hold)) + if best_key is None or key > best_key: + best_key = key + best_threshold = threshold + best_min_hold = min_hold + + frozen = _CostAwarePolicy(best_threshold, best_min_hold) + + def _policy(env: PortfolioEnv, info: Mapping[str, Any]) -> int: + return frozen(env, info) + + # Re-instantiate per eval episode so per-symbol entry-step state is fresh + # (the holdout eval runs one episode; a fresh policy avoids stale state). + _policy.frozen_params = { # type: ignore[attr-defined] + "score_threshold": float(best_threshold), + "min_hold_steps": int(best_min_hold), + } + return _policy + + +def _causal_feature_columns(frame: pd.DataFrame) -> List[str]: + """The same causal feature columns the env consumes: ``rank_score`` + ``feature_*``. + + Mirrors ``PortfolioEnv._infer_feature_columns`` so the supervised ranker is + fit on the EXACT features the RL policy sees (no leaky strawman). + """ + + features = [str(c) for c in frame.columns if str(c).startswith("feature_")] + if "rank_score" in frame.columns and "rank_score" not in features: + features.insert(0, "rank_score") + return features + + +def _next_bar_return_labels(train_frame: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray]: + """Build (X, y) where y is the next-bar return of each candidate, TRAIN only. + + For each ``symbol`` the label is the realised next-bar move + ``(price_{t+1} - price_t) / price_t`` within the TRAIN segment only (sorted by + timestamp). This is a TRAIN-segment quantity: it never reads the TEST tail + because ``_fit_policy`` is handed only ``train_frame``. The classifier target + is ``y > 0`` (next bar up), so the ranker scores candidates by P(up). + """ + + feature_cols = _causal_feature_columns(train_frame) + frame = train_frame.copy() + frame["timestamp"] = pd.to_datetime(frame["timestamp"], errors="coerce") + frame = frame.dropna(subset=["timestamp", "price"]).sort_values(["symbol", "timestamp"]) + frame["_next_price"] = frame.groupby("symbol")["price"].shift(-1) + frame = frame.dropna(subset=["_next_price"]) + if frame.empty: + return np.zeros((0, len(feature_cols)), dtype=np.float64), np.zeros((0,), dtype=np.int64) + ret = (frame["_next_price"].to_numpy(dtype=np.float64) - frame["price"].to_numpy(dtype=np.float64)) + ret = ret / np.maximum(frame["price"].to_numpy(dtype=np.float64), 1e-12) + features = frame[feature_cols].apply(pd.to_numeric, errors="coerce").fillna(0.0) + X = features.to_numpy(dtype=np.float64) + y = (ret > 0.0).astype(np.int64) + return X, y + + +class _SupervisedRankerPolicy: + """TRAIN-fit supervised ranker that selects top_k candidates by P(next-up). + + A regularized logistic regression on the SAME causal features the env uses, + fit on the TRAIN segment ONLY, predicting whether a candidate's next bar is + up. At eval it scores the current-bar candidates and buys the best fillable + candidate whose predicted up-probability is highest (mirrors the env's top_k + selection). No TEST data ever reaches ``fit`` — the leakage guard test + asserts this. Falls back to the env's rank_score order if the model could + not be fit (degenerate single-class TRAIN labels). + """ + + def __init__(self, feature_cols: Sequence[str], model: Optional[Any]) -> None: + self.feature_cols = list(feature_cols) + self.model = model + + def _score_rows(self, candidates: pd.DataFrame) -> np.ndarray: + if candidates.empty: + return np.zeros((0,), dtype=np.float64) + feats = candidates.reindex(columns=self.feature_cols, fill_value=0.0) + X = feats.apply(pd.to_numeric, errors="coerce").fillna(0.0).to_numpy(dtype=np.float64) + if self.model is None or X.shape[0] == 0: + # Fallback: rank by rank_score (already the candidate sort key). + return pd.to_numeric(candidates.get("rank_score"), errors="coerce").fillna(0.0).to_numpy(dtype=np.float64) + return self.model.predict_proba(X)[:, 1] + + def __call__(self, env: PortfolioEnv, info: Mapping[str, Any]) -> int: + mask = list(info["action_mask"]) + sell_offset = 1 + env.config.top_k_candidates + candidates = env._current_candidates() # noqa: SLF001 - read-only current bar view + scores = self._score_rows(candidates) + # Rank buyable candidate slots by predicted P(up), descending. + buy_slots = [slot for slot in range(1, sell_offset) if mask[slot] and (slot - 1) < len(scores)] + buy_slots.sort(key=lambda slot: float(scores[slot - 1]), reverse=True) + for slot in buy_slots: + return slot + # No buyable candidate: free a slot only if forced (hold otherwise — the + # ranker is a long-only top_k selector, not a churn policy). + return ACTION_HOLD + + +def _fit_supervised_ranker( + *, + train_frame: pd.DataFrame, + config: PortfolioWalkForwardConfig, + fold_index: int, +) -> PolicyFn: + """Fit a logistic ranker on TRAIN causal features (P0-2 / §7a factory). + + Wired through the same ``_fit_policy`` plumbing as ``trained_ppo``; fit on the + TRAIN segment only, evaluated on the identical disjoint holdout the RL policy + uses. ``fold_index``/``config`` are accepted for the uniform factory + signature; the fit reads only ``train_frame``. + """ + + from sklearn.linear_model import LogisticRegression + + seed = int(config.seed) if config is not None else 0 + del fold_index, config # uniform factory signature; ranker reads only TRAIN + feature_cols = _causal_feature_columns(train_frame) + X, y = _next_bar_return_labels(train_frame) + model: Optional[Any] = None + if X.shape[0] >= 2 and len(np.unique(y)) >= 2: + model = LogisticRegression(C=1.0, max_iter=1000, random_state=seed) + model.fit(X, y) + frozen = _SupervisedRankerPolicy(feature_cols, model) + + def _policy(env: PortfolioEnv, info: Mapping[str, Any]) -> int: + return frozen(env, info) + + _policy.frozen_params = { # type: ignore[attr-defined] + "ranker_fitted": bool(model is not None), + "feature_count": len(feature_cols), + } + return _policy + + +# Built-in trained-baseline factory: supervised_ranker is always available. +register_trained_policy_factory( + "supervised_ranker", + lambda *, train_frame, config, fold_index: _fit_supervised_ranker( + train_frame=train_frame, config=config, fold_index=fold_index + ), +) + + +def _fit_policy( + policy: str, + train_frame: pd.DataFrame, + config: PortfolioWalkForwardConfig, + fold_index: int, +) -> PolicyFn: + """Fit/prepare a policy on the TRAIN segment, returning a callable. + + The deterministic stand-in and the rule baselines have no learnable state, + so fitting is a no-op that simply closes over the policy name. The + ``cost_aware`` policy *does* fit: its (rank_score threshold, min-hold) params + are grid-searched on ``train_frame`` only and frozen, then evaluated on the + disjoint, strictly-later TEST segment. + + P0-2: a trained/learned baseline (``trained_ppo``, ``supervised_ranker``, or + any runtime-registered key) is handed off here via ``TRAINED_POLICY_FACTORIES`` + BEFORE the ``del`` below — so the factory still receives ``train_frame`` / + ``config`` / ``fold_index``. ``_fit_policy``'s signature is unchanged. + """ + + if policy == "cost_aware": + return _fit_cost_aware_policy(train_frame, config, fold_index) + + # P0-2(b): trained/learned model handoff — MUST run before the del below. + factory = TRAINED_POLICY_FACTORIES.get(policy) + if factory is not None: + return factory(train_frame=train_frame, config=config, fold_index=fold_index) + + del train_frame, config, fold_index # train segment is the future seam + + def _policy(env: PortfolioEnv, info: Mapping[str, Any]) -> int: + return _action_for_policy(policy, env, info) + + return _policy + + +def _max_drawdown_pct(nav_values: Sequence[float]) -> float: + peak = float("-inf") + max_dd = 0.0 + for value in nav_values: + peak = max(peak, float(value)) + if peak > 0: + max_dd = min(max_dd, (float(value) / peak) - 1.0) + return max_dd * 100.0 + + +def _evaluate_on_test( + *, + policy_fn: PolicyFn, + test_candidates: pd.DataFrame, + fold_index: int, + policy: str, + config: PortfolioWalkForwardConfig, +) -> Dict[str, Any]: + """Run a (pre-fit) policy on the disjoint, later TEST segment and score it.""" + + env = _make_env(test_candidates, config, fold_index) + _, info = env.reset(seed=config.seed + fold_index) + terminated = False + truncated = False + rewards: List[float] = [] + steps = 0 + while not (terminated or truncated): + if config.max_steps_per_fold and steps >= int(config.max_steps_per_fold): + break + action = policy_fn(env, info) + _, reward, terminated, truncated, info = env.step(action) + rewards.append(float(reward)) + steps += 1 + + nav_curve = [float(row["nav"]) for row in env.nav_log] or [float(config.initial_cash)] + final_nav = float(info["nav"]) + turnover = float(sum(float(fill.get("gross_value", 0.0)) for fill in env.trade_log)) + total_cost = float(sum(float(fill.get("cost", 0.0)) for fill in env.trade_log)) + return { + "fold_index": fold_index, + "policy": policy, + "steps": steps, + "final_nav": final_nav, + "return_pct": (final_nav / float(config.initial_cash) - 1.0) * 100.0, + "max_drawdown_pct": _max_drawdown_pct(nav_curve), + "turnover": turnover, + "trade_count": int(info["trade_count"]), + "total_cost": total_cost, + "cost_bps": float(config.cost_bps), + "invalid_action_count": int(info["invalid_action_count"]), + "total_reward": float(sum(rewards)), + } + + +def run_portfolio_walk_forward(config: PortfolioWalkForwardConfig) -> Dict[str, Any]: + candidates = _load_candidates(config.candidate_path) + if config.shuffle_signal: + # Destroy the per-timestamp selection signal (rank_score + feature_*) while + # keeping price/fill_price/symbol intact — the mandatory shuffle test (§7). + candidates = shuffle_candidate_signal(candidates, seed=config.shuffle_seed) + folds = build_expanding_window_folds(candidates, config.n_folds) + rows: List[Dict[str, Any]] = [] + periods: List[Dict[str, Any]] = [] + for fold in folds: + # Assert the holdout contract by construction at runtime: TEST is disjoint + # from and strictly later than TRAIN. This is a hard guard, not prose. + train_ts = set(pd.Timestamp(ts) for ts in fold.train_frame["timestamp"].unique()) + test_ts = set(pd.Timestamp(ts) for ts in fold.test_frame["timestamp"].unique()) + if train_ts & test_ts: + raise AssertionError(f"Fold {fold.fold_index}: train/test timestamps overlap") + if test_ts and train_ts and min(test_ts) <= max(train_ts): + raise AssertionError(f"Fold {fold.fold_index}: test segment is not strictly later than train") + + periods.append( + { + "fold_index": fold.fold_index, + "train_start": fold.train_start, + "train_end": fold.train_end, + "test_start": fold.test_start, + "test_end": fold.test_end, + "train_candidate_count": int(len(fold.train_frame)), + "test_candidate_count": int(len(fold.test_frame)), + } + ) + for policy in config.baselines: + policy_fn = _fit_policy(policy, fold.train_frame, config, fold.fold_index) + metrics = _evaluate_on_test( + policy_fn=policy_fn, + test_candidates=fold.test_frame, + fold_index=fold.fold_index, + policy=policy, + config=config, + ) + # Expose the TRAIN-fit params for the cost-aware policy so the fold + # report shows exactly what was frozen before the held-out eval. + # Trained/learned baselines (supervised_ranker, trained_ppo) carry a + # differently-shaped ``frozen_params``; the cost-aware columns stay + # blank for them (use ``.get`` so any factory shape is tolerated). + fitted = getattr(policy_fn, "frozen_params", None) or {} + rows.append( + { + "train_start": fold.train_start, + "train_end": fold.train_end, + "test_start": fold.test_start, + "test_end": fold.test_end, + "fitted_score_threshold": ( + float(fitted["score_threshold"]) if "score_threshold" in fitted else "" + ), + "fitted_min_hold_steps": ( + int(fitted["min_hold_steps"]) if "min_hold_steps" in fitted else "" + ), + **metrics, + } + ) + ranking = sorted(rows, key=lambda row: float(row["return_pct"]), reverse=True) + output_dir = Path(config.output_dir) + payload: Dict[str, Any] = { + "mode": "stom_rl_portfolio_walk_forward", + "config": asdict(config), + "summary": { + "n_folds": len(folds), + "baseline_count": len(config.baselines), + "holdout": "expanding_window_train_le_N_eval_N_plus_1", + "cost_bps": float(config.cost_bps), + "shuffle_signal": bool(config.shuffle_signal), + "shuffle_seed": int(config.shuffle_seed) if config.shuffle_signal else None, + "smoke_success": bool(rows), + "best_policy_by_return": ranking[0]["policy"] if ranking else None, + "performance_success": bool(ranking and float(ranking[0]["return_pct"]) > 0.0), + "performance_note": ( + "Engineering completion requires a real expanding-window holdout " + "(eval on a disjoint, strictly-later test segment) plus generated " + "fold artifacts; alpha superiority over baselines is tracked " + "separately (Page 14)." + ), + }, + "fold_periods": periods, + "folds": rows, + "artifacts": { + "output_dir": str(output_dir), + "report_json": str(output_dir / "portfolio_walk_forward_report.json"), + "folds_csv": str(output_dir / "portfolio_walk_forward_folds.csv"), + }, + } + if config.write_artifacts: + _write_json(output_dir / "portfolio_walk_forward_report.json", payload) + _write_csv( + output_dir / "portfolio_walk_forward_folds.csv", + rows, + [ + "fold_index", + "train_start", + "train_end", + "test_start", + "test_end", + "policy", + "steps", + "final_nav", + "return_pct", + "max_drawdown_pct", + "turnover", + "trade_count", + "total_cost", + "cost_bps", + "invalid_action_count", + "total_reward", + "fitted_score_threshold", + "fitted_min_hold_steps", + ], + ) + return payload + + +def _parse_baselines(raw: str) -> Tuple[str, ...]: + baselines = tuple(part.strip() for part in raw.split(",") if part.strip()) + # P0-2(a): accept DEFAULT_BASELINES (deterministic stand-ins) PLUS the + # trained/learned keys (TRAINED_BASELINES + any runtime-registered factory), + # without relaxing the strict typo-guard for the deterministic set. + allowed = set(DEFAULT_BASELINES) | set(TRAINED_BASELINES) | set(TRAINED_POLICY_FACTORIES) + unknown = sorted(set(baselines) - allowed) + if unknown: + raise ValueError(f"Unknown portfolio baselines: {unknown}. Available: {sorted(allowed)}") + return baselines + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> PortfolioWalkForwardConfig: + parser = argparse.ArgumentParser(description="Run portfolio walk-forward holdout validation.") + parser.add_argument("--candidate-csv", default=None) + parser.add_argument("--output-dir", default=str(DEFAULT_PORTFOLIO_WALK_FORWARD_OUTPUT_DIR)) + parser.add_argument("--n-folds", type=int, default=2) + parser.add_argument("--baselines", default=",".join(DEFAULT_BASELINES)) + parser.add_argument("--top-k-candidates", type=int, default=3) + parser.add_argument("--max-positions", type=int, default=2) + parser.add_argument("--max-steps-per-fold", type=int, default=24) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--initial-cash", type=float, default=1_000_000.0) + parser.add_argument("--cost-bps", type=float, default=25.0) + parser.add_argument("--slippage-bps", type=float, default=0.0) + parser.add_argument( + "--turnover-penalty-lambda", + type=float, + default=0.0, + help="Additive turnover-cost penalty λ in the env reward (0 = legacy). " + "The cost-aware policy overrides this for TRAIN tuning only; held-out " + "TEST eval uses this value (keep 0 for an honest costed comparison).", + ) + parser.add_argument("--no-write", action="store_true") + parser.add_argument( + "--shuffle-signal", + action="store_true", + help="Mandatory shuffle test (§7): permute rank_score + feature_* within " + "each timestamp (overwrite, not reorder) so the selection signal is " + "destroyed while fills/cost stay identical. A real edge must vanish here.", + ) + parser.add_argument("--shuffle-seed", type=int, default=0, help="Deterministic seed for the shuffle test.") + args = parser.parse_args(argv) + return PortfolioWalkForwardConfig( + candidate_path=args.candidate_csv, + output_dir=args.output_dir, + n_folds=args.n_folds, + baselines=_parse_baselines(args.baselines), + top_k_candidates=args.top_k_candidates, + max_positions=args.max_positions, + max_steps_per_fold=args.max_steps_per_fold, + seed=args.seed, + initial_cash=args.initial_cash, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + turnover_penalty_lambda=args.turnover_penalty_lambda, + write_artifacts=not args.no_write, + shuffle_signal=bool(args.shuffle_signal), + shuffle_seed=int(args.shuffle_seed), + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_portfolio_walk_forward(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/predictability_probe.py b/stom_rl/predictability_probe.py new file mode 100644 index 000000000..9b62b39de --- /dev/null +++ b/stom_rl/predictability_probe.py @@ -0,0 +1,488 @@ +"""Within-window predictability gate P0+P1 (the cheap deep-RL go/no-go). + +**RULE strategy, NOT reinforcement learning.** This is the decisive, CPU-only +gate from ``docs/stom_rl_deeprl_opening20min_design_2026-05-29.md``: before any +deep RL, test whether the causal per-second microstructure state predicts a +cost-relevant short-horizon forward return OUT-OF-SAMPLE at all. If it cannot +beat a naive baseline OOS (rank-IC CI excludes 0) and translate to a net-of-cost +Deflated-Sharpe > 0.95, then every downstream RL formulation is dead and we STOP. + +* **P0 — MinTRL admissibility** (Bailey & López de Prado 2012): is the smallest + edge worth deploying even statistically distinguishable at our sample size? +* **P1 — supervised predictability probe**: Ridge + gradient-boosting on the + causal feature vector, purged walk-forward by session DATE, Spearman rank-IC + with session-block bootstrap CI, plus a one-trade-per-session threshold policy + scored net of 23 bp → Deflated Sharpe. + +The pure stats (``min_track_record_length``, ``run_probe`` on arrays) have no I/O +and are unit-tested; the DB extraction is separated into ``extract_samples``. +""" + +from __future__ import annotations + +from statistics import NormalDist, variance +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from stom_rl.exit_baselines import deflated_sharpe_ratio, per_trade_sharpe +from stom_rl.microstructure_features import FEATURE_NAMES, causal_feature_vector + +DEFAULT_COST_PCT: float = 0.23 # 23 bp round trip, in percent +SD_DIFF_PCT: float = 1.2 # assumed per-trade dispersion of the incremental edge (doc §7) + + +# --------------------------------------------------------------------------- +# P0 — Minimum Track Record Length (admissibility). +# --------------------------------------------------------------------------- +def min_track_record_length( + sharpe: float, + *, + skew: float = 0.0, + kurtosis: float = 3.0, + benchmark_sharpe: float = 0.0, + prob: float = 0.95, +) -> float: + """Observations needed for PSR(benchmark) > prob (Bailey-López de Prado 2012). + + ``MinTRL = 1 + (1 - skew*SR + (kurt-1)/4*SR^2) * (Z_prob / (SR - SR_bench))^2`` + with per-observation Sharpe ``SR`` and non-excess ``kurtosis`` (normal = 3). + Returns ``inf`` when ``SR <= benchmark`` (never distinguishable). + """ + + if sharpe <= benchmark_sharpe: + return float("inf") + z = NormalDist().inv_cdf(prob) + num = 1.0 - skew * sharpe + (kurtosis - 1.0) / 4.0 * sharpe ** 2 + return 1.0 + num * (z / (sharpe - benchmark_sharpe)) ** 2 + + +def _spearman_ic(pred: np.ndarray, actual: np.ndarray) -> float: + """Spearman rank correlation; 0.0 if degenerate (no variance / <2 points).""" + + if len(pred) < 2: + return 0.0 + from scipy.stats import spearmanr + + rho, _ = spearmanr(pred, actual) + return 0.0 if (rho is None or not np.isfinite(rho)) else float(rho) + + +def _policy_nets( + pred: np.ndarray, y: np.ndarray, groups: np.ndarray, secs: np.ndarray, cost_pct: float +) -> np.ndarray: + """One-trade-per-session net returns: enter at the first decision second with + predicted forward return > cost, book the realized forward return minus cost. + + One non-overlapping trade per (symbol,session) so the resulting per-trade + series is a clean Sharpe input (no within-session overlap inflation).""" + + nets: List[float] = [] + for g in np.unique(groups): + rows = np.where(groups == g)[0] + order = rows[np.argsort(secs[rows])] + qual = [r for r in order if pred[r] > cost_pct] + if qual: + nets.append(float(y[qual[0]] - cost_pct)) + return np.array(nets, dtype=float) + + +# --------------------------------------------------------------------------- +# P1 — supervised predictability probe (pure: operates on arrays, no I/O). +# --------------------------------------------------------------------------- +def run_probe( + X: np.ndarray, + y: np.ndarray, + dates: Sequence[str], + group_ids: Sequence[str], + secs: Sequence[float], + *, + boundaries: Sequence[float] = (0.5, 0.6, 0.7, 0.8, 0.9), + primary_boundary: float = 0.7, + cost_pct: float = DEFAULT_COST_PCT, + n_bootstrap: int = 1000, + rng_seed: int = 0, +) -> Dict[str, Any]: + """Purged walk-forward predictability probe over the causal feature matrix. + + For each date boundary, train Ridge + gradient-boosting on earlier sessions + and test on strictly later sessions (the boundary day is embargoed). Reports, + per model: OOS Spearman rank-IC at every boundary; at the primary boundary a + session-block bootstrap 95% CI for the IC; and a one-trade-per-session + threshold policy (enter at the first decision second with predicted forward + return > cost) scored net of ``cost_pct`` → per-trade Sharpe → Deflated Sharpe + (charging the full #models x #boundaries trial count). ``verdict`` is GO only + if some model has IC CI strictly > 0 AND net-of-cost DSR > 0.95. + """ + + from sklearn.ensemble import HistGradientBoostingRegressor + from sklearn.linear_model import Ridge + from sklearn.preprocessing import StandardScaler + + X = np.asarray(X, dtype=float) + y = np.asarray(y, dtype=float) + dates = np.asarray([str(d) for d in dates]) + group_ids = np.asarray([str(g) for g in group_ids]) + secs = np.asarray(secs, dtype=float) + rng = np.random.default_rng(rng_seed) + + uniq_dates = np.array(sorted(set(dates.tolist()))) + n_trials = 2 * len(boundaries) # 2 model families x boundaries + + def _split(frac: float) -> Tuple[np.ndarray, np.ndarray]: + cut = uniq_dates[max(0, min(len(uniq_dates) - 1, int(round(len(uniq_dates) * frac)) - 1))] + train = dates < cut # strictly earlier + test = dates > cut # strictly later (boundary day embargoed) + return train, test + + def _models(): + return { + "ridge": Ridge(alpha=10.0), + "gbm": HistGradientBoostingRegressor( + max_depth=3, max_iter=200, learning_rate=0.05, + l2_regularization=1.0, random_state=rng_seed, + ), + } + + per_boundary_ic: Dict[str, Dict[float, Optional[float]]] = {"ridge": {}, "gbm": {}} + # primary-boundary artifacts for CI + DSR + primary_pred: Dict[str, np.ndarray] = {} + primary_test_idx: Optional[np.ndarray] = None + primary_y: Optional[np.ndarray] = None + policy_sharpes: List[float] = [] # across all (model, boundary) configs -> DSR variance + + for frac in boundaries: + train, test = _split(frac) + if train.sum() < 50 or test.sum() < 50: + for m in per_boundary_ic: + per_boundary_ic[m][float(frac)] = None + continue + scaler = StandardScaler().fit(X[train]) + Xtr, Xte = scaler.transform(X[train]), scaler.transform(X[test]) + for name, model in _models().items(): + model.fit(Xtr, y[train]) + pred = model.predict(Xte) + per_boundary_ic[name][float(frac)] = _spearman_ic(pred, y[test]) + nets_b = _policy_nets(pred, y[test], group_ids[test], secs[test], cost_pct) + sr_b = per_trade_sharpe(nets_b.tolist()) if len(nets_b) >= 2 else None + if sr_b is not None: + policy_sharpes.append(sr_b) + if abs(frac - primary_boundary) < 1e-9: + primary_pred[name] = pred + if abs(frac - primary_boundary) < 1e-9: + primary_test_idx = np.where(test)[0] + primary_y = y[test] + + # Data-derived cross-config Sharpe dispersion for DSR (mirrors exit_baselines), + # floored; replaces a hardcoded variance so the deflation bar is principled. + sharpe_var = max(variance(policy_sharpes), 1e-4) if len(policy_sharpes) >= 2 else 0.25 + + results: Dict[str, Any] = { + "n_samples": int(len(y)), + "n_groups": int(len(set(group_ids.tolist()))), + "n_dates": int(len(uniq_dates)), + "n_trials": n_trials, + "primary_boundary": primary_boundary, + "cost_pct": cost_pct, + "sharpe_variance": float(sharpe_var), + "per_boundary_ic": per_boundary_ic, + "models": {}, + } + + best_go = False + if primary_test_idx is not None and primary_y is not None and len(primary_y) >= 2: + test_groups = group_ids[primary_test_idx] + test_secs = secs[primary_test_idx] + uniq_test_groups = np.array(sorted(set(test_groups.tolist()))) + for name in ("ridge", "gbm"): + pred = primary_pred.get(name) + if pred is None: + continue + ic = _spearman_ic(pred, primary_y) + # session-block bootstrap CI for IC (resample test GROUPS w/ replacement) + g_to_rows = {g: np.where(test_groups == g)[0] for g in uniq_test_groups} + boot = [] + for _ in range(n_bootstrap): + pick = rng.choice(uniq_test_groups, size=len(uniq_test_groups), replace=True) + rows = np.concatenate([g_to_rows[g] for g in pick]) + boot.append(_spearman_ic(pred[rows], primary_y[rows])) + lo, hi = np.percentile(boot, [2.5, 97.5]) + # one-trade-per-session threshold policy, net of cost + nets: List[float] = [] + for g in uniq_test_groups: + rows = g_to_rows[g] + order = rows[np.argsort(test_secs[rows])] + qual = [r for r in order if pred[r] > cost_pct] + if qual: + nets.append(float(primary_y[qual[0]] - cost_pct)) + nets_arr = np.array(nets) if nets else np.array([]) + sr = per_trade_sharpe(nets_arr.tolist()) if len(nets_arr) >= 2 else None + dsr = ( + deflated_sharpe_ratio( + sr, n_trials=n_trials, sharpe_variance=sharpe_var, + n_samples=len(nets_arr), + ) + if sr is not None else None + ) + ic_go = lo > 0.0 + dsr_go = dsr is not None and dsr > 0.95 + go = bool(ic_go and dsr_go) + best_go = best_go or go + results["models"][name] = { + "ic_primary": ic, + "ic_ci95": [float(lo), float(hi)], + "ic_ci_excludes_zero": bool(ic_go), + "n_policy_trades": int(len(nets_arr)), + "policy_mean_net_pct": float(nets_arr.mean()) if len(nets_arr) else None, + "policy_sharpe": sr, + "policy_dsr": dsr, + "dsr_gt_0_95": bool(dsr_go), + "go": go, + } + + # Symbol-disjoint robustness IC (unseen tickers, date-purged) — design §3-P1 + # "purge by symbol": train on 70% of symbols' earlier sessions, test on the + # held-out 30% of symbols' later sessions, so the date-only primary IC (an + # upper bound) is checked against per-ticker memorization. + import zlib + + symbols = np.array([g.split("_", 1)[0] for g in group_ids]) + cut_idx = max(0, min(len(uniq_dates) - 1, int(round(len(uniq_dates) * primary_boundary)) - 1)) + cut = uniq_dates[cut_idx] + pool = np.array([zlib.crc32(s.encode()) % 10 for s in symbols]) + tr_sd = (dates < cut) & (pool < 7) + te_sd = (dates > cut) & (pool >= 7) + results["symbol_disjoint_ic"] = {} + if tr_sd.sum() >= 50 and te_sd.sum() >= 50: + sc = StandardScaler().fit(X[tr_sd]) + Xtr2, Xte2 = sc.transform(X[tr_sd]), sc.transform(X[te_sd]) + for name, model in _models().items(): + model.fit(Xtr2, y[tr_sd]) + results["symbol_disjoint_ic"][name] = _spearman_ic(model.predict(Xte2), y[te_sd]) + + results["verdict"] = "GO" if best_go else "NO-GO" + return results + + +# --------------------------------------------------------------------------- +# DB extraction (I/O): ts_imb instances -> causal samples (features + label). +# --------------------------------------------------------------------------- +_COLS = { + "ts": "현재가", "px": "현재가", "cr": "등락율", "strength": "체결강도", + "buy_val": "초당매수금액", "sell_val": "초당매도금액", + "buy_qty": "초당매수수량", "sell_qty": "초당매도수량", + "bid_tot": "매수총잔량", "ask_tot": "매도총잔량", + "bid1": "매수호가1", "ask1": "매도호가1", "bidq1": "매수잔량1", "askq1": "매도잔량1", +} + + +def _sec_of(ts: int) -> int: + s = str(int(ts)) + return int(s[8:10]) * 3600 + int(s[10:12]) * 60 + int(s[12:14]) - 9 * 3600 + + +def extract_samples( + db_path: str, + *, + max_symbols: int = 0, + sample_every_sec: int = 10, + horizon_sec: int = 60, + window_end_sec: int = 1140, # 09:19:00 (leave horizon to 09:20) + strength_thr: float = 100.0, + imbalance_thr: float = 0.5, + entry_cr_thr: float = 2.0, +) -> Dict[str, Any]: + """Read the DB and emit causal (features, forward-return label) samples. + + Only (symbol,session) instances passing the ts_imb ENTRY gate at the first bar + (등락율>=2 AND 체결강도>=100 AND 매수총잔량 imbalance>=0.5) are used. For each, + decision seconds are sampled every ``sample_every_sec`` in [10, window_end_sec]; + features are the causal vector from rows<=t; the label is the forward + ``horizon_sec`` return. Returns arrays (X, y, dates, group_ids, secs). + """ + + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + from finetune_csv.stom_tick_dataset import connect_readonly, get_table_columns, list_stock_tables + + conn = connect_readonly(db_path) + feats_rows: List[List[float]] = [] + ys: List[float] = [] + dates: List[str] = [] + groups: List[str] = [] + secs_out: List[float] = [] + n_instances = 0 + try: + tables = list_stock_tables(conn, max_tables=max_symbols if max_symbols > 0 else None) + sel = ["index"] + [_COLS[k] for k in ( + "px", "cr", "strength", "buy_val", "sell_val", "buy_qty", "sell_qty", + "bid_tot", "ask_tot", "bid1", "ask1", "bidq1", "askq1")] + for table in tables: + cols = set(get_table_columns(conn, table)) + if not all(c in cols for c in sel[1:]): + continue + q_sel = ", ".join('"' + c.replace('"', '""') + '"' for c in sel) + date_q = 'SELECT DISTINCT substr(CAST("index" AS TEXT),1,8) FROM "%s"' % table.replace('"', '""') + sessions = [str(r[0]) for r in conn.execute(date_q).fetchall() + if r[0] is not None and len(str(r[0])) == 8 and str(r[0]).isdigit()] + qt = table.replace('"', '""') + cr_c, st_c = _COLS["cr"], _COLS["strength"] + bt_c, at_c, px_c = _COLS["bid_tot"], _COLS["ask_tot"], _COLS["px"] + # First VALID-price bar so the prefilter gate is EXACTLY the in-loop + # gate (entry = first row with price>0), not a data-dependent superset. + prefilter_q = ( + 'SELECT "%s","%s","%s","%s" FROM "%s" WHERE "index">=? AND "index"<=? ' + 'AND "%s">0 ORDER BY "index" LIMIT 1' % (cr_c, st_c, bt_c, at_c, qt, px_c) + ) + for sess in sorted(sessions): + lo, hi = int(sess + "090000"), int(sess + "093000") + # Cheap first-bar pre-filter: skip the full ~1000-bar pull unless the + # entry bar already passes the ts_imb gate (the vast majority fail). + pre = conn.execute(prefilter_q, (lo, hi)).fetchall() + if not pre: + continue + pcr, pts, pbt, pat = pre[0] + if pcr is None or float(pcr) < entry_cr_thr or pts is None or float(pts) < strength_thr: + continue + psum = (float(pbt) if pbt is not None else 0.0) + (float(pat) if pat is not None else 0.0) + if psum <= 0 or (float(pbt) if pbt is not None else 0.0) / psum < imbalance_thr: + continue + q = ('SELECT %s FROM "%s" WHERE "index">=? AND "index"<=? ORDER BY "index"' + % (q_sel, qt)) + raw = conn.execute(q, (lo, hi)).fetchall() + if len(raw) < 120: + continue + rows: List[Dict[str, Any]] = [] + for r in raw: + px = r[1] + if px is None or float(px) <= 0: + continue + if len(str(int(r[0]))) != 14: # malformed index -> skip row, not abort + continue + rows.append({ + "sec": _sec_of(r[0]), "price": float(px), "cr": r[2], "ts": r[3], + "buy_val": r[4], "sell_val": r[5], "buy_qty": r[6], "sell_qty": r[7], + "bid_tot": r[8], "ask_tot": r[9], "bid1": r[10], "ask1": r[11], + "bidq1": r[12], "askq1": r[13], + }) + if len(rows) < 120: + continue + e = rows[0] + imb = None + if e["bid_tot"] is not None and e["ask_tot"] is not None: + s = float(e["bid_tot"]) + float(e["ask_tot"]) + imb = (float(e["bid_tot"]) / s) if s > 0 else None + if (e["cr"] is None or float(e["cr"]) < entry_cr_thr + or e["ts"] is None or float(e["ts"]) < strength_thr + or imb is None or imb < imbalance_thr): + continue + n_instances += 1 + secs_arr = [rr["sec"] for rr in rows] + gid = "%s_%s" % (table, sess) + year = sess[:4] + ti = 0 + last_pair = None + for tsec in range(sample_every_sec, window_end_sec + 1, sample_every_sec): + while ti + 1 < len(rows) and secs_arr[ti + 1] <= tsec: + ti += 1 + if secs_arr[ti] > tsec: + continue + fj = ti + target = tsec + horizon_sec + while fj + 1 < len(rows) and secs_arr[fj + 1] <= target: + fj += 1 + if fj <= ti: + continue + if (ti, fj) == last_pair: # sparse window -> skip duplicate (feature,label) + continue + last_pair = (ti, fj) + label = (rows[fj]["price"] / rows[ti]["price"] - 1.0) * 100.0 + fv = causal_feature_vector(rows[:ti + 1]) + feats_rows.append([fv[k] for k in FEATURE_NAMES]) + ys.append(label) + dates.append(sess) + groups.append(gid) + secs_out.append(float(tsec)) + finally: + conn.close() + return { + "X": np.array(feats_rows, dtype=float) if feats_rows else np.zeros((0, len(FEATURE_NAMES))), + "y": np.array(ys, dtype=float), + "dates": dates, "group_ids": groups, "secs": np.array(secs_out, dtype=float), + "feature_names": FEATURE_NAMES, "n_instances": n_instances, + "horizon_sec": horizon_sec, + } + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + root = Path(__file__).resolve().parents[1] + p = argparse.ArgumentParser(description="P0+P1 predictability gate (RULE NOT RL).") + p.add_argument("--db", default=str(root / "_database" / "stock_tick_back.db")) + p.add_argument("--max-symbols", type=int, default=0) + p.add_argument("--horizon-sec", type=int, default=60) + p.add_argument("--sample-every-sec", type=int, default=10) + p.add_argument("--json-out", default=str(root / ".omx" / "artifacts" / "predictability" / "summary.json")) + args = p.parse_args(argv) + + print("=== P0+P1 within-window predictability gate (RULE strategy, NOT RL) ===") + data = extract_samples( + args.db, max_symbols=args.max_symbols, + horizon_sec=args.horizon_sec, sample_every_sec=args.sample_every_sec, + ) + print("instances(ts_imb)=%d samples=%d groups=%d features=%d horizon=%ds" + % (data["n_instances"], len(data["y"]), len(set(data["group_ids"])), + len(data["feature_names"]), data["horizon_sec"])) + if len(data["y"]) < 200: + print("too few samples — abort") + return 0 + + # P0: MinTRL admissibility for a few target incremental edges (sd_diff ~1.2%). + print("-- P0 MinTRL admissibility (sd_diff~1.2%/trade, prob 0.95) --") + for edge in (0.10, 0.20, 0.30): + need = min_track_record_length(edge / SD_DIFF_PCT, prob=0.95) + print(" target +%.2f%%/trade -> needs %.0f trades (have %d episodes)" + % (edge, need, data["n_instances"])) + + res = run_probe(data["X"], data["y"], data["dates"], data["group_ids"], data["secs"], + cost_pct=DEFAULT_COST_PCT) + print("-- P1 supervised probe (purged walk-forward, primary boundary %.1f) --" % res["primary_boundary"]) + for name, m in res.get("models", {}).items(): + print(" %-5s IC=%+.4f CI95=[%+.4f,%+.4f] excl0=%s | trades=%d net=%s sharpe=%s DSR=%s -> %s" + % (name, m["ic_primary"], m["ic_ci95"][0], m["ic_ci95"][1], m["ic_ci_excludes_zero"], + m["n_policy_trades"], + ("%+.3f%%" % m["policy_mean_net_pct"]) if m["policy_mean_net_pct"] is not None else "n/a", + ("%.3f" % m["policy_sharpe"]) if m["policy_sharpe"] is not None else "n/a", + ("%.3f" % m["policy_dsr"]) if m["policy_dsr"] is not None else "n/a", + "GO" if m["go"] else "no")) + print(" per-boundary IC:", {k: {b: (round(v, 4) if v is not None else None) for b, v in d.items()} + for k, d in res["per_boundary_ic"].items()}) + if res.get("symbol_disjoint_ic"): + print(" symbol-disjoint IC (unseen tickers):", + {k: round(v, 4) for k, v in res["symbol_disjoint_ic"].items()}) + print(" DSR sharpe_variance (data-derived):", round(res.get("sharpe_variance", 0.0), 4)) + print("\nVERDICT: %s (GO needs IC CI>0 AND net-of-cost DSR>0.95; else STOP — no deep RL)" % res["verdict"]) + + outp = Path(args.json_out) + outp.parent.mkdir(parents=True, exist_ok=True) + res["n_instances"] = data["n_instances"] + res["horizon_sec"] = data["horizon_sec"] + outp.write_text(json.dumps(res, ensure_ascii=False, indent=2, default=float), encoding="utf-8") + print("wrote -> %s" % outp) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/risk_gate.py b/stom_rl/risk_gate.py new file mode 100644 index 000000000..448f47d54 --- /dev/null +++ b/stom_rl/risk_gate.py @@ -0,0 +1,87 @@ +"""Portfolio-level risk gate and blocked-action reason codes.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any, Dict, Mapping, Optional + + +@dataclass(frozen=True) +class RiskGateConfig: + max_drawdown_pct: float = 20.0 + max_consecutive_losses: int = 3 + max_daily_trades: int = 20 + max_position_weight: float = 0.50 + max_positions: int = 5 + + +@dataclass +class RiskState: + peak_nav: float + consecutive_losses: int = 0 + daily_trade_count: int = 0 + last_nav: Optional[float] = None + + +class RiskGate: + """Checks proposed portfolio actions before execution.""" + + def __init__(self, config: Optional[RiskGateConfig] = None) -> None: + self.config = config or RiskGateConfig() + + def evaluate( + self, + *, + action_type: str, + nav: float, + position_count: int, + proposed_weight: float = 0.0, + state: RiskState, + ) -> Dict[str, Any]: + nav = float(nav) + state.peak_nav = max(float(state.peak_nav), nav) + drawdown_pct = 0.0 + if state.peak_nav > 0: + drawdown_pct = (nav / state.peak_nav - 1.0) * 100.0 + + allowed = True + reason = "" + if drawdown_pct <= -abs(float(self.config.max_drawdown_pct)): + allowed = False + reason = "max_drawdown" + elif state.consecutive_losses >= int(self.config.max_consecutive_losses): + allowed = False + reason = "consecutive_losses" + elif action_type == "buy" and state.daily_trade_count >= int(self.config.max_daily_trades): + allowed = False + reason = "daily_trade_count" + elif action_type == "buy" and position_count >= int(self.config.max_positions): + allowed = False + reason = "max_positions" + elif action_type == "buy" and proposed_weight > float(self.config.max_position_weight): + allowed = False + reason = "max_position_weight" + + return { + "allowed": allowed, + "reason": reason, + "drawdown_pct": drawdown_pct, + "state": asdict(state), + "config": asdict(self.config), + } + + def update_after_step(self, state: RiskState, *, nav_before: float, nav_after: float, traded: bool) -> RiskState: + state.peak_nav = max(float(state.peak_nav), float(nav_after)) + if float(nav_after) < float(nav_before): + state.consecutive_losses += 1 + elif float(nav_after) > float(nav_before): + state.consecutive_losses = 0 + if traded: + state.daily_trade_count += 1 + state.last_nav = float(nav_after) + return state + + +def state_from_info(info: Mapping[str, Any]) -> RiskState: + nav = float(info.get("nav", 0.0)) + return RiskState(peak_nav=nav, last_nav=nav) diff --git a/stom_rl/rl_events.py b/stom_rl/rl_events.py new file mode 100644 index 000000000..3aaa7f9da --- /dev/null +++ b/stom_rl/rl_events.py @@ -0,0 +1,170 @@ +"""JSONL event helpers for realtime STOM reinforcement-learning views.""" + +from __future__ import annotations + +import json +import math +from collections import Counter +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping, Optional, Tuple + + +SCHEMA_VERSION = "stom_rl_live_event.v1" +ACTION_LABELS = {0: "hold", 1: "buy", 2: "sell"} +MAX_EVENT_LIMIT = 10_000 + + +def utc_now_iso() -> str: + """Return a compact timezone-aware UTC timestamp.""" + + return datetime.now(tz=timezone.utc).isoformat() + + +def clean_json_value(value: Any) -> Any: + """Convert numpy/scalar values to JSON-safe values and drop NaN/inf.""" + + if value is None: + return None + if isinstance(value, (str, bool, int)): + return value + if isinstance(value, float): + return value if math.isfinite(value) else None + if hasattr(value, "item"): + return clean_json_value(value.item()) + if isinstance(value, Mapping): + return {str(k): clean_json_value(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [clean_json_value(v) for v in value] + try: + number = float(value) + except (TypeError, ValueError): + return str(value) + return number if math.isfinite(number) else None + + +def action_label(action: Any) -> str: + """Return a stable label for a STOM discrete action.""" + + try: + return ACTION_LABELS.get(int(action), str(action)) + except (TypeError, ValueError): + return str(action) + + +@dataclass(frozen=True) +class RlLiveEvent: + """Single JSONL event consumed by the realtime RL dashboard.""" + + run_id: str + algorithm: str + phase: str + global_step: int + action: Optional[int] = None + reward: Optional[float] = None + episode: Optional[int] = None + episode_id: Optional[str] = None + timestamp: Optional[str] = None + price: Optional[float] = None + position: Optional[float] = None + equity: Optional[float] = None + loss: Optional[float] = None + exploration: Optional[float] = None + source: str = "sb3_smoke" + schema_version: str = SCHEMA_VERSION + timestamp_utc: str = field(default_factory=utc_now_iso) + info: Dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> Dict[str, Any]: + payload = asdict(self) + payload["action_name"] = action_label(self.action) if self.action is not None else None + return {key: clean_json_value(value) for key, value in payload.items()} + + +class RlLiveEventWriter: + """Append-only JSONL writer for RL live events.""" + + def __init__(self, path: str | Path, *, run_id: str, enabled: bool = True): + self.path = Path(path) + self.run_id = str(run_id) + self.enabled = bool(enabled) + + def reset(self) -> None: + if not self.enabled: + return + self.path.parent.mkdir(parents=True, exist_ok=True) + self.path.write_text("", encoding="utf-8") + + def write(self, event: RlLiveEvent | Mapping[str, Any]) -> None: + if not self.enabled: + return + self.path.parent.mkdir(parents=True, exist_ok=True) + payload = event.to_dict() if isinstance(event, RlLiveEvent) else clean_json_value(dict(event)) + with self.path.open("a", encoding="utf-8", newline="") as f: + f.write(json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n") + + def write_step(self, *, algorithm: str, phase: str, global_step: int, **kwargs: Any) -> None: + self.write( + RlLiveEvent( + run_id=self.run_id, + algorithm=str(algorithm), + phase=str(phase), + global_step=int(global_step), + **kwargs, + ) + ) + + +def read_live_events(path: str | Path, *, limit: int = 500, tail: bool = True) -> Tuple[List[Dict[str, Any]], bool]: + """Read JSONL events with a bounded limit and malformed-line tolerance.""" + + path = Path(path) + limit = max(0, min(int(limit), MAX_EVENT_LIMIT)) + if not path.is_file() or limit == 0: + return [], False + lines = path.read_text(encoding="utf-8").splitlines() + truncated = len(lines) > limit + source = reversed(lines) if tail else iter(lines) + rows: List[Dict[str, Any]] = [] + for line in source: + if len(rows) >= limit: + break + if not line.strip(): + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + rows.append(payload) + if tail: + rows.reverse() + return rows, truncated + + +def summarize_live_events(events: Iterable[Mapping[str, Any]]) -> Dict[str, Any]: + rows = list(events) + phases = Counter(str(row.get("phase") or "unknown") for row in rows) + algorithms = Counter(str(row.get("algorithm") or "unknown") for row in rows) + actions = Counter(str(row.get("action_name") or action_label(row.get("action"))) for row in rows) + rewards = [float(row["reward"]) for row in rows if row.get("reward") is not None] + equities = [float(row["equity"]) for row in rows if row.get("equity") is not None] + latest = rows[-1] if rows else {} + return { + "schema_version": SCHEMA_VERSION, + "event_count": len(rows), + "phases": dict(sorted(phases.items())), + "algorithms": dict(sorted(algorithms.items())), + "actions": dict(sorted(actions.items())), + "avg_reward": sum(rewards) / len(rewards) if rewards else 0.0, + "latest_equity": equities[-1] if equities else None, + "latest_event": dict(latest), + } + + +def summarize_live_event_file(path: str | Path, *, limit: int = MAX_EVENT_LIMIT) -> Dict[str, Any]: + rows, truncated = read_live_events(path, limit=limit, tail=False) + summary = summarize_live_events(rows) + summary["truncated"] = truncated + return summary diff --git a/stom_rl/rules/buy_demand_pressure.json b/stom_rl/rules/buy_demand_pressure.json new file mode 100644 index 000000000..52498824b --- /dev/null +++ b/stom_rl/rules/buy_demand_pressure.json @@ -0,0 +1,12 @@ +{ + "description": "Order-flow demand-pressure buy filter derived from STOM 복합조건 호가상승압력/체결강도급등 family (variables_reference.md). Captures the intent of '매수 우위 + 호가 상승 압력' using canonical ASCII features: 초당매수수량->buy_qty_1s, 초당매도수량->sell_qty_1s, 호가불균형(매수총잔량/(매수+매도총잔량))->bid_ask_imbalance, 체결강도->trade_strength. A candidate is a buy when intraday buying out-paces selling, the order book leans bid-side, and trade strength is at par or above.", + "source": "docs/reference/stom_ai_agent/variables_reference.md (호가상승압력, 체결강도, 초당매수/매도수량, 호가불균형)", + "rules": [ + { + "condition_id": "buy_demand_pressure", + "side": "buy", + "expression": "buy_qty_1s > sell_qty_1s and bid_ask_imbalance >= 0.5 and trade_strength >= 100", + "rank_expression": "trade_strength * bid_ask_imbalance" + } + ] +} diff --git a/stom_rl/rules/buy_widev1.json b/stom_rl/rules/buy_widev1.json new file mode 100644 index 000000000..b7abbfc4e --- /dev/null +++ b/stom_rl/rules/buy_widev1.json @@ -0,0 +1,12 @@ +{ + "description": "Canonical STOM buy filter from docs/reference/stom_ai_agent/examples.md section 1 (strategy.txt regular form). Korean variables mapped to Kronos canonical ASCII features: 당일거래대금->amount, 체결강도->trade_strength. The original also gates 등락율/고저평균대비등락율/체결강도평균(30); those have no canonical-feature column, so only the whitelisted-mappable demand filters are kept (more permissive, never references unmapped names).", + "source": "docs/reference/stom_ai_agent/examples.md#1, strategy.txt", + "rules": [ + { + "condition_id": "buy_widev1", + "side": "buy", + "expression": "amount >= 100 and trade_strength >= 100", + "rank_expression": "trade_strength" + } + ] +} diff --git a/stom_rl/rules/buy_widev2.json b/stom_rl/rules/buy_widev2.json new file mode 100644 index 000000000..c677183ba --- /dev/null +++ b/stom_rl/rules/buy_widev2.json @@ -0,0 +1,12 @@ +{ + "description": "STOM WideV2Final_B-style buy filter from docs/reference/stom_ai_agent/WideV2Final_B_20260428.py + examples.md section 4. The auto-generated WideV2 line adds a stronger demand gate on top of WideV1. Korean variables mapped to canonical ASCII features: 당일거래대금->amount, 체결강도->trade_strength, 순매수수량(초당매수수량-초당매도수량)->net_buy_qty_1s. The 시가총액/등락율 numeric bands have no canonical-feature column and are intentionally omitted (those gate by market-cap/price-change which are not in the 14 canonical features).", + "source": "docs/reference/stom_ai_agent/WideV2Final_B_20260428.py, examples.md#4", + "rules": [ + { + "condition_id": "buy_widev2", + "side": "buy", + "expression": "amount > 100 and trade_strength >= 100 and net_buy_qty_1s > 0", + "rank_expression": "net_buy_qty_1s" + } + ] +} diff --git a/stom_rl/sb3_adapter.py b/stom_rl/sb3_adapter.py new file mode 100644 index 000000000..22ccd8324 --- /dev/null +++ b/stom_rl/sb3_adapter.py @@ -0,0 +1,91 @@ +"""Gymnasium/Stable-Baselines3 adapter for the STOM tick trading env.""" + +from __future__ import annotations + +from typing import Any, Mapping, Optional, Tuple + +import gymnasium as gym +import numpy as np +from gymnasium import spaces + +from .trading_env import StomTickTradingEnv, StomTickTradingEnvConfig + + +class StomTickTradingGymEnv(gym.Env): + """Thin Gymnasium wrapper around :class:`StomTickTradingEnv`. + + ``StomTickTradingEnv`` deliberately stayed dependency-light for the first + RL pages. This wrapper owns the real Gymnasium spaces/inheritance needed by + Stable-Baselines3 while delegating all trading semantics to the base env. + """ + + metadata = {"render_modes": []} + + def __init__(self, config: Optional[StomTickTradingEnvConfig] = None, **overrides: Any): + if config is not None and overrides: + raise ValueError("Pass either config or keyword overrides, not both.") + self.raw_env = StomTickTradingEnv(config, **overrides) + self.action_space = spaces.Discrete(self.raw_env.action_space.n) + self.observation_space = spaces.Box( + low=-np.inf, + high=np.inf, + shape=self.raw_env.observation_space.shape, + dtype=np.float32, + ) + + @property + def config(self) -> StomTickTradingEnvConfig: + return self.raw_env.config + + def reset( + self, + *, + seed: Optional[int] = None, + options: Optional[Mapping[str, Any]] = None, + ) -> Tuple[np.ndarray, dict[str, Any]]: + super().reset(seed=seed) + observation, info = self.raw_env.reset(seed=seed, options=options) + return np.asarray(observation, dtype=np.float32), dict(info) + + def step(self, action: Any) -> Tuple[np.ndarray, float, bool, bool, dict[str, Any]]: + action_int = int(np.asarray(action).item()) + observation, reward, terminated, truncated, info = self.raw_env.step(action_int) + return np.asarray(observation, dtype=np.float32), float(reward), bool(terminated), bool(truncated), dict(info) + + def render(self) -> None: + return None + + def close(self) -> None: + return None + + +def make_sb3_env( + manifest_path: str, + *, + split: str = "train", + seed: int = 100, + episode_index: Optional[int] = None, + lookback_window: int = 300, + reward_horizon_seconds: int = 300, + cost_bps: float = 25.0, + slippage_bps: float = 0.0, + reward_mode: str = "horizon", +) -> StomTickTradingGymEnv: + """Create a Gymnasium-compatible STOM env for SB3 training/evaluation.""" + + return StomTickTradingGymEnv( + StomTickTradingEnvConfig( + manifest_path=manifest_path, + split=split, + seed=seed, + episode_index=episode_index, + lookback_window=lookback_window, + reward_horizon_seconds=reward_horizon_seconds, + cost_bps=cost_bps, + slippage_bps=slippage_bps, + reward_mode=reward_mode, + ) + ) + + +__all__ = ["StomTickTradingGymEnv", "make_sb3_env"] diff --git a/stom_rl/sb3_eval.py b/stom_rl/sb3_eval.py new file mode 100644 index 000000000..bfbb5cff9 --- /dev/null +++ b/stom_rl/sb3_eval.py @@ -0,0 +1,358 @@ +"""Eval-only re-evaluation of saved Stable-Baselines3 STOM models (no retraining).""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +from .episode_manifest import DEFAULT_OUTPUT_DIR +from .rl_events import RlLiveEventWriter, summarize_live_event_file +from .sb3_smoke import ( + DEFAULT_ALGORITHMS, + Sb3SmokeConfig, + _evaluate_model, + _summarize_model, + _torch_runtime, + _write_csv, + _write_json, +) + + +DEFAULT_SB3_EVAL_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_1s_2025_sb3_eval" + + +@dataclass(frozen=True) +class Sb3EvalConfig: + """Configuration for re-evaluating saved SB3 models on more episodes.""" + + model_dir: str + algorithms: Tuple[str, ...] = ("dqn", "ppo") + eval_episodes: int = 100 + max_eval_steps_per_episode: int = 2048 + manifest_path: str = str(DEFAULT_OUTPUT_DIR / "episode_manifest.json") + eval_split: str = "test" + seed: int = 100 + lookback_window: int = 300 + reward_horizon_seconds: int = 300 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + device: str = "auto" + output_dir: Optional[str] = None + write_artifacts: bool = True + write_live_events: bool = True + live_event_sample_interval: int = 1 + source_run: Optional[str] = None + + +def _parse_algorithms(raw: str) -> Tuple[str, ...]: + algorithms = tuple(part.strip().lower() for part in raw.split(",") if part.strip()) + unknown = sorted(set(algorithms) - set(DEFAULT_ALGORITHMS)) + if unknown: + raise ValueError(f"Unknown SB3 algorithms: {unknown}. Available: {sorted(DEFAULT_ALGORITHMS)}") + return algorithms + + +def _source_model_name(algorithm: str, training_timesteps: int, fallback: Optional[str]) -> str: + """Mirror performance_leaderboard._sb3_model_name suffix logic for the source model.""" + + timesteps = int(training_timesteps) + if timesteps >= 1000: + suffix = f"{timesteps // 1000}k" if timesteps % 1000 == 0 else str(timesteps) + return f"{algorithm}_{suffix}" + return str(fallback) if fallback else f"{algorithm}_smoke" + + +def _resolve_output_dir(config: Sb3EvalConfig) -> Path: + if config.output_dir: + return Path(config.output_dir) + return Path("webui") / "rl_runs" / f"stom_1s_2025_sb3_50k_eval{int(config.eval_episodes)}" + + +def _load_source_summary(model_dir: Path) -> Dict[str, Any]: + summary_path = model_dir / "sb3_smoke_summary.json" + if not summary_path.is_file(): + return {} + try: + return json.loads(summary_path.read_text(encoding="utf-8-sig")) + except (json.JSONDecodeError, OSError): + return {} + + +def _source_row_for_algorithm(source_summary: Dict[str, Any], algorithm: str) -> Dict[str, Any]: + for row in source_summary.get("models") or []: + if str(row.get("algorithm") or "").lower() == algorithm: + return dict(row) + return {} + + +def _load_model(algorithm: str, model_path: Path, device: str) -> Any: + from stable_baselines3 import DQN, PPO + + loaders = {"dqn": DQN, "ppo": PPO} + loader = loaders.get(algorithm) + if loader is None: + raise ValueError(f"Unknown algorithm: {algorithm}") + return loader.load(str(model_path), device=device) + + +def run_sb3_eval(config: Sb3EvalConfig) -> Dict[str, Any]: + """Re-evaluate saved SB3 models on more episodes without any training.""" + + algorithms = tuple(config.algorithms) + if not algorithms: + raise ValueError("At least one SB3 algorithm is required.") + + model_dir = Path(config.model_dir) + source_run = config.source_run or model_dir.name + source_summary = _load_source_summary(model_dir) + output_dir = _resolve_output_dir(config) + runtime = _torch_runtime() + + model_summaries: List[Dict[str, Any]] = [] + all_actions: List[Dict[str, Any]] = [] + all_trades: List[Dict[str, Any]] = [] + all_equity: List[Dict[str, Any]] = [] + all_episodes: List[Dict[str, Any]] = [] + evaluated_algorithms: List[str] = [] + skipped: List[Dict[str, str]] = [] + + event_writer: Optional[RlLiveEventWriter] = None + live_events_path = output_dir / "rl_live_events.jsonl" + if config.write_artifacts and config.write_live_events: + event_writer = RlLiveEventWriter(live_events_path, run_id=output_dir.name) + event_writer.reset() + + for algorithm in algorithms: + model_path = model_dir / f"{algorithm}_model.zip" + if not model_path.is_file(): + message = f"Skipping {algorithm}: model file not found at {model_path}" + print(message) + skipped.append({"algorithm": algorithm, "reason": str(model_path)}) + continue + + source_row = _source_row_for_algorithm(source_summary, algorithm) + source_timesteps = int(float(source_row.get("training_timesteps") or 0)) + source_model = _source_model_name(algorithm, source_timesteps, source_row.get("model")) + + smoke_config = Sb3SmokeConfig( + manifest_path=config.manifest_path, + output_dir=str(output_dir), + eval_split=config.eval_split, + algorithms=(algorithm,), + total_timesteps=source_timesteps, + max_eval_episodes=config.eval_episodes, + max_eval_steps_per_episode=config.max_eval_steps_per_episode, + seed=config.seed, + lookback_window=config.lookback_window, + reward_horizon_seconds=config.reward_horizon_seconds, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + device=config.device, + write_artifacts=config.write_artifacts, + write_live_events=config.write_live_events, + live_event_sample_interval=config.live_event_sample_interval, + ) + + model = _load_model(algorithm, model_path, config.device) + evaluation = _evaluate_model(model, algorithm, smoke_config, event_writer=event_writer) + summary = _summarize_model( + algorithm=algorithm, + config=smoke_config, + episode_rows=evaluation["episodes"], + trade_rows=evaluation["trades"], + aggregate_equity_curve=evaluation["aggregate_equity_curve"], + training_elapsed_seconds=0.0, + model_name=f"{source_model}_eval", + eval_only=True, + source_run=source_run, + source_model=source_model, + is_smoke=False, + ) + model_summaries.append(summary) + all_actions.extend(evaluation["actions"]) + all_trades.extend(evaluation["trades"]) + all_equity.extend(evaluation["equity"]) + all_episodes.extend(evaluation["episodes"]) + evaluated_algorithms.append(algorithm) + + ranking = sorted(model_summaries, key=lambda row: float(row["avg_episode_net_return_pct"]), reverse=True) + payload: Dict[str, Any] = { + "mode": "stom_rl_sb3_eval", + "config": asdict(config), + "runtime": runtime, + "summary": { + "eval_only": True, + "algorithm_count": len(evaluated_algorithms), + "algorithms": list(evaluated_algorithms), + "skipped_algorithms": skipped, + "source_run": source_run, + "eval_episodes": int(config.eval_episodes), + "best_algorithm_by_avg_episode_net": ranking[0]["algorithm"] if ranking else None, + "best_model": ranking[0]["model"] if ranking else None, + "device_requested": config.device, + "cuda_available": runtime["cuda_available"], + }, + "models": model_summaries, + "artifacts": { + "output_dir": str(output_dir), + "summary_json": str(output_dir / "sb3_smoke_summary.json"), + "summary_csv": str(output_dir / "sb3_smoke_summary.csv"), + "actions_csv": str(output_dir / "actions.csv"), + "trades_csv": str(output_dir / "trades.csv"), + "equity_csv": str(output_dir / "equity.csv"), + "episodes_csv": str(output_dir / "episodes.csv"), + "live_events_jsonl": str(live_events_path), + "live_summary_json": str(output_dir / "rl_live_summary.json"), + }, + } + if config.write_artifacts and config.write_live_events: + live_summary = summarize_live_event_file(live_events_path) + payload["live_events"] = live_summary + payload["summary"]["live_event_count"] = live_summary["event_count"] + payload["summary"]["live_event_phases"] = live_summary["phases"] + _write_json(output_dir / "rl_live_summary.json", live_summary) + if config.write_artifacts: + _write_json(output_dir / "sb3_smoke_summary.json", payload) + _write_csv( + output_dir / "sb3_smoke_summary.csv", + model_summaries, + [ + "algorithm", + "model", + "policy", + "eval_split", + "training_timesteps", + "training_elapsed_seconds", + "episode_count", + "trade_count", + "trades_per_episode", + "avg_episode_net_return_pct", + "median_episode_net_return_pct", + "compounded_return_pct", + "avg_trade_net_return_pct", + "hit_rate", + "max_drawdown_pct", + "passes_cost_gate", + "is_smoke", + "eval_only", + "source_run", + "source_model", + "eval_episode_count", + "cost_bps", + "slippage_bps", + ], + ) + _write_csv( + output_dir / "actions.csv", + all_actions, + [ + "model", + "algorithm", + "policy", + "episode_id", + "symbol", + "session", + "step_idx", + "timestamp", + "price", + "action", + "action_name", + "position_after", + "env_reward", + "mark_equity", + "invalid_action", + ], + ) + _write_csv( + output_dir / "trades.csv", + all_trades, + [ + "model", + "algorithm", + "policy", + "episode_id", + "entry_timestamp", + "exit_timestamp", + "entry_price", + "exit_price", + "gross_return_pct", + "net_return_pct", + "cost_pct", + "forced_exit", + ], + ) + _write_csv( + output_dir / "equity.csv", + all_equity, + ["model", "algorithm", "policy", "episode_id", "timestamp", "equity", "position"], + ) + _write_csv( + output_dir / "episodes.csv", + all_episodes, + [ + "model", + "algorithm", + "policy", + "episode_id", + "symbol", + "session", + "final_equity", + "episode_return_pct", + "trade_count", + "forced_exit_count", + "steps", + ], + ) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> Sb3EvalConfig: + parser = argparse.ArgumentParser( + description="Re-evaluate saved STOM SB3 models on more episodes without retraining.", + ) + parser.add_argument("--model-dir", required=True, help="Directory containing {algo}_model.zip and source summary.") + parser.add_argument("--algorithms", default=",".join(("dqn", "ppo"))) + parser.add_argument("--eval-episodes", type=int, default=100) + parser.add_argument("--max-eval-steps-per-episode", type=int, default=2048) + parser.add_argument("--output-dir", default=None) + parser.add_argument("--manifest", default=str(DEFAULT_OUTPUT_DIR / "episode_manifest.json")) + parser.add_argument("--eval-split", default="test") + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--cost-bps", type=float, default=25.0) + parser.add_argument("--slippage-bps", type=float, default=0.0) + parser.add_argument("--device", default="auto") + parser.add_argument("--no-write", action="store_true") + parser.add_argument("--no-live-events", action="store_true") + parser.add_argument("--live-event-sample-interval", type=int, default=1) + parser.add_argument("--source-run", default=None) + args = parser.parse_args(argv) + return Sb3EvalConfig( + model_dir=args.model_dir, + algorithms=_parse_algorithms(args.algorithms), + eval_episodes=args.eval_episodes, + max_eval_steps_per_episode=args.max_eval_steps_per_episode, + manifest_path=args.manifest, + eval_split=args.eval_split, + seed=args.seed, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + device=args.device, + output_dir=args.output_dir, + write_artifacts=not args.no_write, + write_live_events=not args.no_live_events, + live_event_sample_interval=args.live_event_sample_interval, + source_run=args.source_run, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_sb3_eval(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/sb3_smoke.py b/stom_rl/sb3_smoke.py new file mode 100644 index 000000000..7d7358fd5 --- /dev/null +++ b/stom_rl/sb3_smoke.py @@ -0,0 +1,685 @@ +"""Stable-Baselines3 smoke training for the STOM trading environment.""" + +from __future__ import annotations + +import argparse +import csv +import json +import time +from dataclasses import asdict, dataclass +from math import exp, log +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +import numpy as np + +from .baselines import AccountState +from .episode_manifest import DEFAULT_OUTPUT_DIR +from .rl_events import RlLiveEventWriter, summarize_live_event_file +from .sb3_adapter import StomTickTradingGymEnv, make_sb3_env + + +DEFAULT_SB3_SMOKE_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_1s_2025_sb3_smoke" +DEFAULT_ALGORITHMS = ("dqn", "ppo") + + +@dataclass(frozen=True) +class Sb3SmokeConfig: + """Configuration for dependency, env-check, DQN, and PPO smoke validation.""" + + manifest_path: str = str(DEFAULT_OUTPUT_DIR / "episode_manifest.json") + output_dir: str = str(DEFAULT_SB3_SMOKE_OUTPUT_DIR) + train_split: str = "train" + eval_split: str = "test" + algorithms: Tuple[str, ...] = DEFAULT_ALGORITHMS + total_timesteps: int = 256 + max_eval_episodes: int = 2 + max_eval_steps_per_episode: int = 256 + seed: int = 100 + lookback_window: int = 300 + reward_horizon_seconds: int = 300 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + device: str = "auto" + dqn_learning_starts: int = 16 + dqn_buffer_size: int = 1024 + dqn_batch_size: int = 32 + ppo_n_steps: int = 64 + ppo_batch_size: int = 32 + ppo_n_epochs: int = 1 + min_trade_count: int = 1 + min_avg_episode_net_pct: float = 0.0 + max_drawdown_pct: float = 20.0 + write_artifacts: bool = True + write_live_events: bool = True + live_event_sample_interval: int = 1 + + +def _write_json(path: Path, payload: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8-sig") + + +def _write_csv(path: Path, rows: Sequence[Mapping[str, Any]], fieldnames: Sequence[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore") + writer.writeheader() + writer.writerows(rows) + + +def _parse_algorithms(raw: str) -> Tuple[str, ...]: + algorithms = tuple(part.strip().lower() for part in raw.split(",") if part.strip()) + unknown = sorted(set(algorithms) - set(DEFAULT_ALGORITHMS)) + if unknown: + raise ValueError(f"Unknown SB3 algorithms: {unknown}. Available: {sorted(DEFAULT_ALGORITHMS)}") + return algorithms + + +def _sb3_imports(): + from stable_baselines3 import DQN, PPO + from stable_baselines3.common.env_checker import check_env + + return DQN, PPO, check_env + + +def _torch_runtime() -> Dict[str, Any]: + import torch + + return { + "torch_version": torch.__version__, + "cuda_available": bool(torch.cuda.is_available()), + "cuda_version": torch.version.cuda, + "cuda_device_name": torch.cuda.get_device_name(0) if torch.cuda.is_available() else None, + } + + +def _make_env(config: Sb3SmokeConfig, *, split: str, episode_index: Optional[int] = None) -> StomTickTradingGymEnv: + return make_sb3_env( + config.manifest_path, + split=split, + seed=config.seed, + episode_index=episode_index, + lookback_window=config.lookback_window, + reward_horizon_seconds=config.reward_horizon_seconds, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + reward_mode="horizon", + ) + + +def _check_env(config: Sb3SmokeConfig) -> Dict[str, Any]: + _, _, check_env = _sb3_imports() + env = _make_env(config, split=config.train_split, episode_index=0) + try: + check_env(env, warn=True, skip_render_check=True) + return { + "passed": True, + "observation_space": str(env.observation_space), + "action_space": str(env.action_space), + "feature_columns": list(env.raw_env.feature_columns), + } + finally: + env.close() + + +def _bounded_batch_size(value: int, *, upper: int) -> int: + return max(2, min(int(value), int(upper))) + + +def _train_model(algorithm: str, config: Sb3SmokeConfig, event_writer: Optional[RlLiveEventWriter] = None): + DQN, PPO, _ = _sb3_imports() + from stable_baselines3.common.callbacks import BaseCallback + + class LiveEventCallback(BaseCallback): + def __init__(self, writer: Optional[RlLiveEventWriter], *, algorithm: str, sample_interval: int): + super().__init__(verbose=0) + self.writer = writer + self.algorithm = algorithm + self.sample_interval = max(1, int(sample_interval)) + + def _on_step(self) -> bool: + if self.writer is None or self.num_timesteps % self.sample_interval: + return True + rewards = self.locals.get("rewards") + actions = self.locals.get("actions") + infos = self.locals.get("infos") + rewards = rewards if rewards is not None else [] + actions = actions if actions is not None else [] + infos = infos if infos is not None else [] + reward = rewards[0] if len(rewards) else None + action = actions[0] if len(actions) else None + info = dict(infos[0]) if len(infos) else {} + action_value: Optional[int] + if action is None: + action_value = None + elif hasattr(action, "item"): + action_value = int(action.item()) + else: + action_array = np.asarray(action) + action_value = int(action_array.item()) if action_array.size else None + self.writer.write_step( + algorithm=self.algorithm, + phase="train", + global_step=int(self.num_timesteps), + episode_id=info.get("episode_id"), + timestamp=info.get("action_timestamp"), + price=info.get("close"), + action=action_value, + reward=float(reward) if reward is not None else None, + position=info.get("position_after"), + equity=None, + exploration=getattr(self.model, "exploration_rate", None), + info={ + "event": info.get("event"), + "split": info.get("split"), + "current_idx": info.get("current_idx"), + "invalid_action": info.get("invalid_action"), + "trade_count": info.get("trade_count"), + }, + ) + return True + + env = _make_env(config, split=config.train_split) + policy_kwargs = {"net_arch": [64, 32]} + try: + if algorithm == "dqn": + learning_starts = max(1, min(int(config.dqn_learning_starts), max(1, int(config.total_timesteps) // 4))) + model = DQN( + "MlpPolicy", + env, + seed=config.seed, + device=config.device, + verbose=0, + learning_starts=learning_starts, + buffer_size=max(int(config.dqn_buffer_size), int(config.total_timesteps) * 2, 64), + batch_size=_bounded_batch_size(config.dqn_batch_size, upper=max(2, int(config.total_timesteps))), + train_freq=4, + gradient_steps=1, + target_update_interval=64, + exploration_fraction=0.4, + exploration_final_eps=0.05, + policy_kwargs=policy_kwargs, + ) + elif algorithm == "ppo": + n_steps = max(8, min(int(config.ppo_n_steps), max(8, int(config.total_timesteps)))) + model = PPO( + "MlpPolicy", + env, + seed=config.seed, + device=config.device, + verbose=0, + n_steps=n_steps, + batch_size=_bounded_batch_size(config.ppo_batch_size, upper=n_steps), + n_epochs=max(1, int(config.ppo_n_epochs)), + policy_kwargs=policy_kwargs, + ) + else: + raise ValueError(f"Unknown algorithm: {algorithm}") + started = time.perf_counter() + model.learn( + total_timesteps=int(config.total_timesteps), + progress_bar=False, + callback=LiveEventCallback( + event_writer, + algorithm=algorithm, + sample_interval=config.live_event_sample_interval, + ), + ) + elapsed = time.perf_counter() - started + if getattr(model, "env", None) is not None: + model.env.close() + return model, elapsed + finally: + env.close() + + +def _safe_compounded_return_pct(final_equities: Sequence[float]) -> float: + if not final_equities: + return 0.0 + total_log = sum(log(max(float(value), 1e-12)) for value in final_equities) + if total_log > 50: + return float("inf") + if total_log < -50: + return -100.0 + return (exp(total_log) - 1.0) * 100.0 + + +def _max_drawdown_pct(equity_values: Sequence[float]) -> float: + peak = 1.0 + max_dd = 0.0 + for value in equity_values: + value = float(value) + peak = max(peak, value) + if peak > 0: + max_dd = min(max_dd, (value / peak) - 1.0) + return max_dd * 100.0 + + +def _summarize_model( + *, + algorithm: str, + config: Sb3SmokeConfig, + episode_rows: Sequence[Mapping[str, Any]], + trade_rows: Sequence[Mapping[str, Any]], + aggregate_equity_curve: Sequence[float], + training_elapsed_seconds: float, + model_name: Optional[str] = None, + eval_only: bool = False, + source_run: Optional[str] = None, + source_model: Optional[str] = None, + is_smoke: Optional[bool] = None, +) -> Dict[str, Any]: + returns = np.asarray([float(row["episode_return_pct"]) for row in episode_rows], dtype=np.float64) + final_equities = [float(row["final_equity"]) for row in episode_rows] + trade_returns = np.asarray([float(row["net_return_pct"]) for row in trade_rows], dtype=np.float64) + avg_net = float(np.mean(returns)) if len(returns) else 0.0 + max_dd = _max_drawdown_pct(aggregate_equity_curve) + trade_count = len(trade_rows) + episode_count = len(episode_rows) + passes_cost_gate = bool( + avg_net > float(config.min_avg_episode_net_pct) + and max_dd >= -abs(float(config.max_drawdown_pct)) + and trade_count >= int(config.min_trade_count) + ) + resolved_is_smoke = bool(is_smoke) if is_smoke is not None else True + summary: Dict[str, Any] = { + "algorithm": algorithm, + "model": str(model_name) if model_name is not None else f"{algorithm}_smoke", + "policy": f"stable_baselines3_{algorithm}", + "eval_split": config.eval_split, + "training_timesteps": int(config.total_timesteps), + "training_elapsed_seconds": float(training_elapsed_seconds), + "episode_count": episode_count, + "trade_count": trade_count, + "trades_per_episode": float(trade_count / episode_count) if episode_rows else 0.0, + "avg_episode_net_return_pct": avg_net, + "median_episode_net_return_pct": float(np.median(returns)) if len(returns) else 0.0, + "compounded_return_pct": _safe_compounded_return_pct(final_equities), + "avg_trade_net_return_pct": float(np.mean(trade_returns)) if len(trade_returns) else 0.0, + "hit_rate": float(np.mean(trade_returns > 0.0)) if len(trade_returns) else 0.0, + "max_drawdown_pct": max_dd, + "passes_cost_gate": passes_cost_gate, + "is_smoke": resolved_is_smoke, + "eval_only": bool(eval_only), + "source_run": source_run, + "source_model": source_model, + "cost_bps": float(config.cost_bps), + "slippage_bps": float(config.slippage_bps), + } + if eval_only: + summary["eval_episode_count"] = episode_count + return summary + + +def _evaluate_model( + model: Any, + algorithm: str, + config: Sb3SmokeConfig, + event_writer: Optional[RlLiveEventWriter] = None, + episode_indices: Optional[Sequence[int]] = None, +) -> Dict[str, Any]: + if episode_indices is None: + probe_env = _make_env(config, split=config.eval_split) + eval_episode_count = len(probe_env.raw_env.episodes) + probe_env.close() + if config.max_eval_episodes and config.max_eval_episodes > 0: + eval_episode_count = min(eval_episode_count, int(config.max_eval_episodes)) + evaluation_indices: Sequence[int] = range(eval_episode_count) + else: + evaluation_indices = list(episode_indices) + + action_rows: List[Dict[str, Any]] = [] + equity_rows: List[Dict[str, Any]] = [] + trade_rows: List[Dict[str, Any]] = [] + episode_rows: List[Dict[str, Any]] = [] + aggregate_equity_curve = [1.0] + policy_name = f"stable_baselines3_{algorithm}" + + for episode_index in evaluation_indices: + env = _make_env(config, split=config.eval_split, episode_index=episode_index) + observation, info = env.reset(seed=config.seed + episode_index) + raw_env = env.raw_env + account = AccountState(cost_pct=(config.cost_bps + config.slippage_bps) / 10_000.0) + terminated = False + truncated = False + step_counter = 0 + episode_equity_curve = [1.0] + + while not (terminated or truncated): + if config.max_eval_steps_per_episode and step_counter >= int(config.max_eval_steps_per_episode): + break + price = raw_env._close_at(raw_env.current_idx) + timestamp = raw_env._timestamp_at(raw_env.current_idx) + action, _ = model.predict(observation, deterministic=True) + action_int = int(np.asarray(action).item()) + observation, reward, terminated, truncated, step_info = env.step(action_int) + trade = account.apply_action( + action=action_int, + price=price, + timestamp=timestamp, + episode_id=str(info["episode_id"]), + policy=policy_name, + ) + if trade: + trade.update({"model": f"{algorithm}_smoke", "algorithm": algorithm}) + trade_rows.append(trade) + mark_equity = account.mark_equity(price) + episode_equity_curve.append(mark_equity) + action_row = { + "model": f"{algorithm}_smoke", + "algorithm": algorithm, + "policy": policy_name, + "episode_id": info["episode_id"], + "symbol": info["symbol"], + "session": info["session"], + "step_idx": info["current_idx"], + "timestamp": timestamp, + "price": price, + "action": action_int, + "action_name": step_info.get("action_name"), + "position_after": account.position, + "env_reward": reward, + "mark_equity": mark_equity, + "invalid_action": step_info.get("invalid_action"), + } + action_rows.append(action_row) + if event_writer is not None: + event_writer.write_step( + algorithm=algorithm, + phase="eval", + global_step=len(action_rows), + episode=episode_index, + episode_id=str(info["episode_id"]), + timestamp=timestamp, + price=price, + action=action_int, + reward=float(reward), + position=account.position, + equity=mark_equity, + info={ + "symbol": info["symbol"], + "session": info["session"], + "step_idx": info["current_idx"], + "invalid_action": step_info.get("invalid_action"), + }, + ) + equity_rows.append( + { + "model": f"{algorithm}_smoke", + "algorithm": algorithm, + "policy": policy_name, + "episode_id": info["episode_id"], + "timestamp": timestamp, + "equity": mark_equity, + "position": account.position, + } + ) + info = step_info + step_counter += 1 + + if account.position: + idx = min(raw_env.current_idx, raw_env.max_action_idx) + forced_trade = account.apply_action( + action=2, + price=raw_env._close_at(idx), + timestamp=raw_env._timestamp_at(idx), + episode_id=str(info["episode_id"]), + policy=policy_name, + forced=True, + ) + if forced_trade: + forced_trade.update({"model": f"{algorithm}_smoke", "algorithm": algorithm}) + trade_rows.append(forced_trade) + episode_return_pct = (account.equity - 1.0) * 100.0 + episode_rows.append( + { + "model": f"{algorithm}_smoke", + "algorithm": algorithm, + "policy": policy_name, + "episode_id": info["episode_id"], + "symbol": info["symbol"], + "session": info["session"], + "final_equity": account.equity, + "episode_return_pct": episode_return_pct, + "trade_count": len(account.trades), + "forced_exit_count": account.forced_exit_count, + "steps": step_counter, + } + ) + aggregate_base = aggregate_equity_curve[-1] + aggregate_equity_curve.extend(aggregate_base * value for value in episode_equity_curve[1:]) + env.close() + + return { + "episodes": episode_rows, + "trades": trade_rows, + "actions": action_rows, + "equity": equity_rows, + "aggregate_equity_curve": aggregate_equity_curve, + } + + +def run_sb3_smoke(config: Sb3SmokeConfig) -> Dict[str, Any]: + """Run Gymnasium check_env plus DQN/PPO smoke training and evaluation.""" + + algorithms = tuple(config.algorithms) + if not algorithms: + raise ValueError("At least one SB3 algorithm is required.") + check_result = _check_env(config) + runtime = _torch_runtime() + output_dir = Path(config.output_dir) + + model_summaries: List[Dict[str, Any]] = [] + all_actions: List[Dict[str, Any]] = [] + all_trades: List[Dict[str, Any]] = [] + all_equity: List[Dict[str, Any]] = [] + all_episodes: List[Dict[str, Any]] = [] + model_files: Dict[str, str] = {} + event_writer: Optional[RlLiveEventWriter] = None + live_events_path = output_dir / "rl_live_events.jsonl" + if config.write_artifacts and config.write_live_events: + event_writer = RlLiveEventWriter(live_events_path, run_id=output_dir.name) + event_writer.reset() + + for algorithm in algorithms: + model, elapsed = _train_model(algorithm, config, event_writer=event_writer) + evaluation = _evaluate_model(model, algorithm, config, event_writer=event_writer) + summary = _summarize_model( + algorithm=algorithm, + config=config, + episode_rows=evaluation["episodes"], + trade_rows=evaluation["trades"], + aggregate_equity_curve=evaluation["aggregate_equity_curve"], + training_elapsed_seconds=elapsed, + ) + model_summaries.append(summary) + all_actions.extend(evaluation["actions"]) + all_trades.extend(evaluation["trades"]) + all_equity.extend(evaluation["equity"]) + all_episodes.extend(evaluation["episodes"]) + if config.write_artifacts: + output_dir.mkdir(parents=True, exist_ok=True) + model_path = output_dir / f"{algorithm}_model.zip" + model.save(model_path) + model_files[algorithm] = str(model_path) + + ranking = sorted(model_summaries, key=lambda row: float(row["avg_episode_net_return_pct"]), reverse=True) + payload: Dict[str, Any] = { + "mode": "stom_rl_sb3_smoke", + "config": asdict(config), + "runtime": runtime, + "check_env": check_result, + "summary": { + "algorithm_count": len(algorithms), + "algorithms": list(algorithms), + "check_env_passed": bool(check_result["passed"]), + "best_algorithm_by_avg_episode_net": ranking[0]["algorithm"] if ranking else None, + "best_model": ranking[0]["model"] if ranking else None, + "device_requested": config.device, + "cuda_available": runtime["cuda_available"], + "feature_columns": check_result["feature_columns"], + }, + "models": model_summaries, + "artifacts": { + "output_dir": str(output_dir), + "summary_json": str(output_dir / "sb3_smoke_summary.json"), + "summary_csv": str(output_dir / "sb3_smoke_summary.csv"), + "actions_csv": str(output_dir / "actions.csv"), + "trades_csv": str(output_dir / "trades.csv"), + "equity_csv": str(output_dir / "equity.csv"), + "episodes_csv": str(output_dir / "episodes.csv"), + "model_files": model_files, + "live_events_jsonl": str(live_events_path), + "live_summary_json": str(output_dir / "rl_live_summary.json"), + }, + } + if config.write_artifacts and config.write_live_events: + live_summary = summarize_live_event_file(live_events_path) + payload["live_events"] = live_summary + payload["summary"]["live_event_count"] = live_summary["event_count"] + payload["summary"]["live_event_phases"] = live_summary["phases"] + _write_json(output_dir / "rl_live_summary.json", live_summary) + if config.write_artifacts: + _write_json(output_dir / "sb3_smoke_summary.json", payload) + _write_csv( + output_dir / "sb3_smoke_summary.csv", + model_summaries, + [ + "algorithm", + "model", + "policy", + "eval_split", + "training_timesteps", + "training_elapsed_seconds", + "episode_count", + "trade_count", + "trades_per_episode", + "avg_episode_net_return_pct", + "median_episode_net_return_pct", + "compounded_return_pct", + "avg_trade_net_return_pct", + "hit_rate", + "max_drawdown_pct", + "passes_cost_gate", + "is_smoke", + "eval_only", + "source_run", + "source_model", + "cost_bps", + "slippage_bps", + ], + ) + _write_csv( + output_dir / "actions.csv", + all_actions, + [ + "model", + "algorithm", + "policy", + "episode_id", + "symbol", + "session", + "step_idx", + "timestamp", + "price", + "action", + "action_name", + "position_after", + "env_reward", + "mark_equity", + "invalid_action", + ], + ) + _write_csv( + output_dir / "trades.csv", + all_trades, + [ + "model", + "algorithm", + "policy", + "episode_id", + "entry_timestamp", + "exit_timestamp", + "entry_price", + "exit_price", + "gross_return_pct", + "net_return_pct", + "cost_pct", + "forced_exit", + ], + ) + _write_csv( + output_dir / "equity.csv", + all_equity, + ["model", "algorithm", "policy", "episode_id", "timestamp", "equity", "position"], + ) + _write_csv( + output_dir / "episodes.csv", + all_episodes, + [ + "model", + "algorithm", + "policy", + "episode_id", + "symbol", + "session", + "final_equity", + "episode_return_pct", + "trade_count", + "forced_exit_count", + "steps", + ], + ) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> Sb3SmokeConfig: + parser = argparse.ArgumentParser(description="Run STOM Gymnasium/SB3 check_env + DQN/PPO smoke training.") + parser.add_argument("--manifest", default=str(DEFAULT_OUTPUT_DIR / "episode_manifest.json")) + parser.add_argument("--output-dir", default=str(DEFAULT_SB3_SMOKE_OUTPUT_DIR)) + parser.add_argument("--train-split", default="train") + parser.add_argument("--eval-split", default="test") + parser.add_argument("--algorithms", default=",".join(DEFAULT_ALGORITHMS)) + parser.add_argument("--total-timesteps", type=int, default=256) + parser.add_argument("--max-eval-episodes", type=int, default=2) + parser.add_argument("--max-eval-steps-per-episode", type=int, default=256) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--reward-horizon-seconds", type=int, default=300) + parser.add_argument("--cost-bps", type=float, default=25.0) + parser.add_argument("--slippage-bps", type=float, default=0.0) + parser.add_argument("--device", default="auto") + parser.add_argument("--no-write", action="store_true") + parser.add_argument("--no-live-events", action="store_true") + parser.add_argument("--live-event-sample-interval", type=int, default=1) + args = parser.parse_args(argv) + return Sb3SmokeConfig( + manifest_path=args.manifest, + output_dir=args.output_dir, + train_split=args.train_split, + eval_split=args.eval_split, + algorithms=_parse_algorithms(args.algorithms), + total_timesteps=args.total_timesteps, + max_eval_episodes=args.max_eval_episodes, + max_eval_steps_per_episode=args.max_eval_steps_per_episode, + seed=args.seed, + lookback_window=args.lookback_window, + reward_horizon_seconds=args.reward_horizon_seconds, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + device=args.device, + write_artifacts=not args.no_write, + write_live_events=not args.no_live_events, + live_event_sample_interval=args.live_event_sample_interval, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_sb3_smoke(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/skip_gate.py b/stom_rl/skip_gate.py new file mode 100644 index 000000000..837cd1ea1 --- /dev/null +++ b/stom_rl/skip_gate.py @@ -0,0 +1,755 @@ +"""Experiment ① — entry skip-gate for the STOM gap-up ``ts_imb`` RULE. + +**RULE strategy, NOT reinforcement learning.** This module asks the money +question left open by ``docs/stom_rl_resume_handoff_2026-06-01.md``: + + Can causal entry microstructure identify trades that should be SKIPPED, + improving the existing fixed-entry ``ts_imb`` rule after 23bp costs and + marketable fills? + +The target is realized, cost-adjusted net return from +``marketable_fill.simulate_rule_from_entry``. SL labels are intentionally not +used for the trading decision: SL predictability is only a risk proxy and can +fall into the documented drift trap (SL-heavy slices may still have positive +net). The gate therefore skips a train-selected bottom predicted-net fraction +and books a paired baseline-relative increment: + +* baseline: take every ``ts_imb`` trade -> realized ``net``; +* policy: take non-skipped trades -> realized ``net``; skipped trades -> ``0``; +* incremental: ``policy - baseline`` -> ``-net`` for skipped trades, ``0`` else. + +Pure stats functions are I/O-free and unit-tested. DB extraction is isolated in +``extract_skip_samples``. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from stom_rl.exit_baselines import deflated_sharpe_ratio, per_trade_sharpe +from stom_rl.marketable_fill import simulate_rule_from_entry +from stom_rl.microstructure_features import FEATURE_NAMES, causal_feature_vector +from stom_rl.predictability_probe import _COLS, _sec_of +from stom_rl.timing_gate import DEFAULT_EXTERNAL_SHARPE_VARIANCE, DEFAULT_TRIAL_LEDGER + +DEFAULT_BOUNDARIES: Tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9) +DEFAULT_SKIP_FRACTIONS: Tuple[float, ...] = (0.10, 0.20, 0.30, 0.40) +DEFAULT_PRIMARY_BOUNDARY: float = 0.7 + + +def _as_float_array(values: Sequence[float], *, name: str) -> np.ndarray: + arr = np.asarray(values, dtype=float) + if arr.ndim != 1: + raise ValueError(f"{name} must be one-dimensional") + if not np.all(np.isfinite(arr)): + raise ValueError(f"{name} contains non-finite values") + return arr + + +def _validate_inputs( + X: np.ndarray, + net: np.ndarray, + dates: np.ndarray, + group_ids: np.ndarray, +) -> None: + if X.ndim != 2: + raise ValueError("X must be a 2D feature matrix") + n = len(net) + if len(X) != n or len(dates) != n or len(group_ids) != n: + raise ValueError("X, net, dates, and group_ids must have the same length") + + +def _validate_skip_fractions(skip_fractions: Sequence[float]) -> Tuple[float, ...]: + vals = tuple(float(v) for v in skip_fractions) + if not vals: + raise ValueError("skip_fractions must be non-empty") + if any(v <= 0.0 or v >= 1.0 for v in vals): + raise ValueError("each skip fraction must be in (0, 1)") + return vals + + +def bottom_fraction_mask(scores: Sequence[float], skip_fraction: float) -> np.ndarray: + """Boolean mask for the bottom predicted-net fraction. + + Uses stable rank selection instead of a threshold comparison so ties cannot + accidentally skip the whole dataset. At least one row is skipped for a + non-empty input and a valid positive fraction. + """ + + pred = _as_float_array(scores, name="scores") + if not 0.0 < float(skip_fraction) < 1.0: + raise ValueError("skip_fraction must be in (0, 1)") + mask = np.zeros(len(pred), dtype=bool) + if len(pred) == 0: + return mask + k = min(len(pred), max(1, int(np.ceil(len(pred) * float(skip_fraction))))) + order = np.argsort(pred, kind="mergesort") + mask[order[:k]] = True + return mask + + +def score_skip_policy( + net: Sequence[float], + skip_mask: Sequence[bool], + group_ids: Optional[Sequence[str]] = None, +) -> Dict[str, Any]: + """Score a skip mask against the take-all baseline. + + ``net`` is realized cost-adjusted return (%) if the trade is taken. A skipped + trade earns ``0``. Therefore the baseline-relative increment is ``-net`` for + skipped rows and ``0`` for taken rows. + """ + + net_arr = _as_float_array(net, name="net") + skip = np.asarray(skip_mask, dtype=bool) + if len(skip) != len(net_arr): + raise ValueError("skip_mask and net must have the same length") + + policy = np.where(skip, 0.0, net_arr) + inc = policy - net_arr + skipped = net_arr[skip] + taken = net_arr[~skip] + + out: Dict[str, Any] = { + "n": int(len(net_arr)), + "n_skipped": int(skip.sum()), + "skip_fraction_realized": float(skip.mean()) if len(skip) else 0.0, + "baseline_total_net_pct": float(net_arr.sum()) if len(net_arr) else 0.0, + "policy_total_net_pct": float(policy.sum()) if len(policy) else 0.0, + "incremental_total_pct": float(inc.sum()) if len(inc) else 0.0, + "baseline_mean_net_pct": float(net_arr.mean()) if len(net_arr) else None, + "policy_mean_net_pct": float(policy.mean()) if len(policy) else None, + "incremental_mean_pct": float(inc.mean()) if len(inc) else None, + "skipped_net_mean_pct": float(skipped.mean()) if len(skipped) else None, + "taken_net_mean_pct": float(taken.mean()) if len(taken) else None, + "drift_trap_ok": bool(len(skipped) > 0 and float(skipped.mean()) < 0.0), + "increments": inc, + } + + if group_ids is not None: + groups = np.asarray([str(g) for g in group_ids]) + if len(groups) != len(net_arr): + raise ValueError("group_ids and net must have the same length") + group_incs = [] + for g in np.unique(groups): + rows = groups == g + group_incs.append(float(inc[rows].mean())) + out["group_incremental"] = np.asarray(group_incs, dtype=float) + return out + + +def select_skip_fraction( + pred: Sequence[float], + net: Sequence[float], + skip_fractions: Sequence[float] = DEFAULT_SKIP_FRACTIONS, +) -> Dict[str, Any]: + """Select ONE skip fraction using train data only. + + The selected fraction maximizes train-set incremental mean. Ties prefer the + smaller skip fraction to reduce turnover/selection degrees of freedom. + """ + + pred_arr = _as_float_array(pred, name="pred") + net_arr = _as_float_array(net, name="net") + if len(pred_arr) != len(net_arr): + raise ValueError("pred and net must have the same length") + fractions = _validate_skip_fractions(skip_fractions) + + rows: List[Dict[str, Any]] = [] + for frac in fractions: + mask = bottom_fraction_mask(pred_arr, frac) + scored = score_skip_policy(net_arr, mask) + rows.append({ + "skip_fraction": float(frac), + "train_incremental_mean_pct": scored["incremental_mean_pct"], + "train_skipped_net_mean_pct": scored["skipped_net_mean_pct"], + "train_n_skipped": scored["n_skipped"], + }) + best = max( + rows, + key=lambda r: ( + float(r["train_incremental_mean_pct"]), + -float(r["skip_fraction"]), + ), + ) + return { + "selected_skip_fraction": float(best["skip_fraction"]), + "train_scores": rows, + } + + +def _split_by_date(dates: np.ndarray, boundary: float) -> Tuple[np.ndarray, np.ndarray]: + uniq = np.array(sorted(set(dates.tolist()))) + if len(uniq) < 3: + return np.zeros(len(dates), dtype=bool), np.zeros(len(dates), dtype=bool) + cut = uniq[max(0, min(len(uniq) - 1, int(round(len(uniq) * boundary)) - 1))] + return dates < cut, dates > cut + + +def _models(rng_seed: int): + from sklearn.ensemble import HistGradientBoostingRegressor + from sklearn.linear_model import Ridge + + return { + "ridge": Ridge(alpha=10.0), + "gbm": HistGradientBoostingRegressor( + max_depth=3, + max_iter=200, + learning_rate=0.05, + l2_regularization=1.0, + random_state=rng_seed, + ), + } + + +def _fit_predict(X: np.ndarray, y: np.ndarray, train: np.ndarray, test: np.ndarray, *, rng_seed: int): + from sklearn.preprocessing import StandardScaler + + scaler = StandardScaler().fit(X[train]) + Xtr, Xte = scaler.transform(X[train]), scaler.transform(X[test]) + preds: Dict[str, Tuple[np.ndarray, np.ndarray]] = {} + for name, model in _models(rng_seed).items(): + model.fit(Xtr, y[train]) + preds[name] = (model.predict(Xtr), model.predict(Xte)) + return preds + + +def _bootstrap_ci(values: np.ndarray, *, n_bootstrap: int, rng: np.random.Generator) -> Tuple[Optional[float], Optional[float]]: + if len(values) < 2 or n_bootstrap <= 0: + return None, None + boot = rng.choice(values, size=(int(n_bootstrap), len(values)), replace=True).mean(axis=1) + lo, hi = np.percentile(boot, [2.5, 97.5]) + return float(lo), float(hi) + + +def _evaluate_model_boundary( + pred_train: np.ndarray, + pred_test: np.ndarray, + net_train: np.ndarray, + net_test: np.ndarray, + test_groups: np.ndarray, + *, + skip_fractions: Sequence[float], + n_bootstrap: int, + rng: np.random.Generator, + external_sharpe_variance: float, + n_trials: int, +) -> Dict[str, Any]: + selected = select_skip_fraction(pred_train, net_train, skip_fractions) + frac = selected["selected_skip_fraction"] + skip = bottom_fraction_mask(pred_test, frac) + scored = score_skip_policy(net_test, skip, test_groups) + group_inc = np.asarray(scored.get("group_incremental", []), dtype=float) + lo, hi = _bootstrap_ci(group_inc, n_bootstrap=n_bootstrap, rng=rng) + sr = per_trade_sharpe(group_inc.tolist()) if len(group_inc) >= 2 else None + dsr = ( + deflated_sharpe_ratio( + sr, + n_trials=n_trials, + sharpe_variance=external_sharpe_variance, + n_samples=len(group_inc), + ) + if sr is not None + else None + ) + ci_go = bool(lo is not None and lo > 0.0) + dsr_go = bool(dsr is not None and dsr >= 0.95) + drift_ok = bool(scored["drift_trap_ok"]) + return { + "selected_skip_fraction": float(frac), + "train_skip_fraction_scores": selected["train_scores"], + "n_test_trades": int(len(net_test)), + "n_sessions": int(len(group_inc)), + "n_skipped": scored["n_skipped"], + "skip_fraction_realized": scored["skip_fraction_realized"], + "baseline_mean_net_pct": scored["baseline_mean_net_pct"], + "policy_mean_net_pct": scored["policy_mean_net_pct"], + "incremental_mean_pct": float(group_inc.mean()) if len(group_inc) else None, + "incremental_ci95": [lo, hi], + "ci_excludes_zero": ci_go, + "incremental_sharpe": sr, + "incremental_dsr": dsr, + "dsr_gt_0_95": dsr_go, + "skipped_net_mean_pct": scored["skipped_net_mean_pct"], + "taken_net_mean_pct": scored["taken_net_mean_pct"], + "drift_trap_ok": drift_ok, + "go_components": { + "ci_excludes_zero": ci_go, + "dsr_gt_0_95": dsr_go, + "skipped_net_mean_lt_0": drift_ok, + }, + "go": False, # filled after boundary-positive count is known + } + + +def run_skip_gate( + X: np.ndarray, + net: Sequence[float], + dates: Sequence[str], + group_ids: Sequence[str], + *, + boundaries: Sequence[float] = DEFAULT_BOUNDARIES, + primary_boundary: float = DEFAULT_PRIMARY_BOUNDARY, + skip_fractions: Sequence[float] = DEFAULT_SKIP_FRACTIONS, + n_bootstrap: int = 1000, + rng_seed: int = 0, + external_sharpe_variance: float = DEFAULT_EXTERNAL_SHARPE_VARIANCE, + n_trials: int = DEFAULT_TRIAL_LEDGER, + min_train: int = 50, + min_test: int = 50, +) -> Dict[str, Any]: + """Purged walk-forward entry skip-gate over realized marketable net. + + Trains on earlier sessions and tests on later sessions. For each model and + boundary, the skip fraction is selected on the train split only. GO at the + primary boundary requires: + + * incremental bootstrap CI lower bound > 0; + * DSR >= 0.95; + * skipped test slice has negative realized net (drift-trap guard); + * at least 3 of 5 boundary means are positive for the same model. + """ + + X_arr = np.asarray(X, dtype=float) + net_arr = _as_float_array(net, name="net") + dates_arr = np.asarray([str(d) for d in dates]) + groups_arr = np.asarray([str(g) for g in group_ids]) + _validate_inputs(X_arr, net_arr, dates_arr, groups_arr) + fractions = _validate_skip_fractions(skip_fractions) + rng = np.random.default_rng(rng_seed) + + boundaries_t = tuple(float(b) for b in boundaries) + uniq_dates = np.array(sorted(set(dates_arr.tolist()))) + per_boundary: Dict[str, Dict[float, Optional[float]]] = {"ridge": {}, "gbm": {}} + primary: Dict[str, Dict[str, Any]] = {} + + for frac in boundaries_t: + train, test = _split_by_date(dates_arr, frac) + if train.sum() < min_train or test.sum() < min_test: + for name in per_boundary: + per_boundary[name][float(frac)] = None + continue + preds = _fit_predict(X_arr, net_arr, train, test, rng_seed=rng_seed) + for name, (pred_train, pred_test) in preds.items(): + eval_res = _evaluate_model_boundary( + pred_train, + pred_test, + net_arr[train], + net_arr[test], + groups_arr[test], + skip_fractions=fractions, + n_bootstrap=n_bootstrap, + rng=rng, + external_sharpe_variance=external_sharpe_variance, + n_trials=n_trials, + ) + per_boundary[name][float(frac)] = eval_res["incremental_mean_pct"] + if abs(frac - primary_boundary) < 1e-9: + primary[name] = eval_res + + results: Dict[str, Any] = { + "n_samples": int(len(net_arr)), + "n_groups": int(len(set(groups_arr.tolist()))), + "n_dates": int(len(uniq_dates)), + "n_trials": int(n_trials), + "external_sharpe_variance": float(external_sharpe_variance), + "primary_boundary": float(primary_boundary), + "boundaries": [float(b) for b in boundaries_t], + "skip_fractions": [float(v) for v in fractions], + "per_boundary_incremental_mean": per_boundary, + "models": {}, + "symbol_disjoint": {}, + } + + best_go = False + for name in ("ridge", "gbm"): + m = dict(primary.get(name, {})) + if not m: + continue + positive_count = sum( + 1 for v in per_boundary.get(name, {}).values() if v is not None and float(v) > 0.0 + ) + boundary_ok = positive_count >= 3 + components = dict(m["go_components"]) + components["positive_boundaries_ge_3"] = boundary_ok + go = bool( + components["ci_excludes_zero"] + and components["dsr_gt_0_95"] + and components["skipped_net_mean_lt_0"] + and components["positive_boundaries_ge_3"] + ) + m["positive_boundary_count"] = int(positive_count) + m["go_components"] = components + m["go"] = go + results["models"][name] = m + best_go = best_go or go + + results["symbol_disjoint"] = _symbol_disjoint_diagnostic( + X_arr, + net_arr, + dates_arr, + groups_arr, + primary_boundary=primary_boundary, + skip_fractions=fractions, + rng_seed=rng_seed, + min_train=min_train, + min_test=min_test, + ) + results["verdict"] = "GO" if best_go else "NO-GO" + return results + + +def _symbol_disjoint_diagnostic( + X: np.ndarray, + net: np.ndarray, + dates: np.ndarray, + groups: np.ndarray, + *, + primary_boundary: float, + skip_fractions: Sequence[float], + rng_seed: int, + min_train: int, + min_test: int, +) -> Dict[str, Any]: + import zlib + + uniq_dates = np.array(sorted(set(dates.tolist()))) + if len(uniq_dates) < 3: + return {"degenerate": True} + cut = uniq_dates[max(0, min(len(uniq_dates) - 1, int(round(len(uniq_dates) * primary_boundary)) - 1))] + symbols = np.array([str(g).split("_", 1)[0] for g in groups]) + pool = np.array([zlib.crc32(str(s).encode()) % 10 for s in symbols]) + train = (dates < cut) & (pool < 7) + test = (dates > cut) & (pool >= 7) + out: Dict[str, Any] = { + "n_train": int(train.sum()), + "n_test": int(test.sum()), + "models": {}, + } + if train.sum() < min_train or test.sum() < min_test: + out["degenerate"] = True + return out + preds = _fit_predict(X, net, train, test, rng_seed=rng_seed) + for name, (pred_train, pred_test) in preds.items(): + selected = select_skip_fraction(pred_train, net[train], skip_fractions) + skip = bottom_fraction_mask(pred_test, selected["selected_skip_fraction"]) + scored = score_skip_policy(net[test], skip, groups[test]) + out["models"][name] = { + "selected_skip_fraction": selected["selected_skip_fraction"], + "incremental_mean_pct": scored["incremental_mean_pct"], + "skipped_net_mean_pct": scored["skipped_net_mean_pct"], + "drift_trap_ok": scored["drift_trap_ok"], + "n_skipped": scored["n_skipped"], + } + return out + + +def run_shuffled_feature_control( + X: np.ndarray, + net: Sequence[float], + dates: Sequence[str], + group_ids: Sequence[str], + *, + rng_seed: int = 999, + **kwargs: Any, +) -> Dict[str, Any]: + """Negative control: destroy feature/target pairing by row-shuffling X.""" + + X_arr = np.asarray(X, dtype=float) + rng = np.random.default_rng(rng_seed) + order = rng.permutation(len(X_arr)) + return run_skip_gate(X_arr[order], net, dates, group_ids, rng_seed=rng_seed, **kwargs) + + +def apply_negative_control_gate(result: Dict[str, Any], negative_control: Dict[str, Any]) -> Dict[str, Any]: + """Attach the feature-shuffle control and enforce it as a hard GO blocker. + + Pre-registration requires the negative control to be NO-GO. Therefore a + primary ``GO`` is downgraded when shuffled features also produce ``GO`` (or any + non-``NO-GO`` verdict), because the apparent edge is not distinguishable from + a destroyed feature/target pairing. + """ + + out = dict(result) + neg_verdict = str(negative_control.get("verdict", "")).upper() + out["negative_control"] = { + "verdict": neg_verdict or None, + "models": { + name: { + "incremental_mean_pct": m.get("incremental_mean_pct"), + "go": m.get("go"), + "skipped_net_mean_pct": m.get("skipped_net_mean_pct"), + } + for name, m in negative_control.get("models", {}).items() + }, + } + out["negative_control_passed"] = neg_verdict == "NO-GO" + if out.get("verdict") == "GO" and neg_verdict != "NO-GO": + out["verdict_before_negative_control"] = "GO" + out["verdict"] = "NO-GO" + out["negative_control_blocked_go"] = True + out["go_block_reason"] = "negative_control_not_no_go" + else: + out["negative_control_blocked_go"] = False + return out + + +# --------------------------------------------------------------------------- +# DB extraction: ts_imb instances -> entry features + marketable costed net. +# --------------------------------------------------------------------------- +def extract_skip_samples( + db_path: str, + *, + max_symbols: int = 0, + entry_window_sec: int = 5, + tp_pct: float = 5.0, + sl_pct: float = 1.0, + cost_bps: float = 23.0, + slippage_bps: float = 0.0, + strength_thr: float = 100.0, + imbalance_thr: float = 0.5, + entry_cr_thr: float = 2.0, +) -> Dict[str, Any]: + """Extract one entry skip-gate sample per ``ts_imb`` instance. + + The entry gate matches the existing gap-up rule: first valid bar must have + ``등락율 >= 2``, ``체결강도 >= 100``, and total-depth bid imbalance >= 0.5. + The feature vector uses only rows up to ``entry_window_sec``. The label is + marketable TP5/SL1/09:25 net after 23bp costs. + """ + + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + from finetune_csv.stom_tick_dataset import connect_readonly, get_table_columns, list_stock_tables + + conn = connect_readonly(db_path) + sel = ["index"] + [_COLS[k] for k in ( + "px", "cr", "strength", "buy_val", "sell_val", "buy_qty", "sell_qty", + "bid_tot", "ask_tot", "bid1", "ask1", "bidq1", "askq1")] + + feats: List[List[float]] = [] + nets: List[float] = [] + dates: List[str] = [] + groups: List[str] = [] + exit_reasons: List[str] = [] + n_instances = 0 + n_negative_net = 0 + try: + tables = list_stock_tables(conn, max_tables=max_symbols if max_symbols > 0 else None) + for table in tables: + cols = set(get_table_columns(conn, table)) + if not all(c in cols for c in sel[1:]): + continue + qt = table.replace('"', '""') + q_sel = ", ".join('"' + c.replace('"', '""') + '"' for c in sel) + px_c = _COLS["px"] + # One ordered table scan is materially faster than issuing a separate + # range query for every (symbol, session). The DB is already bounded to + # the opening 09:00-09:30 window; keep a defensive second filter anyway. + raw = conn.execute( + 'SELECT %s FROM "%s" WHERE "%s">0 ORDER BY "index"' % (q_sel, qt, px_c) + ).fetchall() + by_session: Dict[str, List[Dict[str, Any]]] = {} + for r in raw: + idx_raw = r[0] + idx_s = str(int(idx_raw)) if idx_raw is not None else "" + if len(idx_s) != 14: + continue + sec = _sec_of(idx_raw) + if sec < 0 or sec > 1800: + continue + px = r[1] + if px is None or float(px) <= 0: + continue + sess = idx_s[:8] + by_session.setdefault(sess, []).append({ + "sec": sec, "price": float(px), "cr": r[2], "ts": r[3], + "buy_val": r[4], "sell_val": r[5], "buy_qty": r[6], "sell_qty": r[7], + "bid_tot": r[8], "ask_tot": r[9], "bid1": r[10], "ask1": r[11], + "bidq1": r[12], "askq1": r[13], + }) + for sess in sorted(by_session): + rows = by_session[sess] + if len(rows) < 120: + continue + e = rows[0] + imb = None + if e["bid_tot"] is not None and e["ask_tot"] is not None: + s = float(e["bid_tot"]) + float(e["ask_tot"]) + imb = (float(e["bid_tot"]) / s) if s > 0 else None + if (e["cr"] is None or float(e["cr"]) < entry_cr_thr + or e["ts"] is None or float(e["ts"]) < strength_thr + or imb is None or imb < imbalance_thr): + continue + + prices = [rr["price"] for rr in rows] + bids = [rr["bid1"] for rr in rows] + asks = [rr["ask1"] for rr in rows] + secs_arr = [rr["sec"] for rr in rows] + net, reason = simulate_rule_from_entry( + prices, + bids, + asks, + secs_arr, + 0, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + slippage_bps=slippage_bps, + ) + e_last = 0 + for j, row in enumerate(rows): + if row["sec"] <= entry_window_sec: + e_last = j + else: + break + fv = causal_feature_vector(rows[: e_last + 1]) + feats.append([fv[k] for k in FEATURE_NAMES]) + nets.append(float(net)) + dates.append(sess) + groups.append("%s_%s" % (table, sess)) + exit_reasons.append(reason) + n_instances += 1 + n_negative_net += int(float(net) < 0.0) + finally: + conn.close() + + net_arr = np.array(nets, dtype=float) + return { + "X": np.array(feats, dtype=float) if feats else np.zeros((0, len(FEATURE_NAMES))), + "net": net_arr, + "dates": dates, + "group_ids": groups, + "exit_reasons": exit_reasons, + "n_instances": n_instances, + "n_negative_net": n_negative_net, + "negative_net_rate": (n_negative_net / n_instances) if n_instances else None, + "mean_net_pct": float(net_arr.mean()) if len(net_arr) else None, + "feature_names": FEATURE_NAMES, + "entry_window_sec": entry_window_sec, + "cost_bps": cost_bps, + "slippage_bps": slippage_bps, + } + + +def _json_default(obj: Any) -> Any: + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, (np.floating, np.integer)): + return obj.item() + return float(obj) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + root = Path(__file__).resolve().parents[1] + p = argparse.ArgumentParser(description="STOM ① entry skip-gate (RULE NOT RL).") + p.add_argument("--db-path", "--db", default=str(root / "_database" / "stock_tick_back.db")) + p.add_argument("--max-symbols", type=int, default=0) + p.add_argument("--output-dir", default=str(root / ".omx" / "artifacts" / "skip_gate")) + p.add_argument("--n-bootstrap", type=int, default=1000) + p.add_argument("--rng-seed", type=int, default=0) + p.add_argument("--slippage-bps", type=float, default=0.0) + p.add_argument("--no-write", action="store_true") + args = p.parse_args(argv) + + print("=== STOM ① entry skip-gate (RULE NOT RL) ===") + d = extract_skip_samples( + args.db_path, + max_symbols=args.max_symbols, + slippage_bps=args.slippage_bps, + ) + print( + "instances=%d mean_net=%s negative_rate=%s" + % ( + d["n_instances"], + ("%.4f%%" % d["mean_net_pct"]) if d["mean_net_pct"] is not None else "n/a", + ("%.3f" % d["negative_net_rate"]) if d["negative_net_rate"] is not None else "n/a", + ) + ) + if len(d["net"]) < 200: + res = { + "verdict": "INCONCLUSIVE", + "reason": "too_few_samples_for_preregistered_gate", + "n_samples": int(len(d["net"])), + "extract": { + k: v for k, v in d.items() + if k not in {"X", "net", "dates", "group_ids", "exit_reasons"} + }, + } + print("too few samples for the pre-registered gate; wrote INCONCLUSIVE summary") + if not args.no_write: + out_dir = Path(args.output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + outp = out_dir / "summary.json" + outp.write_text(json.dumps(res, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8") + print("wrote -> %s" % outp) + return 0 + + res = run_skip_gate( + d["X"], + d["net"], + d["dates"], + d["group_ids"], + n_bootstrap=args.n_bootstrap, + rng_seed=args.rng_seed, + ) + neg = run_shuffled_feature_control( + d["X"], + d["net"], + d["dates"], + d["group_ids"], + n_bootstrap=max(100, min(args.n_bootstrap, 300)), + rng_seed=args.rng_seed + 999, + ) + res = apply_negative_control_gate(res, neg) + res["extract"] = { + k: v for k, v in d.items() + if k not in {"X", "net", "dates", "group_ids", "exit_reasons"} + } + + print("-- primary boundary %.1f --" % res["primary_boundary"]) + for name, m in res.get("models", {}).items(): + ci = m.get("incremental_ci95") or [None, None] + print( + " %-5s inc=%s CI95=[%s,%s] DSR=%s skipped_net=%s pos_bounds=%s -> %s" + % ( + name, + ("%.4f%%" % m["incremental_mean_pct"]) if m.get("incremental_mean_pct") is not None else "n/a", + ("%.4f" % ci[0]) if ci[0] is not None else "n/a", + ("%.4f" % ci[1]) if ci[1] is not None else "n/a", + ("%.3f" % m["incremental_dsr"]) if m.get("incremental_dsr") is not None else "n/a", + ("%.4f%%" % m["skipped_net_mean_pct"]) if m.get("skipped_net_mean_pct") is not None else "n/a", + m.get("positive_boundary_count"), + "GO" if m.get("go") else "no", + ) + ) + print("negative control verdict: %s" % res["negative_control"]["verdict"]) + print("VERDICT: %s" % res["verdict"]) + + if not args.no_write: + out_dir = Path(args.output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + outp = out_dir / "summary.json" + outp.write_text(json.dumps(res, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8") + print("wrote -> %s" % outp) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/sl_predictor.py b/stom_rl/sl_predictor.py new file mode 100644 index 000000000..9dd1f17bf --- /dev/null +++ b/stom_rl/sl_predictor.py @@ -0,0 +1,494 @@ +"""Experiment ③ — SL-vs-non-SL precursor classifier (cheap de-risker gate). + +**RULE strategy, NOT reinforcement learning.** This is the MEASUREMENT-FIRST gate +from ``docs/stom_data_layer_assessment_2026-05-30.md`` §4: can causal entry-bar + +early-path microstructure predict whether a ts_imb trade EVENTUALLY exits on the +stop-loss (rule TP5/SL1/09:25)? If SL-predictability is at chance (AUC ~ 0.5, +symbol-robust), it cheaply KILLS the more expensive experiments ① (skip-gate) and +④ (state-conditioned exit) BEFORE they are built. It is not a money test; it is a +"is there anything to condition on" test. + +Two pre-registered snapshots, two AUCs: + +* ``entry`` — features from the entry bar + first few seconds (ALL instances). + Relevant to ① (decide at entry whether to skip). No survival conditioning. +* ``path30`` — features from the first ``path_window_sec`` seconds, restricted to + instances STILL OPEN at that time (not yet TP/SL). Relevant to ④ (decide while + holding: "I'm still in at 30s, will this end in SL?"). Conditioning on + open-at-30s is correct for ④, not a leak (the SL/TP check truncates features at + any in-window resolution, so features never use post-exit path). + +Pre-registered verdict (recorded BEFORE results): a snapshot is PREDICTABLE only if +its purged walk-forward test AUC has a session-bootstrap 95% CI **lower bound > 0.5** +(statistically above chance) AND point AUC >= 0.55 (practically meaningful) AND the +symbol-disjoint AUC >= 0.53 (robust to per-ticker memorisation). Otherwise AT-CHANCE. +GO for ①④ requires at least one snapshot PREDICTABLE; else STOP. + +Pure stats (``run_sl_gate`` on arrays) have no I/O and are unit-tested; the DB +extraction (``extract_sl_samples``) is separated. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from stom_rl.microstructure_features import FEATURE_NAMES, causal_feature_vector + +# Pre-registered thresholds (locked before results). +AUC_ABOVE_CHANCE: float = 0.5 # CI lower bound must exceed this +AUC_MEANINGFUL: float = 0.55 # point AUC must reach this to be "practically useful" +AUC_ROBUST: float = 0.53 # symbol-disjoint AUC must reach this +RULE_TP_PCT: float = 5.0 +RULE_SL_PCT: float = 1.0 + + +def _auc(scores: np.ndarray, labels: np.ndarray) -> Optional[float]: + """ROC AUC; None when the label vector is single-class (AUC undefined).""" + + labels = np.asarray(labels) + if labels.min() == labels.max(): + return None + from sklearn.metrics import roc_auc_score + + return float(roc_auc_score(labels, scores)) + + +def rule_exit_reason( + prices: Sequence[float], + secs: Sequence[float], + *, + tp_pct: float = RULE_TP_PCT, + sl_pct: float = RULE_SL_PCT, +) -> Tuple[str, int]: + """Rule exit reason + bar index over a per-second 현재가 path (entry = index 0). + + Scans forward: the FIRST bar whose return from entry reaches ``+tp_pct`` is a + take-profit (``"tp"``); the first reaching ``-sl_pct`` is a stop-loss + (``"sl"``); if both are crossed on the same scan step SL wins (conservative); + otherwise the final bar is a time exit (``"time"``). Returns ``(reason, idx)``. + """ + + if not prices: + raise ValueError("prices must be non-empty") + entry = float(prices[0]) + if entry <= 0: + raise ValueError("entry price must be positive") + for i in range(1, len(prices)): + ret = (float(prices[i]) / entry - 1.0) * 100.0 + if ret <= -sl_pct: + return "sl", i + if ret >= tp_pct: + return "tp", i + return "time", len(prices) - 1 + + +def _walk_forward_auc( + X: np.ndarray, + y: np.ndarray, + dates: np.ndarray, + groups: np.ndarray, + *, + boundary: float, + n_bootstrap: int, + rng: np.random.Generator, +) -> Dict[str, Any]: + """Train on earlier sessions, test on later; AUC + session-bootstrap CI. + + Logistic regression (linear-first) and gradient boosting. CI resamples test + GROUPS (symbols) with replacement so the interval respects per-ticker + clustering. Returns per-model AUC, CI, and bootstrap detail. + """ + + from sklearn.ensemble import HistGradientBoostingClassifier + from sklearn.linear_model import LogisticRegression + from sklearn.preprocessing import StandardScaler + + uniq = np.array(sorted(set(dates.tolist()))) + cut = uniq[max(0, min(len(uniq) - 1, int(round(len(uniq) * boundary)) - 1))] + tr = dates < cut + te = dates > cut + out: Dict[str, Any] = {"n_train": int(tr.sum()), "n_test": int(te.sum()), "models": {}} + if tr.sum() < 50 or te.sum() < 50 or y[tr].min() == y[tr].max() or y[te].min() == y[te].max(): + out["degenerate"] = True + return out + + scaler = StandardScaler().fit(X[tr]) + Xtr, Xte = scaler.transform(X[tr]), scaler.transform(X[te]) + test_groups = groups[te] + uniq_tg = np.array(sorted(set(test_groups.tolist()))) + g_rows = {g: np.where(test_groups == g)[0] for g in uniq_tg} + + models = { + "logit": LogisticRegression(max_iter=1000, C=1.0), + "gbm": HistGradientBoostingClassifier( + max_depth=3, max_iter=200, learning_rate=0.05, l2_regularization=1.0, + random_state=0, + ), + } + for name, model in models.items(): + model.fit(Xtr, y[tr]) + score = model.predict_proba(Xte)[:, 1] + auc = _auc(score, y[te]) + boot: List[float] = [] + if auc is not None: + for _ in range(n_bootstrap): + pick = rng.choice(uniq_tg, size=len(uniq_tg), replace=True) + idx = np.concatenate([g_rows[g] for g in pick]) + a = _auc(score[idx], y[te][idx]) + if a is not None: + boot.append(a) + lo, hi = (float(np.percentile(boot, 2.5)), float(np.percentile(boot, 97.5))) if boot else (None, None) + out["models"][name] = { + "auc": auc, + "auc_ci95": [lo, hi], + "ci_excludes_chance": bool(lo is not None and lo > AUC_ABOVE_CHANCE), + } + return out + + +def _symbol_disjoint_auc( + X: np.ndarray, y: np.ndarray, dates: np.ndarray, groups: np.ndarray, *, boundary: float, +) -> Dict[str, Optional[float]]: + """AUC on unseen tickers (train 70% symbols earlier, test 30% symbols later).""" + + import zlib + + from sklearn.ensemble import HistGradientBoostingClassifier + from sklearn.linear_model import LogisticRegression + from sklearn.preprocessing import StandardScaler + + uniq = np.array(sorted(set(dates.tolist()))) + cut = uniq[max(0, min(len(uniq) - 1, int(round(len(uniq) * boundary)) - 1))] + symbols = np.array([str(g).split("_", 1)[0] for g in groups]) + pool = np.array([zlib.crc32(s.encode()) % 10 for s in symbols]) + tr = (dates < cut) & (pool < 7) + te = (dates > cut) & (pool >= 7) + res: Dict[str, Optional[float]] = {} + if tr.sum() < 50 or te.sum() < 50 or y[tr].min() == y[tr].max() or y[te].min() == y[te].max(): + return res + scaler = StandardScaler().fit(X[tr]) + Xtr, Xte = scaler.transform(X[tr]), scaler.transform(X[te]) + for name, model in ( + ("logit", LogisticRegression(max_iter=1000, C=1.0)), + ("gbm", HistGradientBoostingClassifier( + max_depth=3, max_iter=200, learning_rate=0.05, l2_regularization=1.0, random_state=0)), + ): + model.fit(Xtr, y[tr]) + res[name] = _auc(model.predict_proba(Xte)[:, 1], y[te]) + return res + + +def run_sl_gate( + X: np.ndarray, + y: np.ndarray, + dates: Sequence[str], + group_ids: Sequence[str], + *, + boundary: float = 0.7, + n_bootstrap: int = 1000, + rng_seed: int = 0, +) -> Dict[str, Any]: + """Purged walk-forward SL-predictability gate for ONE snapshot. + + ``y`` is the binary eventual-SL label. Trains logit + GBM on earlier sessions, + tests on later; reports AUC + session-bootstrap CI and a symbol-disjoint AUC. + ``predictable`` is True iff some model clears all three pre-registered bars + (CI lower > 0.5, AUC >= 0.55, symbol-disjoint AUC >= 0.53). + """ + + X = np.asarray(X, dtype=float) + y = np.asarray(y, dtype=int) + dates = np.asarray([str(d) for d in dates]) + groups = np.asarray([str(g) for g in group_ids]) + rng = np.random.default_rng(rng_seed) + + wf = _walk_forward_auc(X, y, dates, groups, boundary=boundary, n_bootstrap=n_bootstrap, rng=rng) + sd = _symbol_disjoint_auc(X, y, dates, groups, boundary=boundary) + + predictable = False + for name, m in wf.get("models", {}).items(): + auc = m.get("auc") + ci_ok = m.get("ci_excludes_chance", False) + meaningful = auc is not None and auc >= AUC_MEANINGFUL + robust = sd.get(name) is not None and sd[name] >= AUC_ROBUST + m["meaningful"] = bool(meaningful) + m["symbol_disjoint_auc"] = sd.get(name) + m["robust"] = bool(robust) + m["predictable"] = bool(ci_ok and meaningful and robust) + predictable = predictable or m["predictable"] + + base_rate = float(y.mean()) if len(y) else None + return { + "n_samples": int(len(y)), + "n_groups": int(len(set(groups.tolist()))), + "base_rate_sl": base_rate, + "boundary": boundary, + "thresholds": { + "auc_above_chance": AUC_ABOVE_CHANCE, + "auc_meaningful": AUC_MEANINGFUL, + "auc_robust": AUC_ROBUST, + }, + "walk_forward": wf, + "symbol_disjoint_auc": sd, + "predictable": predictable, + "verdict": "PREDICTABLE" if predictable else "AT-CHANCE", + } + + +# --------------------------------------------------------------------------- +# DB extraction: ts_imb instances -> (entry snapshot, path30 snapshot) datasets. +# --------------------------------------------------------------------------- +_COLS = { + "px": "현재가", "cr": "등락율", "strength": "체결강도", + "buy_val": "초당매수금액", "sell_val": "초당매도금액", + "buy_qty": "초당매수수량", "sell_qty": "초당매도수량", + "bid_tot": "매수총잔량", "ask_tot": "매도총잔량", + "bid1": "매수호가1", "ask1": "매도호가1", "bidq1": "매수잔량1", "askq1": "매도잔량1", +} + + +def _sec_of(ts: int) -> int: + s = str(int(ts)) + return int(s[8:10]) * 3600 + int(s[10:12]) * 60 + int(s[12:14]) - 9 * 3600 + + +def _row_dict(r: Sequence[Any]) -> Dict[str, Any]: + return { + "sec": _sec_of(r[0]), "price": float(r[1]), "cr": r[2], "ts": r[3], + "buy_val": r[4], "sell_val": r[5], "buy_qty": r[6], "sell_qty": r[7], + "bid_tot": r[8], "ask_tot": r[9], "bid1": r[10], "ask1": r[11], + "bidq1": r[12], "askq1": r[13], + } + + +def extract_sl_samples( + db_path: str, + *, + max_symbols: int = 0, + entry_window_sec: int = 5, + path_window_sec: int = 30, + strength_thr: float = 100.0, + imbalance_thr: float = 0.5, + entry_cr_thr: float = 2.0, +) -> Dict[str, Any]: + """Emit two causal datasets (entry, path30) with eventual-SL labels. + + ts_imb gate identical to the predictability probe (등락율>=2 AND 체결강도>=100 AND + 매수총잔량 imbalance>=0.5 at the first valid-price bar). Path pulled over + [09:00, 09:25] (rule TP5/SL1/09:25 window). For each instance: + + * label = rule exit reason == "sl" (:func:`rule_exit_reason`); + * ``entry`` features = causal vector over rows within ``entry_window_sec`` + (truncated at exit if it resolves that fast); + * ``path30`` features = causal vector over rows within ``path_window_sec``, + ONLY if the trade is still open at ``path_window_sec`` (else excluded), + truncated at any in-window resolution so no post-exit path is used. + """ + + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + from finetune_csv.stom_tick_dataset import connect_readonly, get_table_columns, list_stock_tables + + conn = connect_readonly(db_path) + sel = ["index"] + [_COLS[k] for k in ( + "px", "cr", "strength", "buy_val", "sell_val", "buy_qty", "sell_qty", + "bid_tot", "ask_tot", "bid1", "ask1", "bidq1", "askq1")] + + entry_X: List[List[float]] = [] + entry_y: List[int] = [] + entry_d: List[str] = [] + entry_g: List[str] = [] + path_X: List[List[float]] = [] + path_y: List[int] = [] + path_d: List[str] = [] + path_g: List[str] = [] + n_instances = 0 + n_sl = 0 + n_open_at_path = 0 + try: + tables = list_stock_tables(conn, max_tables=max_symbols if max_symbols > 0 else None) + for table in tables: + cols = set(get_table_columns(conn, table)) + if not all(c in cols for c in sel[1:]): + continue + qt = table.replace('"', '""') + q_sel = ", ".join('"' + c.replace('"', '""') + '"' for c in sel) + cr_c, st_c = _COLS["cr"], _COLS["strength"] + bt_c, at_c, px_c = _COLS["bid_tot"], _COLS["ask_tot"], _COLS["px"] + date_q = 'SELECT DISTINCT substr(CAST("index" AS TEXT),1,8) FROM "%s"' % qt + sessions = [str(r[0]) for r in conn.execute(date_q).fetchall() + if r[0] is not None and len(str(r[0])) == 8 and str(r[0]).isdigit()] + prefilter_q = ( + 'SELECT "%s","%s","%s","%s" FROM "%s" WHERE "index">=? AND "index"<=? ' + 'AND "%s">0 ORDER BY "index" LIMIT 1' % (cr_c, st_c, bt_c, at_c, qt, px_c) + ) + for sess in sorted(sessions): + lo, hi = int(sess + "090000"), int(sess + "092500") + pre = conn.execute(prefilter_q, (lo, hi)).fetchall() + if not pre: + continue + pcr, pts, pbt, pat = pre[0] + if pcr is None or float(pcr) < entry_cr_thr or pts is None or float(pts) < strength_thr: + continue + psum = (float(pbt) if pbt is not None else 0.0) + (float(pat) if pat is not None else 0.0) + if psum <= 0 or (float(pbt) if pbt is not None else 0.0) / psum < imbalance_thr: + continue + raw = conn.execute( + 'SELECT %s FROM "%s" WHERE "index">=? AND "index"<=? ORDER BY "index"' % (q_sel, qt), + (lo, hi), + ).fetchall() + rows: List[Dict[str, Any]] = [] + for r in raw: + px = r[1] + if px is None or float(px) <= 0: + continue + if len(str(int(r[0]))) != 14: + continue + rows.append(_row_dict(r)) + if len(rows) < 120: + continue + e = rows[0] + imb = None + if e["bid_tot"] is not None and e["ask_tot"] is not None: + s = float(e["bid_tot"]) + float(e["ask_tot"]) + imb = (float(e["bid_tot"]) / s) if s > 0 else None + if (e["cr"] is None or float(e["cr"]) < entry_cr_thr + or e["ts"] is None or float(e["ts"]) < strength_thr + or imb is None or imb < imbalance_thr): + continue + + n_instances += 1 + prices = [rr["price"] for rr in rows] + secs = [rr["sec"] for rr in rows] + reason, exit_idx = rule_exit_reason(prices, secs) + label = 1 if reason == "sl" else 0 + n_sl += label + gid = "%s_%s" % (table, sess) + + # entry snapshot: rows within entry_window_sec, capped at exit. + e_cap = exit_idx + e_last = 0 + for j in range(len(rows)): + if rows[j]["sec"] <= entry_window_sec and j <= e_cap: + e_last = j + else: + break + fv_e = causal_feature_vector(rows[: e_last + 1]) + entry_X.append([fv_e[k] for k in FEATURE_NAMES]) + entry_y.append(label) + entry_d.append(sess) + entry_g.append(gid) + + # path30 snapshot: only if still open at path_window_sec. + if exit_idx >= 1 and secs[exit_idx] > path_window_sec: + n_open_at_path += 1 + p_last = 0 + for j in range(len(rows)): + if rows[j]["sec"] <= path_window_sec: + p_last = j + else: + break + fv_p = causal_feature_vector(rows[: p_last + 1]) + path_X.append([fv_p[k] for k in FEATURE_NAMES]) + path_y.append(label) + path_d.append(sess) + path_g.append(gid) + finally: + conn.close() + + def _pack(Xl, yl, dl, gl): + return { + "X": np.array(Xl, dtype=float) if Xl else np.zeros((0, len(FEATURE_NAMES))), + "y": np.array(yl, dtype=int), + "dates": dl, "group_ids": gl, + } + + return { + "entry": _pack(entry_X, entry_y, entry_d, entry_g), + "path30": _pack(path_X, path_y, path_d, path_g), + "n_instances": n_instances, + "n_sl": n_sl, + "n_open_at_path": n_open_at_path, + "sl_rate": (n_sl / n_instances) if n_instances else None, + "feature_names": FEATURE_NAMES, + "entry_window_sec": entry_window_sec, + "path_window_sec": path_window_sec, + } + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + root = Path(__file__).resolve().parents[1] + p = argparse.ArgumentParser(description="Experiment 3: SL-predictability precursor gate (RULE NOT RL).") + p.add_argument("--db", default=str(root / "_database" / "stock_tick_back.db")) + p.add_argument("--max-symbols", type=int, default=0) + p.add_argument("--entry-window-sec", type=int, default=5) + p.add_argument("--path-window-sec", type=int, default=30) + p.add_argument("--json-out", default=str(root / ".omx" / "artifacts" / "sl_predictor" / "summary.json")) + args = p.parse_args(argv) + + print("=== experiment 3: SL-vs-non-SL precursor gate (RULE strategy, NOT RL) ===") + data = extract_sl_samples( + args.db, max_symbols=args.max_symbols, + entry_window_sec=args.entry_window_sec, path_window_sec=args.path_window_sec, + ) + print("instances(ts_imb)=%d SL=%d (rate=%.1f%%) open_at_%ds=%d features=%d" + % (data["n_instances"], data["n_sl"], 100.0 * (data["sl_rate"] or 0.0), + args.path_window_sec, data["n_open_at_path"], len(data["feature_names"]))) + + out: Dict[str, Any] = { + "n_instances": data["n_instances"], "n_sl": data["n_sl"], "sl_rate": data["sl_rate"], + "n_open_at_path": data["n_open_at_path"], + "entry_window_sec": args.entry_window_sec, "path_window_sec": args.path_window_sec, + "snapshots": {}, + } + any_predictable = False + for snap in ("entry", "path30"): + d = data[snap] + if len(d["y"]) < 200: + print("-- %s: too few samples (%d) — skip" % (snap, len(d["y"]))) + continue + res = run_sl_gate(d["X"], d["y"], d["dates"], d["group_ids"]) + out["snapshots"][snap] = res + any_predictable = any_predictable or res["predictable"] + print("-- %s snapshot: n=%d base_rate_SL=%.1f%% --" + % (snap, res["n_samples"], 100.0 * (res["base_rate_sl"] or 0.0))) + for name, m in res["walk_forward"].get("models", {}).items(): + ci = m.get("auc_ci95", [None, None]) + print(" %-5s AUC=%s CI95=[%s,%s] sd_auc=%s -> %s" + % (name, + ("%.3f" % m["auc"]) if m.get("auc") is not None else "n/a", + ("%.3f" % ci[0]) if ci[0] is not None else "n/a", + ("%.3f" % ci[1]) if ci[1] is not None else "n/a", + ("%.3f" % m["symbol_disjoint_auc"]) if m.get("symbol_disjoint_auc") is not None else "n/a", + "PREDICTABLE" if m.get("predictable") else "chance")) + print(" verdict: %s" % res["verdict"]) + + final = "GO (build experiments 1/4)" if any_predictable else "STOP — SL at chance, skip experiments 1 and 4" + out["final_verdict"] = "GO" if any_predictable else "STOP" + print("\nFINAL: %s" % final) + print("(pre-registered: PREDICTABLE needs AUC CI lower>0.5 AND AUC>=0.55 AND symbol-disjoint AUC>=0.53)") + + outp = Path(args.json_out) + outp.parent.mkdir(parents=True, exist_ok=True) + outp.write_text(json.dumps(out, ensure_ascii=False, indent=2, default=float), encoding="utf-8") + print("wrote -> %s" % outp) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/state_exit_gate.py b/stom_rl/state_exit_gate.py new file mode 100644 index 000000000..47953b1cc --- /dev/null +++ b/stom_rl/state_exit_gate.py @@ -0,0 +1,798 @@ +"""Experiment ④ — 30s state-conditioned early-exit gate for STOM ``ts_imb``. + +RULE / supervised risk-control gate, NOT reinforcement learning. + +Question: if a gap-up ``ts_imb`` rule trade is still open at 30 seconds, can +causal path state identify cases where selling marketable now beats continuing +the fixed TP5/SL1/09:25 baseline? The target is paired money delta after +marketable fills and 23bp costs; SL labels are diagnostics only. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from stom_rl.exit_baselines import deflated_sharpe_ratio, per_trade_sharpe +from stom_rl.marketable_fill import ( + DEFAULT_TIME_EXIT_SEC, + EXIT_SL, + EXIT_TIME, + EXIT_TP, + marketable_entry_price, + marketable_exit_price, +) +from stom_rl.microstructure_features import FEATURE_NAMES, causal_feature_vector +from stom_rl.predictability_probe import _COLS, _sec_of +from stom_rl.timing_gate import DEFAULT_EXTERNAL_SHARPE_VARIANCE + +DEFAULT_BOUNDARIES: Tuple[float, ...] = (0.5, 0.6, 0.7, 0.8, 0.9) +DEFAULT_EXIT_FRACTIONS: Tuple[float, ...] = (0.10, 0.20, 0.30, 0.40) +DEFAULT_PRIMARY_BOUNDARY = 0.7 +DEFAULT_CHECKPOINT_SEC = 30 +PRIMARY_MODEL_FAMILY = "gbm" +MODEL_FAMILIES: Tuple[str, ...] = ("ridge", "gbm") + + +def _as_float_array(values: Sequence[float], *, name: str) -> np.ndarray: + arr = np.asarray(values, dtype=float) + if arr.ndim != 1: + raise ValueError(f"{name} must be one-dimensional") + if not np.all(np.isfinite(arr)): + raise ValueError(f"{name} contains non-finite values") + return arr + + +def _validate_exit_fractions(exit_fractions: Sequence[float]) -> Tuple[float, ...]: + vals = tuple(float(v) for v in exit_fractions) + if not vals: + raise ValueError("exit_fractions must be non-empty") + if any(v <= 0.0 or v >= 1.0 for v in vals): + raise ValueError("each exit fraction must be in (0, 1)") + return vals + + +def top_fraction_mask(scores: Sequence[float], fraction: float) -> np.ndarray: + """Boolean mask for the top predicted-delta fraction.""" + + pred = _as_float_array(scores, name="scores") + if not 0.0 < float(fraction) < 1.0: + raise ValueError("fraction must be in (0, 1)") + mask = np.zeros(len(pred), dtype=bool) + if len(pred) == 0: + return mask + k = min(len(pred), max(1, int(np.ceil(len(pred) * float(fraction))))) + mask[np.argsort(-pred, kind="mergesort")[:k]] = True + return mask + + +def eligible_top_fraction_mask( + pred_eligible: Sequence[float], + eligible_mask: Sequence[bool], + exit_fraction: float, +) -> np.ndarray: + """Map top-fraction predictions for eligible rows back to all original rows.""" + + eligible = np.asarray(eligible_mask, dtype=bool) + pred = _as_float_array(pred_eligible, name="pred_eligible") + pos = np.flatnonzero(eligible) + if len(pred) != len(pos): + raise ValueError("pred_eligible length must equal eligible row count") + out = np.zeros(len(eligible), dtype=bool) + out[pos[top_fraction_mask(pred, exit_fraction)]] = True + return out + + +def score_exit_policy( + baseline_net: Sequence[float], + early_exit_net: Sequence[float], + eligible_mask: Sequence[bool], + exit_mask: Sequence[bool], + group_ids: Optional[Sequence[str]] = None, +) -> Dict[str, Any]: + """Score early-exit decisions against the continue-baseline policy. + + Primary mean is over all original trades. Ineligible trades and continued + eligible trades both receive incremental zero. + """ + + base = _as_float_array(baseline_net, name="baseline_net") + early = _as_float_array(early_exit_net, name="early_exit_net") + eligible = np.asarray(eligible_mask, dtype=bool) + exits = np.asarray(exit_mask, dtype=bool) & eligible + if not (len(base) == len(early) == len(eligible) == len(exits)): + raise ValueError("baseline, early_exit, eligible, and exit_mask lengths must match") + + policy = np.where(exits, early, base) + inc = policy - base + delta = early - base + exited_delta = delta[exits] + eligible_inc = inc[eligible] + out: Dict[str, Any] = { + "n_original_trades": int(len(base)), + "n_checkpoint_eligible_trades": int(eligible.sum()), + "n_policy_exits": int(exits.sum()), + "exit_fraction_realized": float(exits.sum() / max(1, eligible.sum())) if len(base) else 0.0, + "baseline_total_net_pct": float(base.sum()) if len(base) else 0.0, + "policy_total_net_pct": float(policy.sum()) if len(policy) else 0.0, + "incremental_total_pct": float(inc.sum()) if len(inc) else 0.0, + "baseline_mean_net_pct": float(base.mean()) if len(base) else None, + "policy_mean_net_pct": float(policy.mean()) if len(policy) else None, + "incremental_mean_pct_per_original_trade": float(inc.mean()) if len(inc) else None, + "eligible_incremental_mean_pct": float(eligible_inc.mean()) if len(eligible_inc) else None, + "exited_delta_mean_pct": float(exited_delta.mean()) if len(exited_delta) else None, + "exited_baseline_mean_net_pct": float(base[exits].mean()) if exits.any() else None, + "exited_early_exit_mean_net_pct": float(early[exits].mean()) if exits.any() else None, + "increments": inc, + } + if group_ids is not None: + groups = np.asarray([str(g) for g in group_ids]) + if len(groups) != len(base): + raise ValueError("group_ids and baseline_net must have the same length") + out["group_incremental"] = np.asarray( + [float(inc[groups == g].mean()) for g in np.unique(groups)], + dtype=float, + ) + return out + + +def select_exit_fraction( + pred_eligible: Sequence[float], + baseline_net: Sequence[float], + early_exit_net: Sequence[float], + eligible_mask: Sequence[bool], + exit_fractions: Sequence[float] = DEFAULT_EXIT_FRACTIONS, +) -> Dict[str, Any]: + """Select one exit fraction using train data only.""" + + rows: List[Dict[str, Any]] = [] + for frac in _validate_exit_fractions(exit_fractions): + exits = eligible_top_fraction_mask(pred_eligible, eligible_mask, frac) + scored = score_exit_policy(baseline_net, early_exit_net, eligible_mask, exits) + rows.append({ + "exit_fraction": float(frac), + "train_incremental_mean_pct_per_original_trade": scored["incremental_mean_pct_per_original_trade"], + "train_exited_delta_mean_pct": scored["exited_delta_mean_pct"], + "train_n_policy_exits": scored["n_policy_exits"], + }) + best = max( + rows, + key=lambda r: ( + float(r["train_incremental_mean_pct_per_original_trade"]), + -float(r["exit_fraction"]), + ), + ) + return {"selected_exit_fraction": float(best["exit_fraction"]), "train_scores": rows} + + +def _split_by_date(dates: np.ndarray, boundary: float) -> Tuple[np.ndarray, np.ndarray]: + uniq = np.array(sorted(set(dates.tolist()))) + if len(uniq) < 3: + return np.zeros(len(dates), dtype=bool), np.zeros(len(dates), dtype=bool) + cut = uniq[max(0, min(len(uniq) - 1, int(round(len(uniq) * boundary)) - 1))] + return dates < cut, dates > cut + + +def _models(rng_seed: int): + from sklearn.ensemble import HistGradientBoostingRegressor + from sklearn.linear_model import Ridge + + return { + "ridge": Ridge(alpha=10.0), + "gbm": HistGradientBoostingRegressor( + max_depth=3, + max_iter=200, + learning_rate=0.05, + l2_regularization=1.0, + random_state=rng_seed, + ), + } + + +def _fit_predict_eligible( + X: np.ndarray, + y_delta: np.ndarray, + train_eligible: np.ndarray, + test_eligible: np.ndarray, + *, + rng_seed: int, +) -> Dict[str, Tuple[np.ndarray, np.ndarray]]: + from sklearn.preprocessing import StandardScaler + + scaler = StandardScaler().fit(X[train_eligible]) + Xtr = scaler.transform(X[train_eligible]) + Xte = scaler.transform(X[test_eligible]) + preds: Dict[str, Tuple[np.ndarray, np.ndarray]] = {} + for name, model in _models(rng_seed).items(): + model.fit(Xtr, y_delta[train_eligible]) + preds[name] = (model.predict(Xtr), model.predict(Xte)) + return preds + + +def _bootstrap_ci(values: np.ndarray, *, n_bootstrap: int, rng: np.random.Generator) -> Tuple[Optional[float], Optional[float]]: + if len(values) < 2 or n_bootstrap <= 0: + return None, None + boot = rng.choice(values, size=(int(n_bootstrap), len(values)), replace=True).mean(axis=1) + lo, hi = np.percentile(boot, [2.5, 97.5]) + return float(lo), float(hi) + + +def _evaluate_model_boundary( + pred_train_eligible: np.ndarray, + pred_test_eligible: np.ndarray, + baseline_train: np.ndarray, + early_train: np.ndarray, + eligible_train: np.ndarray, + baseline_test: np.ndarray, + early_test: np.ndarray, + eligible_test: np.ndarray, + test_groups: np.ndarray, + *, + exit_fractions: Sequence[float], + n_bootstrap: int, + rng: np.random.Generator, + external_sharpe_variance: float, + n_trials: int, + min_checkpoint_eligible_test: int, + min_policy_exits: int, +) -> Dict[str, Any]: + selected = select_exit_fraction(pred_train_eligible, baseline_train, early_train, eligible_train, exit_fractions) + frac = selected["selected_exit_fraction"] + exits = eligible_top_fraction_mask(pred_test_eligible, eligible_test, frac) + scored = score_exit_policy(baseline_test, early_test, eligible_test, exits, test_groups) + group_inc = np.asarray(scored.get("group_incremental", []), dtype=float) + lo, hi = _bootstrap_ci(group_inc, n_bootstrap=n_bootstrap, rng=rng) + sr = per_trade_sharpe(group_inc.tolist()) if len(group_inc) >= 2 else None + dsr = ( + deflated_sharpe_ratio( + sr, + n_trials=n_trials, + sharpe_variance=external_sharpe_variance, + n_samples=len(group_inc), + ) + if sr is not None + else None + ) + ci_go = bool(lo is not None and lo > 0.0) + dsr_go = bool(dsr is not None and dsr >= 0.95) + delta_go = bool(scored["exited_delta_mean_pct"] is not None and scored["exited_delta_mean_pct"] > 0.0) + policy_go = bool( + scored["policy_mean_net_pct"] is not None + and scored["baseline_mean_net_pct"] is not None + and scored["policy_mean_net_pct"] > scored["baseline_mean_net_pct"] + ) + min_eligible_go = bool(scored["n_checkpoint_eligible_trades"] >= int(min_checkpoint_eligible_test)) + min_exits_go = bool(scored["n_policy_exits"] >= int(min_policy_exits)) + return { + "selected_exit_fraction": float(frac), + "train_exit_fraction_scores": selected["train_scores"], + "n_original_test_trades": scored["n_original_trades"], + "n_checkpoint_eligible_test_trades": scored["n_checkpoint_eligible_trades"], + "n_policy_exits": scored["n_policy_exits"], + "exit_fraction_realized": scored["exit_fraction_realized"], + "baseline_mean_net_pct": scored["baseline_mean_net_pct"], + "policy_mean_net_pct": scored["policy_mean_net_pct"], + "incremental_mean_pct_per_original_trade": float(group_inc.mean()) if len(group_inc) else None, + "eligible_incremental_mean_pct": scored["eligible_incremental_mean_pct"], + "incremental_ci95": [lo, hi], + "ci_excludes_zero": ci_go, + "incremental_sharpe": sr, + "incremental_dsr": dsr, + "dsr_gt_0_95": dsr_go, + "exited_delta_mean_pct": scored["exited_delta_mean_pct"], + "exited_baseline_mean_net_pct": scored["exited_baseline_mean_net_pct"], + "exited_early_exit_mean_net_pct": scored["exited_early_exit_mean_net_pct"], + "components": { + "ci_excludes_zero": ci_go, + "dsr_gt_0_95": dsr_go, + "exited_delta_mean_gt_0": delta_go, + "policy_mean_gt_baseline": policy_go, + "min_checkpoint_eligible_test_trades": min_eligible_go, + "min_policy_exits": min_exits_go, + }, + "go": False, + } + + +def run_state_exit_gate( + X: np.ndarray, + baseline_net: Sequence[float], + early_exit_net: Sequence[float], + eligible: Sequence[bool], + dates: Sequence[str], + group_ids: Sequence[str], + *, + boundaries: Sequence[float] = DEFAULT_BOUNDARIES, + primary_boundary: float = DEFAULT_PRIMARY_BOUNDARY, + exit_fractions: Sequence[float] = DEFAULT_EXIT_FRACTIONS, + primary_model_family: str = PRIMARY_MODEL_FAMILY, + n_bootstrap: int = 1000, + rng_seed: int = 0, + external_sharpe_variance: float = DEFAULT_EXTERNAL_SHARPE_VARIANCE, + n_trials: Optional[int] = None, + min_train_eligible: int = 50, + min_test_eligible: int = 50, + min_checkpoint_eligible_test: int = 500, + min_policy_exits: int = 50, +) -> Dict[str, Any]: + """Walk-forward 30s early-exit gate over paired marketable net deltas.""" + + X_arr = np.asarray(X, dtype=float) + base = _as_float_array(baseline_net, name="baseline_net") + early = _as_float_array(early_exit_net, name="early_exit_net") + eligible_arr = np.asarray(eligible, dtype=bool) + dates_arr = np.asarray([str(d) for d in dates]) + groups_arr = np.asarray([str(g) for g in group_ids]) + if X_arr.ndim != 2 or not (len(X_arr) == len(base) == len(early) == len(eligible_arr) == len(dates_arr) == len(groups_arr)): + raise ValueError("X, nets, eligible, dates, and group_ids must have matching lengths") + if primary_model_family not in MODEL_FAMILIES: + raise ValueError("primary_model_family must be one of %r" % (MODEL_FAMILIES,)) + + fractions = _validate_exit_fractions(exit_fractions) + boundaries_t = tuple(float(b) for b in boundaries) + trial_count = int(n_trials) if n_trials is not None else len(fractions) * len(boundaries_t) * 1 + rng = np.random.default_rng(rng_seed) + y_delta = early - base + per_boundary: Dict[str, Dict[float, Optional[float]]] = {m: {} for m in MODEL_FAMILIES} + primary_results: Dict[str, Dict[str, Any]] = {} + + for frac in boundaries_t: + train, test = _split_by_date(dates_arr, frac) + train_eligible = train & eligible_arr + test_eligible = test & eligible_arr + if train_eligible.sum() < min_train_eligible or test_eligible.sum() < min_test_eligible: + for name in per_boundary: + per_boundary[name][float(frac)] = None + continue + preds = _fit_predict_eligible(X_arr, y_delta, train_eligible, test_eligible, rng_seed=rng_seed) + for name, (pred_train, pred_test) in preds.items(): + eval_res = _evaluate_model_boundary( + pred_train, + pred_test, + base[train], + early[train], + eligible_arr[train], + base[test], + early[test], + eligible_arr[test], + groups_arr[test], + exit_fractions=fractions, + n_bootstrap=n_bootstrap, + rng=rng, + external_sharpe_variance=external_sharpe_variance, + n_trials=trial_count, + min_checkpoint_eligible_test=min_checkpoint_eligible_test, + min_policy_exits=min_policy_exits, + ) + per_boundary[name][float(frac)] = eval_res["incremental_mean_pct_per_original_trade"] + if abs(frac - primary_boundary) < 1e-9: + primary_results[name] = eval_res + + primary_train, primary_test = _split_by_date(dates_arr, primary_boundary) + result: Dict[str, Any] = { + "verdict": "NO-GO", + "n_original_trades": int(len(base)), + "n_checkpoint_eligible_trades": int(eligible_arr.sum()), + "n_original_test_trades": int(primary_test.sum()), + "n_checkpoint_eligible_test_trades": int((primary_test & eligible_arr).sum()), + "n_policy_exits": 0, + "n_groups": int(len(set(groups_arr.tolist()))), + "n_dates": int(len(set(dates_arr.tolist()))), + "primary_boundary": float(primary_boundary), + "boundaries": [float(b) for b in boundaries_t], + "exit_fractions": [float(v) for v in fractions], + "primary_model_family": primary_model_family, + "n_trials": trial_count, + "external_sharpe_variance": float(external_sharpe_variance), + "per_boundary_incremental_mean": per_boundary, + "models": {}, + "negative_controls": [], + } + + for name in MODEL_FAMILIES: + m = dict(primary_results.get(name, {})) + if not m: + continue + positive_count = sum(1 for v in per_boundary.get(name, {}).values() if v is not None and float(v) > 0.0) + components = dict(m["components"]) + components["positive_boundaries_ge_3"] = positive_count >= 3 + can_go = bool( + components["ci_excludes_zero"] + and components["dsr_gt_0_95"] + and components["exited_delta_mean_gt_0"] + and components["policy_mean_gt_baseline"] + and components["positive_boundaries_ge_3"] + and components["min_checkpoint_eligible_test_trades"] + and components["min_policy_exits"] + ) + m["positive_boundary_count"] = int(positive_count) + m["components"] = components + m["go"] = bool(can_go and name == primary_model_family) + if name != primary_model_family: + m["diagnostic_only"] = True + result["models"][name] = m + + if primary_model_family not in result["models"]: + result["verdict"] = "INCONCLUSIVE" + result["inconclusive_reason"] = "primary_boundary_not_evaluable" + else: + pm = result["models"][primary_model_family] + result["n_policy_exits"] = int(pm.get("n_policy_exits", 0)) + comps = pm.get("components", {}) + if not comps.get("min_checkpoint_eligible_test_trades", True) or not comps.get("min_policy_exits", True): + result["verdict"] = "INCONCLUSIVE" + result["inconclusive_reason"] = "primary_min_count_not_met" + elif pm.get("go"): + result["verdict"] = "GO" + else: + result["verdict"] = "NO-GO" + return result + + +def run_shuffled_feature_controls( + X: np.ndarray, + baseline_net: Sequence[float], + early_exit_net: Sequence[float], + eligible: Sequence[bool], + dates: Sequence[str], + group_ids: Sequence[str], + *, + n_shuffles: int = 5, + rng_seed: int = 1000, + **kwargs: Any, +) -> List[Dict[str, Any]]: + """Run deterministic shuffled-feature negative controls.""" + + X_arr = np.asarray(X, dtype=float) + controls: List[Dict[str, Any]] = [] + for i in range(int(n_shuffles)): + seed = int(rng_seed) + i + order = np.random.default_rng(seed).permutation(len(X_arr)) + ctrl = run_state_exit_gate(X_arr[order], baseline_net, early_exit_net, eligible, dates, group_ids, rng_seed=seed, **kwargs) + controls.append({ + "shuffle_index": i, + "rng_seed": seed, + "verdict": ctrl.get("verdict"), + "primary_model": ctrl.get("models", {}).get(ctrl.get("primary_model_family", PRIMARY_MODEL_FAMILY), {}), + }) + return controls + + +def apply_negative_control_gate(result: Dict[str, Any], controls: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + """Attach shuffled controls and enforce them as a hard GO blocker.""" + + out = dict(result) + negs = list(controls) + out["negative_controls"] = negs + passed = bool(negs) and all(str(c.get("verdict", "")).upper() == "NO-GO" for c in negs) + out["negative_control_passed"] = passed + if out.get("verdict") == "GO" and not passed: + out["verdict_before_negative_control"] = "GO" + out["verdict"] = "NO-GO" + out["negative_control_blocked_go"] = True + out["go_block_reason"] = "negative_controls_not_all_no_go" + else: + out["negative_control_blocked_go"] = False + return out + + +def _baseline_exit( + prices: Sequence[float], + bids: Sequence[Optional[float]], + asks: Sequence[Optional[float]], + secs: Sequence[int], + *, + entry_idx: int, + tp_pct: float, + sl_pct: float, + time_exit_sec: int, + cost_bps: float, + slippage_bps: float, +) -> Tuple[float, str, int]: + entry_fill = marketable_entry_price(bids[entry_idx], asks[entry_idx], prices[entry_idx], slippage_bps=slippage_bps) + tp_level = entry_fill * (1.0 + tp_pct / 100.0) + sl_level = entry_fill * (1.0 - sl_pct / 100.0) + exit_idx, reason = len(prices) - 1, EXIT_TIME + for j in range(entry_idx + 1, len(prices)): + if int(secs[j]) >= time_exit_sec: + exit_idx, reason = j, EXIT_TIME + break + p = float(prices[j]) + if p <= sl_level: + exit_idx, reason = j, EXIT_SL + break + if p >= tp_level: + exit_idx, reason = j, EXIT_TP + break + exit_fill = marketable_exit_price(bids[exit_idx], asks[exit_idx], prices[exit_idx], slippage_bps=slippage_bps) + return (exit_fill / entry_fill - 1.0) * 100.0 - cost_bps / 100.0, reason, exit_idx + + +def _checkpoint_index(secs: Sequence[int], checkpoint_sec: int) -> int: + idx = 0 + for j, sec in enumerate(secs): + if int(sec) <= int(checkpoint_sec): + idx = j + else: + break + return idx + + +def checkpoint_features_from_rows(rows: Sequence[Dict[str, Any]], checkpoint_sec: int = DEFAULT_CHECKPOINT_SEC) -> Dict[str, float]: + """Causal feature vector using only rows at or before ``checkpoint_sec``.""" + + if not rows: + raise ValueError("rows must be non-empty") + idx = _checkpoint_index([int(r["sec"]) for r in rows], checkpoint_sec) + return causal_feature_vector(rows[: idx + 1]) + + +def simulate_checkpoint_exit_pair( + prices: Sequence[float], + bids: Sequence[Optional[float]], + asks: Sequence[Optional[float]], + secs: Sequence[int], + *, + checkpoint_sec: int = DEFAULT_CHECKPOINT_SEC, + entry_idx: int = 0, + tp_pct: float = 5.0, + sl_pct: float = 1.0, + time_exit_sec: int = DEFAULT_TIME_EXIT_SEC, + cost_bps: float = 23.0, + slippage_bps: float = 0.0, +) -> Dict[str, Any]: + """Return paired baseline-vs-checkpoint early-exit nets.""" + + baseline_net, reason, exit_idx = _baseline_exit( + prices, + bids, + asks, + secs, + entry_idx=entry_idx, + tp_pct=tp_pct, + sl_pct=sl_pct, + time_exit_sec=time_exit_sec, + cost_bps=cost_bps, + slippage_bps=slippage_bps, + ) + cp_idx = _checkpoint_index(secs, checkpoint_sec) + eligible = bool(exit_idx > cp_idx) + if eligible: + entry_fill = marketable_entry_price(bids[entry_idx], asks[entry_idx], prices[entry_idx], slippage_bps=slippage_bps) + exit_fill = marketable_exit_price(bids[cp_idx], asks[cp_idx], prices[cp_idx], slippage_bps=slippage_bps) + early_net = (exit_fill / entry_fill - 1.0) * 100.0 - cost_bps / 100.0 + else: + early_net = baseline_net + return { + "baseline_continue_net_pct": float(baseline_net), + "early_exit_now_net_pct": float(early_net), + "delta_exit_now_pct": float(early_net - baseline_net), + "eligible": eligible, + "baseline_exit_reason": reason, + "baseline_exit_idx": int(exit_idx), + "baseline_exit_sec": int(secs[exit_idx]), + "checkpoint_idx": int(cp_idx), + "checkpoint_sec_observed": int(secs[cp_idx]), + } + + +def extract_state_exit_samples( + db_path: str, + *, + max_symbols: int = 0, + checkpoint_sec: int = DEFAULT_CHECKPOINT_SEC, + tp_pct: float = 5.0, + sl_pct: float = 1.0, + cost_bps: float = 23.0, + slippage_bps: float = 0.0, + strength_thr: float = 100.0, + imbalance_thr: float = 0.5, + entry_cr_thr: float = 2.0, +) -> Dict[str, Any]: + """Extract one state-exit sample per ``ts_imb`` rule instance.""" + + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + from finetune_csv.stom_tick_dataset import connect_readonly, get_table_columns, list_stock_tables + + conn = connect_readonly(db_path) + sel = ["index"] + [_COLS[k] for k in ( + "px", "cr", "strength", "buy_val", "sell_val", "buy_qty", "sell_qty", + "bid_tot", "ask_tot", "bid1", "ask1", "bidq1", "askq1")] + feats: List[List[float]] = [] + baseline_nets: List[float] = [] + early_nets: List[float] = [] + eligible: List[bool] = [] + dates: List[str] = [] + groups: List[str] = [] + exit_reasons: List[str] = [] + n_instances = n_eligible = 0 + try: + tables = list_stock_tables(conn, max_tables=max_symbols if max_symbols > 0 else None) + for table in tables: + cols = set(get_table_columns(conn, table)) + if not all(c in cols for c in sel[1:]): + continue + qt = table.replace('"', '""') + q_sel = ", ".join('"' + c.replace('"', '""') + '"' for c in sel) + raw = conn.execute( + 'SELECT %s FROM "%s" WHERE "%s">0 ORDER BY "index"' % (q_sel, qt, _COLS["px"]) + ).fetchall() + by_session: Dict[str, List[Dict[str, Any]]] = {} + for r in raw: + idx_s = str(int(r[0])) if r[0] is not None else "" + if len(idx_s) != 14: + continue + sec = _sec_of(r[0]) + if sec < 0 or sec > 1800 or r[1] is None or float(r[1]) <= 0: + continue + by_session.setdefault(idx_s[:8], []).append({ + "sec": sec, "price": float(r[1]), "cr": r[2], "ts": r[3], + "buy_val": r[4], "sell_val": r[5], "buy_qty": r[6], "sell_qty": r[7], + "bid_tot": r[8], "ask_tot": r[9], "bid1": r[10], "ask1": r[11], + "bidq1": r[12], "askq1": r[13], + }) + for sess in sorted(by_session): + rows = by_session[sess] + if len(rows) < 120: + continue + e = rows[0] + imb = None + if e["bid_tot"] is not None and e["ask_tot"] is not None: + s = float(e["bid_tot"]) + float(e["ask_tot"]) + imb = (float(e["bid_tot"]) / s) if s > 0 else None + if (e["cr"] is None or float(e["cr"]) < entry_cr_thr + or e["ts"] is None or float(e["ts"]) < strength_thr + or imb is None or imb < imbalance_thr): + continue + prices = [rr["price"] for rr in rows] + bids = [rr["bid1"] for rr in rows] + asks = [rr["ask1"] for rr in rows] + secs_arr = [rr["sec"] for rr in rows] + paired = simulate_checkpoint_exit_pair( + prices, bids, asks, secs_arr, + checkpoint_sec=checkpoint_sec, + tp_pct=tp_pct, + sl_pct=sl_pct, + cost_bps=cost_bps, + slippage_bps=slippage_bps, + ) + fv = checkpoint_features_from_rows(rows, checkpoint_sec=checkpoint_sec) + feats.append([fv[k] for k in FEATURE_NAMES]) + baseline_nets.append(float(paired["baseline_continue_net_pct"])) + early_nets.append(float(paired["early_exit_now_net_pct"])) + eligible.append(bool(paired["eligible"])) + dates.append(sess) + groups.append("%s_%s" % (table, sess)) + exit_reasons.append(str(paired["baseline_exit_reason"])) + n_instances += 1 + n_eligible += int(bool(paired["eligible"])) + finally: + conn.close() + + base_arr = np.array(baseline_nets, dtype=float) + early_arr = np.array(early_nets, dtype=float) + elig_arr = np.array(eligible, dtype=bool) + return { + "X": np.array(feats, dtype=float) if feats else np.zeros((0, len(FEATURE_NAMES))), + "baseline_net": base_arr, + "early_exit_net": early_arr, + "eligible": elig_arr, + "dates": dates, + "group_ids": groups, + "exit_reasons": exit_reasons, + "n_instances": n_instances, + "n_checkpoint_eligible": n_eligible, + "checkpoint_eligible_rate": (n_eligible / n_instances) if n_instances else None, + "n_closed_before_checkpoint": n_instances - n_eligible, + "mean_baseline_net_pct": float(base_arr.mean()) if len(base_arr) else None, + "mean_early_exit_net_pct_eligible": float(early_arr[elig_arr].mean()) if elig_arr.any() else None, + "mean_delta_exit_now_pct_eligible": float((early_arr[elig_arr] - base_arr[elig_arr]).mean()) if elig_arr.any() else None, + "feature_names": FEATURE_NAMES, + "checkpoint_sec": int(checkpoint_sec), + "cost_bps": cost_bps, + "slippage_bps": slippage_bps, + } + + +def _json_default(obj: Any) -> Any: + if isinstance(obj, np.ndarray): + return obj.tolist() + if isinstance(obj, (np.floating, np.integer)): + return obj.item() + if isinstance(obj, np.bool_): + return bool(obj) + return float(obj) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + + root = Path(__file__).resolve().parents[1] + p = argparse.ArgumentParser(description="STOM ④ state-conditioned early-exit gate (RULE/SUPERVISED, NOT RL).") + p.add_argument("--db-path", "--db", default=str(root / "_database" / "stock_tick_back.db")) + p.add_argument("--max-symbols", type=int, default=0) + p.add_argument("--output-dir", default=str(root / ".omx" / "artifacts" / "state_exit")) + p.add_argument("--n-bootstrap", type=int, default=1000) + p.add_argument("--rng-seed", type=int, default=0) + p.add_argument("--n-negative-shuffles", type=int, default=5) + p.add_argument("--slippage-bps", type=float, default=0.0) + p.add_argument("--no-write", action="store_true") + args = p.parse_args(argv) + + print("=== STOM ④ state-conditioned early-exit gate (RULE/SUPERVISED NOT RL) ===") + d = extract_state_exit_samples(args.db_path, max_symbols=args.max_symbols, slippage_bps=args.slippage_bps) + print( + "instances=%d eligible=%d eligible_rate=%s baseline_mean=%s eligible_delta_mean=%s" + % ( + d["n_instances"], + d["n_checkpoint_eligible"], + ("%.3f" % d["checkpoint_eligible_rate"]) if d["checkpoint_eligible_rate"] is not None else "n/a", + ("%.4f%%" % d["mean_baseline_net_pct"]) if d["mean_baseline_net_pct"] is not None else "n/a", + ("%.4f%%" % d["mean_delta_exit_now_pct_eligible"]) if d["mean_delta_exit_now_pct_eligible"] is not None else "n/a", + ) + ) + + if len(d["baseline_net"]) < 200: + res = { + "verdict": "INCONCLUSIVE", + "reason": "too_few_samples_for_preregistered_gate", + "n_original_trades": int(len(d["baseline_net"])), + "extract": {k: v for k, v in d.items() if k not in {"X", "baseline_net", "early_exit_net", "eligible", "dates", "group_ids", "exit_reasons"}}, + } + else: + res = run_state_exit_gate(d["X"], d["baseline_net"], d["early_exit_net"], d["eligible"], d["dates"], d["group_ids"], n_bootstrap=args.n_bootstrap, rng_seed=args.rng_seed) + controls = run_shuffled_feature_controls( + d["X"], d["baseline_net"], d["early_exit_net"], d["eligible"], d["dates"], d["group_ids"], + n_shuffles=args.n_negative_shuffles, + n_bootstrap=max(100, min(args.n_bootstrap, 300)), + rng_seed=args.rng_seed + 1000, + ) + res = apply_negative_control_gate(res, controls) + res["extract"] = {k: v for k, v in d.items() if k not in {"X", "baseline_net", "early_exit_net", "eligible", "dates", "group_ids", "exit_reasons"}} + + print("-- primary boundary %s --" % res.get("primary_boundary", "n/a")) + for name, m in res.get("models", {}).items(): + ci = m.get("incremental_ci95") or [None, None] + print( + " %-5s inc=%s CI95=[%s,%s] DSR=%s delta=%s exits=%s pos_bounds=%s -> %s%s" + % ( + name, + ("%.4f%%" % m["incremental_mean_pct_per_original_trade"]) if m.get("incremental_mean_pct_per_original_trade") is not None else "n/a", + ("%.4f" % ci[0]) if ci[0] is not None else "n/a", + ("%.4f" % ci[1]) if ci[1] is not None else "n/a", + ("%.3f" % m["incremental_dsr"]) if m.get("incremental_dsr") is not None else "n/a", + ("%.4f%%" % m["exited_delta_mean_pct"]) if m.get("exited_delta_mean_pct") is not None else "n/a", + m.get("n_policy_exits"), + m.get("positive_boundary_count"), + "GO" if m.get("go") else "no", + " diagnostic" if m.get("diagnostic_only") else "", + ) + ) + print("negative controls: %s" % [c.get("verdict") for c in res.get("negative_controls", [])]) + print("VERDICT: %s" % res["verdict"]) + + if not args.no_write: + out_dir = Path(args.output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + outp = out_dir / "summary.json" + outp.write_text(json.dumps(res, ensure_ascii=False, indent=2, default=_json_default), encoding="utf-8") + print("wrote -> %s" % outp) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/symbol_norm.py b/stom_rl/symbol_norm.py new file mode 100644 index 000000000..13a611c63 --- /dev/null +++ b/stom_rl/symbol_norm.py @@ -0,0 +1,98 @@ +"""Canonical symbol normalization for STOM portfolio candidate/panel CSVs. + +Korean stock codes are **6-digit zero-padded** strings (the STOM tick DB stores +each symbol as a table whose name is the padded code, e.g. ``000250`` / ``000100``). +When such a code is written to a CSV and re-read with ``pandas.read_csv``, pandas +infers ``int64`` for the column and silently strips the leading zeros — ``000250`` +becomes ``250``. That is internally consistent (every read strips uniformly) but +breaks at boundaries: the dashboard displays ``250``, and a full-universe join +would mis-match the stripped candidate symbol against the DB table name ``000250``. + +This module provides the single shared seam every CSV read of candidate/panel +data should use, so the fix lives in one place instead of a ``dtype={...}`` repeated +across six call sites. + +Normalization contract +----------------------- +* Read the ``symbol`` column as **string** (never let pandas infer ``int64``). +* If the symbol is **all digits**, left-pad to the canonical 6-digit form via + ``zfill(6)`` — so ``000250`` round-trips as ``000250`` even after an int-strip, + and ``250`` is restored to ``000250``. +* If the symbol is **not all digits** (synthetic test fixtures such as ``"A"``), + leave it unchanged — ``zfill(6)`` on ``"A"`` would corrupt it to ``"00000A"``. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional, Union + +import pandas as pd + +# Korean stock codes are 6-digit zero-padded. +KOREAN_SYMBOL_WIDTH: int = 6 + + +def normalize_symbol(value: Any, width: int = KOREAN_SYMBOL_WIDTH) -> str: + """Return the canonical symbol string for one value. + + All-digit codes are zero-padded to ``width`` (default 6); non-numeric symbols + (e.g. synthetic ``"A"``) are returned unchanged as a string. Values that are + missing (``NaN``/``None``) collapse to an empty string so the caller can drop + them with the same ``dropna(subset=["symbol"])`` it already uses. + """ + + try: + if pd.isna(value): + return "" + except (TypeError, ValueError): + pass + text = str(value).strip() + if text == "": + return "" + if text.isdigit(): + return text.zfill(int(width)) + return text + + +def normalize_symbol_series(series: pd.Series, width: int = KOREAN_SYMBOL_WIDTH) -> pd.Series: + """Vectorized :func:`normalize_symbol` over a pandas ``Series``. + + Coerces to string first (so an already-stripped ``int64`` column becomes the + text form), then zero-pads only the all-digit entries, leaving non-numeric + symbols untouched. + """ + + text = series.astype(str).str.strip() + digit_mask = text.str.fullmatch(r"\d+").fillna(False) + padded = text.where(~digit_mask, text.str.zfill(int(width))) + return padded + + +def read_candidates_csv( + path: Union[str, Path], + *, + symbol_column: str = "symbol", + width: int = KOREAN_SYMBOL_WIDTH, + encoding: str = "utf-8-sig", + **read_csv_kwargs: Any, +) -> pd.DataFrame: + """Read a candidate/panel CSV with the symbol column normalized. + + The symbol column is forced to ``str`` at read time (``dtype={symbol: str}``) + so pandas never strips leading zeros, then normalized to the canonical + 6-digit form for all-digit codes. Any caller-supplied ``dtype`` is merged + (the symbol entry always wins). When the file has no symbol column the read + is a plain ``read_csv`` — no normalization is attempted. + """ + + dtype: Optional[dict] = read_csv_kwargs.pop("dtype", None) + merged_dtype = dict(dtype) if isinstance(dtype, dict) else None + if merged_dtype is None: + merged_dtype = {} + merged_dtype.setdefault(symbol_column, str) + + frame = pd.read_csv(path, encoding=encoding, dtype=merged_dtype, **read_csv_kwargs) + if symbol_column in frame.columns: + frame[symbol_column] = normalize_symbol_series(frame[symbol_column], width=width) + return frame diff --git a/stom_rl/timing_gate.py b/stom_rl/timing_gate.py new file mode 100644 index 000000000..4ee53a95d --- /dev/null +++ b/stom_rl/timing_gate.py @@ -0,0 +1,353 @@ +"""P1b — baseline-relative, de-idealized entry-timing gate (the decisive deep-RL test). + +**RULE strategy, NOT reinforcement learning.** + +P1 found a real ranking signal but scored it with idealized fills and NOT +baseline-relative, so its apparent "GO" conflated the model's selection skill with +the gap-up momentum drift the RULE already harvests. P1b closes both gaps: + +* **De-idealized fills**: every entry/exit is MARKETABLE (buy at the ask, sell at + the bid) via :mod:`stom_rl.marketable_fill`, so the spread is paid twice and the + 60-second idealization is gone. The label for a decision second ``t`` is the + de-idealized net of ENTERING at ``t`` and holding to the RULE's exit (TP5/SL1/ + 09:25). +* **Baseline-relative**: the model's chosen entry is compared PAIRED, per session, + against the RULE's fixed 09:00 entry (same de-idealized exit). The incremental + series ``model_net - rule_net`` is what must be positive — so beating the + unconditional drift the rule already captures is NOT rewarded. + +GO requires the incremental mean's session-block bootstrap CI to exclude 0 AND a +Deflated Sharpe > 0.95 computed with a CONSERVATIVE external Sharpe dispersion +(not the too-small within-probe one that inflated P1). Else STOP — the model's +timing adds nothing over the rule and deep RL is not worth building. + +Pure stats (``run_timing_gate`` on arrays) have no I/O; DB extraction is isolated. +""" + +from __future__ import annotations + +from statistics import NormalDist +from typing import Any, Dict, List, Optional, Sequence + +import numpy as np + +from stom_rl.exit_baselines import deflated_sharpe_ratio, per_trade_sharpe +from stom_rl.marketable_fill import simulate_rule_from_entry +from stom_rl.microstructure_features import FEATURE_NAMES, causal_feature_vector +from stom_rl.predictability_probe import _COLS, _sec_of + +# Conservative EXTERNAL per-trade Sharpe dispersion for DSR (NOT the within-probe +# cross-config variance, which is too small and inflated P1's DSR). SD ~0.08 of a +# per-trade Sharpe across the broad strategy search the lab has run. +DEFAULT_EXTERNAL_SHARPE_VARIANCE: float = 0.08 ** 2 +# Lab trial ledger: many strategies/configs tried across the whole lab (1s/1min/ +# session selection, exit grid, this probe family). Conservative count for DSR. +DEFAULT_TRIAL_LEDGER: int = 40 + + +def run_timing_gate( + X: np.ndarray, + y: np.ndarray, # de-idealized net (%) of entering at this decision t + dates: Sequence[str], + group_ids: Sequence[str], + secs: Sequence[float], + baseline_net: Sequence[float], # rule's fixed-09:00 de-idealized net (%), per sample's session + *, + boundaries: Sequence[float] = (0.5, 0.6, 0.7, 0.8, 0.9), + primary_boundary: float = 0.7, + n_bootstrap: int = 1000, + rng_seed: int = 0, + external_sharpe_variance: float = DEFAULT_EXTERNAL_SHARPE_VARIANCE, + n_trials: int = DEFAULT_TRIAL_LEDGER, +) -> Dict[str, Any]: + """Walk-forward, baseline-relative, de-idealized entry-timing gate. + + Per date boundary, train Ridge + gradient-boosting to predict the de-idealized + entry-net ``y`` from causal features; on the test split, the model's policy + picks, per session, the decision second with the highest PREDICTED entry-net and + books that entry's REALIZED de-idealized net. The incremental series is + ``model_net - rule_baseline_net`` per session; GO needs its bootstrap CI > 0 and + DSR > 0.95 (conservative external variance + lab trial ledger). + """ + + from sklearn.ensemble import HistGradientBoostingRegressor + from sklearn.linear_model import Ridge + from sklearn.preprocessing import StandardScaler + + X = np.asarray(X, dtype=float) + y = np.asarray(y, dtype=float) + dates = np.asarray([str(d) for d in dates]) + group_ids = np.asarray([str(g) for g in group_ids]) + secs = np.asarray(secs, dtype=float) + baseline_net = np.asarray(baseline_net, dtype=float) + rng = np.random.default_rng(rng_seed) + uniq_dates = np.array(sorted(set(dates.tolist()))) + + def _split(frac): + cut = uniq_dates[max(0, min(len(uniq_dates) - 1, int(round(len(uniq_dates) * frac)) - 1))] + return dates < cut, dates > cut + + def _models(): + return { + "ridge": Ridge(alpha=10.0), + "gbm": HistGradientBoostingRegressor( + max_depth=3, max_iter=200, learning_rate=0.05, + l2_regularization=1.0, random_state=rng_seed), + } + + def _incremental_per_session(pred, idx): + """Per session in the test index set: model picks the max-predicted-entry + decision second; incremental = its realized de-idealized net - rule baseline.""" + g = group_ids[idx] + diffs = [] + for grp in np.unique(g): + rows = idx[g == grp] + best = rows[int(np.argmax(pred[g == grp]))] + diffs.append(float(y[best] - baseline_net[best])) + return np.array(diffs, dtype=float) + + per_boundary_inc = {"ridge": {}, "gbm": {}} + primary = {} + for frac in boundaries: + train, test = _split(frac) + if train.sum() < 50 or test.sum() < 50: + for m in per_boundary_inc: + per_boundary_inc[m][float(frac)] = None + continue + scaler = StandardScaler().fit(X[train]) + Xtr, Xte = scaler.transform(X[train]), scaler.transform(X[test]) + test_idx = np.where(test)[0] + for name, model in _models().items(): + model.fit(Xtr, y[train]) + pred = model.predict(Xte) + inc = _incremental_per_session(pred, test_idx) + per_boundary_inc[name][float(frac)] = float(inc.mean()) if len(inc) else None + if abs(frac - primary_boundary) < 1e-9: + primary[name] = inc + + results: Dict[str, Any] = { + "n_samples": int(len(y)), + "n_groups": int(len(set(group_ids.tolist()))), + "n_dates": int(len(uniq_dates)), + "n_trials": n_trials, + "external_sharpe_variance": external_sharpe_variance, + "primary_boundary": primary_boundary, + "per_boundary_incremental_mean": per_boundary_inc, + "models": {}, + } + + best_go = False + for name in ("ridge", "gbm"): + inc = primary.get(name) + if inc is None or len(inc) < 2: + continue + mean_inc = float(inc.mean()) + boot = rng.choice(inc, size=(n_bootstrap, len(inc)), replace=True).mean(axis=1) + lo, hi = np.percentile(boot, [2.5, 97.5]) + sr = per_trade_sharpe(inc.tolist()) + dsr = ( + deflated_sharpe_ratio( + sr, n_trials=n_trials, sharpe_variance=external_sharpe_variance, + n_samples=len(inc)) + if sr is not None else None + ) + ci_go = lo > 0.0 + dsr_go = dsr is not None and dsr > 0.95 + go = bool(ci_go and dsr_go) + best_go = best_go or go + results["models"][name] = { + "n_sessions": int(len(inc)), + "incremental_mean_pct": mean_inc, + "incremental_ci95": [float(lo), float(hi)], + "ci_excludes_zero": bool(ci_go), + "incremental_sharpe": sr, + "incremental_dsr": dsr, + "dsr_gt_0_95": bool(dsr_go), + "go": go, + } + + results["verdict"] = "GO" if best_go else "NO-GO" + return results + + +# --------------------------------------------------------------------------- +# DB extraction: ts_imb panels -> (features, de-idealized entry-net label, baseline). +# --------------------------------------------------------------------------- +def extract_timing_samples( + db_path: str, + *, + max_symbols: int = 0, + sample_every_sec: int = 10, + entry_window_end_sec: int = 1080, # last entry decision = 09:18 + tp_pct: float = 5.0, + sl_pct: float = 1.0, + cost_bps: float = 23.0, + slippage_bps: float = 0.0, + strength_thr: float = 100.0, + imbalance_thr: float = 0.5, + entry_cr_thr: float = 2.0, +) -> Dict[str, Any]: + """Per ts_imb instance, emit decision-point features, the de-idealized net of + entering at that second (held to the rule's exit), and the rule's fixed-09:00 + de-idealized net (baseline) for that session.""" + + import sys + from pathlib import Path + + root = Path(__file__).resolve().parents[1] + if str(root) not in sys.path: + sys.path.insert(0, str(root)) + from finetune_csv.stom_tick_dataset import connect_readonly, get_table_columns, list_stock_tables + + conn = connect_readonly(db_path) + feats: List[List[float]] = [] + ys: List[float] = [] + base: List[float] = [] + dates: List[str] = [] + groups: List[str] = [] + secs_out: List[float] = [] + n_instances = 0 + sel = ["index"] + [_COLS[k] for k in ( + "px", "cr", "strength", "buy_val", "sell_val", "buy_qty", "sell_qty", + "bid_tot", "ask_tot", "bid1", "ask1", "bidq1", "askq1")] + try: + tables = list_stock_tables(conn, max_tables=max_symbols if max_symbols > 0 else None) + for table in tables: + cols = set(get_table_columns(conn, table)) + if not all(c in cols for c in sel[1:]): + continue + qt = table.replace('"', '""') + q_sel = ", ".join('"' + c.replace('"', '""') + '"' for c in sel) + cr_c, st_c, bt_c, at_c, px_c = ( + _COLS["cr"], _COLS["strength"], _COLS["bid_tot"], _COLS["ask_tot"], _COLS["px"]) + pre_q = ('SELECT "%s","%s","%s","%s" FROM "%s" WHERE "index">=? AND "index"<=? ' + 'AND "%s">0 ORDER BY "index" LIMIT 1' % (cr_c, st_c, bt_c, at_c, qt, px_c)) + date_q = 'SELECT DISTINCT substr(CAST("index" AS TEXT),1,8) FROM "%s"' % qt + sessions = [str(r[0]) for r in conn.execute(date_q).fetchall() + if r[0] is not None and len(str(r[0])) == 8 and str(r[0]).isdigit()] + for sess in sorted(sessions): + lo, hi = int(sess + "090000"), int(sess + "093000") + pre = conn.execute(pre_q, (lo, hi)).fetchall() + if not pre: + continue + pcr, pts, pbt, pat = pre[0] + if pcr is None or float(pcr) < entry_cr_thr or pts is None or float(pts) < strength_thr: + continue + psum = (float(pbt) if pbt is not None else 0.0) + (float(pat) if pat is not None else 0.0) + if psum <= 0 or (float(pbt) if pbt is not None else 0.0) / psum < imbalance_thr: + continue + raw = conn.execute( + 'SELECT %s FROM "%s" WHERE "index">=? AND "index"<=? ORDER BY "index"' + % (q_sel, qt), (lo, hi)).fetchall() + rows: List[Dict[str, Any]] = [] + for r in raw: + px = r[1] + if px is None or float(px) <= 0 or len(str(int(r[0]))) != 14: + continue + rows.append({ + "sec": _sec_of(r[0]), "price": float(px), "cr": r[2], "ts": r[3], + "buy_val": r[4], "sell_val": r[5], "buy_qty": r[6], "sell_qty": r[7], + "bid_tot": r[8], "ask_tot": r[9], "bid1": r[10], "ask1": r[11], + "bidq1": r[12], "askq1": r[13]}) + if len(rows) < 120: + continue + e = rows[0] + imb = None + if e["bid_tot"] is not None and e["ask_tot"] is not None: + s = float(e["bid_tot"]) + float(e["ask_tot"]) + imb = (float(e["bid_tot"]) / s) if s > 0 else None + if (e["cr"] is None or float(e["cr"]) < entry_cr_thr or e["ts"] is None + or float(e["ts"]) < strength_thr or imb is None or imb < imbalance_thr): + continue + n_instances += 1 + prices = [rr["price"] for rr in rows] + bids = [rr["bid1"] for rr in rows] + asks = [rr["ask1"] for rr in rows] + secs_arr = [rr["sec"] for rr in rows] + rule_net, _ = simulate_rule_from_entry( + prices, bids, asks, secs_arr, 0, + tp_pct=tp_pct, sl_pct=sl_pct, cost_bps=cost_bps, slippage_bps=slippage_bps) + gid = "%s_%s" % (table, sess) + ti = 0 + last_ti = -1 + for tsec in range(sample_every_sec, entry_window_end_sec + 1, sample_every_sec): + while ti + 1 < len(rows) and secs_arr[ti + 1] <= tsec: + ti += 1 + if secs_arr[ti] > tsec or ti == last_ti: + continue + last_ti = ti + net_t, _ = simulate_rule_from_entry( + prices, bids, asks, secs_arr, ti, + tp_pct=tp_pct, sl_pct=sl_pct, cost_bps=cost_bps, slippage_bps=slippage_bps) + fv = causal_feature_vector(rows[:ti + 1]) + feats.append([fv[k] for k in FEATURE_NAMES]) + ys.append(net_t) + base.append(rule_net) + dates.append(sess) + groups.append(gid) + secs_out.append(float(tsec)) + finally: + conn.close() + return { + "X": np.array(feats, dtype=float) if feats else np.zeros((0, len(FEATURE_NAMES))), + "y": np.array(ys, dtype=float), "baseline_net": np.array(base, dtype=float), + "dates": dates, "group_ids": groups, "secs": np.array(secs_out, dtype=float), + "n_instances": n_instances, + } + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + import json + import sys + from pathlib import Path + + try: + sys.stdout.reconfigure(encoding="utf-8") + except Exception: + pass + root = Path(__file__).resolve().parents[1] + p = argparse.ArgumentParser(description="P1b baseline-relative de-idealized timing gate (RULE NOT RL).") + p.add_argument("--db", default=str(root / "_database" / "stock_tick_back.db")) + p.add_argument("--max-symbols", type=int, default=0) + p.add_argument("--slippage-bps", type=float, default=0.0) + p.add_argument("--json-out", default=str(root / ".omx" / "artifacts" / "timing_gate" / "summary.json")) + args = p.parse_args(argv) + + print("=== P1b baseline-relative de-idealized entry-timing gate (RULE NOT RL) ===") + d = extract_timing_samples(args.db, max_symbols=args.max_symbols, slippage_bps=args.slippage_bps) + print("instances=%d samples=%d groups=%d (marketable fills: buy@ask, sell@bid; +%.0fbp slip)" + % (d["n_instances"], len(d["y"]), len(set(d["group_ids"])), args.slippage_bps)) + if len(d["y"]) < 200: + print("too few samples — abort") + return 0 + base = np.asarray(d["baseline_net"]) + print("rule fixed-09:00 de-idealized net: mean=%+.3f%%/trade (per-session baseline)" + % (np.unique(np.array([(g, b) for g, b in zip(d["group_ids"], base)], dtype=object)[:, 1].astype(float)).mean() + if len(base) else 0.0)) + res = run_timing_gate(d["X"], d["y"], d["dates"], d["group_ids"], d["secs"], d["baseline_net"]) + print("-- model timing vs rule (paired incremental, primary boundary %.1f) --" % res["primary_boundary"]) + for name, m in res.get("models", {}).items(): + print(" %-5s inc_mean=%+.4f%%/trade CI95=[%+.4f,%+.4f] excl0=%s | sharpe=%s DSR=%s -> %s" + % (name, m["incremental_mean_pct"], m["incremental_ci95"][0], m["incremental_ci95"][1], + m["ci_excludes_zero"], + ("%.3f" % m["incremental_sharpe"]) if m["incremental_sharpe"] is not None else "n/a", + ("%.3f" % m["incremental_dsr"]) if m["incremental_dsr"] is not None else "n/a", + "GO" if m["go"] else "no")) + print(" per-boundary incremental mean:", + {k: {b: (round(v, 4) if v is not None else None) for b, v in dd.items()} + for k, dd in res["per_boundary_incremental_mean"].items()}) + print(" DSR: external sharpe_variance=%.4f, n_trials=%d (conservative)" + % (res["external_sharpe_variance"], res["n_trials"])) + print("\nVERDICT: %s (GO needs incremental-vs-rule CI>0 AND DSR>0.95; else STOP — RL not worth it)" + % res["verdict"]) + outp = Path(args.json_out) + outp.parent.mkdir(parents=True, exist_ok=True) + res["n_instances"] = d["n_instances"] + outp.write_text(json.dumps(res, ensure_ascii=False, indent=2, default=float), encoding="utf-8") + print("wrote -> %s" % outp) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/trading_env.py b/stom_rl/trading_env.py new file mode 100644 index 000000000..be60b7edf --- /dev/null +++ b/stom_rl/trading_env.py @@ -0,0 +1,442 @@ +"""Gymnasium-style STOM single-episode trading environment. + +This is page 3 of the independent STOM RL lab. The environment is intentionally +small and dependency-light: it follows Gymnasium's reset/step return convention +without requiring ``gymnasium`` as a hard dependency. Later pages can wrap this +class with Gymnasium/Stable-Baselines3 adapters if we decide to add those +dependencies. + +The environment is long-only and model-free: + +* observations contain only historical rows ending before the action timestamp; +* actions are ``hold`` / ``buy`` / ``sell``; +* rewards use a 300-second horizon by default, but the future horizon value is + used only for reward/info, never for observations; +* invalid actions are recorded and penalized instead of silently corrected. +""" + +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, ClassVar, Dict, Mapping, Optional, Sequence, Tuple + +import numpy as np +import pandas as pd + +from .episode_manifest import DEFAULT_OUTPUT_DIR, load_episode_manifest + + +ACTION_HOLD = 0 +ACTION_BUY = 1 +ACTION_SELL = 2 +ACTION_NAMES = { + ACTION_HOLD: "hold", + ACTION_BUY: "buy", + ACTION_SELL: "sell", +} +BASE_MARKET_COLUMNS = ["open", "high", "low", "close", "volume", "amount"] +POSITION_FEATURE_COLUMNS = ["position", "unrealized_return", "time_in_position"] + + +@dataclass(frozen=True) +class StomTickTradingEnvConfig: + """Configuration for ``StomTickTradingEnv``. + + ``reward_horizon_seconds`` defaults to 300 because the previous Kronos + horizon comparison found 300 seconds closest to break-even after cost. The + environment still steps one row at a time; the horizon defines the + forward-looking reward target and the latest safe action index. + """ + + manifest_path: str = str(DEFAULT_OUTPUT_DIR / "episode_manifest.json") + split: str = "train" + episode_id: Optional[str] = None + episode_index: Optional[int] = None + seed: int = 100 + lookback_window: int = 300 + reward_horizon_seconds: int = 300 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + invalid_action_penalty: float = 0.001 + reward_mode: str = "horizon" + normalize_observation: bool = True + feature_columns: Optional[Tuple[str, ...]] = None + feature_schema_mode: str = "compat" + + +class DiscreteSpace: + """Tiny stand-in for ``gymnasium.spaces.Discrete``.""" + + def __init__(self, n: int): + self.n = int(n) + + def contains(self, value: Any) -> bool: + try: + action = int(value) + except (TypeError, ValueError): + return False + return 0 <= action < self.n + + +class BoxSpace: + """Tiny observation-space descriptor compatible with simple smoke tests.""" + + def __init__(self, shape: Sequence[int], dtype: Any = np.float32): + self.shape = tuple(int(part) for part in shape) + self.dtype = dtype + + def contains(self, value: Any) -> bool: + array = np.asarray(value) + return array.shape == self.shape + + +class StomTickTradingEnv: + """Long-only STOM trading environment backed by an episode manifest.""" + + metadata: ClassVar[Dict[str, Any]] = {"render_modes": []} + + def __init__(self, config: Optional[StomTickTradingEnvConfig] = None, **overrides: Any): + if config is not None and overrides: + raise ValueError("Pass either config or keyword overrides, not both.") + self.config = config or StomTickTradingEnvConfig(**overrides) + if self.config.reward_mode not in {"horizon", "mark_to_market"}: + raise ValueError("reward_mode must be one of: horizon, mark_to_market") + if self.config.feature_schema_mode not in {"compat", "strict"}: + raise ValueError("feature_schema_mode must be one of: compat, strict") + if self.config.lookback_window <= 0: + raise ValueError("lookback_window must be positive") + if self.config.reward_horizon_seconds <= 0: + raise ValueError("reward_horizon_seconds must be positive") + + self.manifest = load_episode_manifest(self.config.manifest_path) + self.episodes = [ + episode + for episode in self.manifest.get("episodes", []) + if episode.get("split") == self.config.split + ] + if not self.episodes: + raise ValueError(f"No episodes found for split={self.config.split!r}") + + self.market_feature_columns = list(self.config.feature_columns or tuple(BASE_MARKET_COLUMNS)) + self.feature_columns = self.market_feature_columns + [ + column for column in POSITION_FEATURE_COLUMNS if column not in self.market_feature_columns + ] + self.action_space = DiscreteSpace(3) + self.observation_space = BoxSpace((self.config.lookback_window, len(self.feature_columns))) + self._rng = np.random.default_rng(self.config.seed) + + self.episode: Dict[str, Any] = {} + self.frame = pd.DataFrame() + self.current_idx = 0 + self.max_action_idx = 0 + self.position = 0 + self.entry_price = 0.0 + self.entry_idx: Optional[int] = None + self.realized_return = 0.0 + self.cumulative_reward = 0.0 + self.trade_count = 0 + self.invalid_action_count = 0 + self.last_info: Dict[str, Any] = {} + + @property + def trade_cost_pct(self) -> float: + return (float(self.config.cost_bps) + float(self.config.slippage_bps)) / 10_000.0 + + def reset(self, *, seed: Optional[int] = None, options: Optional[Mapping[str, Any]] = None) -> Tuple[np.ndarray, Dict[str, Any]]: + """Reset to a deterministic episode and return ``(observation, info)``.""" + + if seed is not None: + self._rng = np.random.default_rng(seed) + options = dict(options or {}) + self.episode = self._select_episode(options) + self.frame = self._load_episode_frame(Path(self.episode["source_csv"])) + min_rows = self.config.lookback_window + self.config.reward_horizon_seconds + 1 + if len(self.frame) < min_rows: + raise ValueError( + f"Episode {self.episode.get('episode_id')} has {len(self.frame)} rows, " + f"but at least {min_rows} are required." + ) + self.current_idx = int(self.config.lookback_window) + self.max_action_idx = int(len(self.frame) - self.config.reward_horizon_seconds - 1) + self.position = 0 + self.entry_price = 0.0 + self.entry_idx = None + self.realized_return = 0.0 + self.cumulative_reward = 0.0 + self.trade_count = 0 + self.invalid_action_count = 0 + + info = self._base_info() + info.update( + { + "event": "reset", + "config": asdict(self.config), + "episode": self.episode, + "safe_action_range": [self.config.lookback_window, self.max_action_idx], + "no_future_observation": self._last_observation_timestamp() < self._action_timestamp(), + } + ) + self.last_info = info + return self._observation(), info + + def step(self, action: int) -> Tuple[np.ndarray, float, bool, bool, Dict[str, Any]]: + """Apply an action and return Gymnasium-style step output.""" + + if not self.action_space.contains(action): + raise ValueError(f"Invalid action {action!r}; expected 0, 1, or 2.") + if self.frame.empty: + raise RuntimeError("Call reset() before step().") + if self.current_idx > self.max_action_idx: + raise RuntimeError("Episode is already terminated; call reset().") + + action = int(action) + action_name = ACTION_NAMES[action] + current_price = self._close_at(self.current_idx) + next_price = self._close_at(self.current_idx + 1) + position_before = self.position + invalid_action = False + trade_cost = 0.0 + realized_trade_return = 0.0 + reward = 0.0 + + if action == ACTION_BUY: + if self.position == 1: + invalid_action = True + else: + self.position = 1 + self.entry_price = current_price + self.entry_idx = self.current_idx + self.trade_count += 1 + trade_cost = self.trade_cost_pct + elif action == ACTION_SELL: + if self.position == 0: + invalid_action = True + else: + realized_trade_return = (current_price - self.entry_price) / self.entry_price + self.realized_return += realized_trade_return - self.trade_cost_pct + self.position = 0 + self.entry_price = 0.0 + self.entry_idx = None + self.trade_count += 1 + trade_cost = self.trade_cost_pct + + if invalid_action: + self.invalid_action_count += 1 + reward -= float(self.config.invalid_action_penalty) + elif self.config.reward_mode == "horizon": + horizon_return = self._horizon_return(self.current_idx) + if action == ACTION_SELL and position_before == 1 and not invalid_action: + reward += realized_trade_return + elif self.position == 1: + reward += horizon_return + reward -= trade_cost + else: + one_step_return = (next_price - current_price) / current_price + reward += self.position * one_step_return - trade_cost + + self.cumulative_reward += float(reward) + self.current_idx += 1 + terminated = self.current_idx > self.max_action_idx + truncated = False + + info = self._base_info() + info.update( + { + "event": "step", + "action": action, + "action_name": action_name, + "position_before": position_before, + "position_after": self.position, + "invalid_action": invalid_action, + "trade_cost_pct": trade_cost, + "realized_trade_return": realized_trade_return, + "reward": float(reward), + "cumulative_reward": float(self.cumulative_reward), + "trade_count": self.trade_count, + "invalid_action_count": self.invalid_action_count, + "next_close": next_price, + "terminated": terminated, + "truncated": truncated, + "no_future_observation": self._last_observation_timestamp() < self._action_timestamp(), + } + ) + self.last_info = info + return self._observation(), float(reward), terminated, truncated, info + + def _select_episode(self, options: Mapping[str, Any]) -> Dict[str, Any]: + episode_id = options.get("episode_id") or self.config.episode_id + if episode_id: + for episode in self.episodes: + if episode.get("episode_id") == episode_id: + return dict(episode) + raise ValueError(f"Episode not found in split {self.config.split!r}: {episode_id}") + + episode_index = options.get("episode_index", self.config.episode_index) + if episode_index is not None: + return dict(self.episodes[int(episode_index) % len(self.episodes)]) + return dict(self.episodes[int(self._rng.integers(0, len(self.episodes)))]) + + def _load_episode_frame(self, path: Path) -> pd.DataFrame: + frame = pd.read_csv(path) + required = {"date", *BASE_MARKET_COLUMNS} + missing = sorted(required - set(frame.columns)) + if missing: + raise ValueError(f"Episode CSV missing required columns: {missing}") + frame = frame.copy() + frame["date"] = pd.to_datetime(frame["date"], errors="coerce") + frame = frame.dropna(subset=["date"]).sort_values("date").reset_index(drop=True) + optional_missing = sorted(set(self.market_feature_columns) - set(frame.columns) - set(BASE_MARKET_COLUMNS)) + if optional_missing and self.config.feature_schema_mode == "strict": + raise ValueError(f"Episode CSV missing configured feature columns: {optional_missing}") + numeric_columns = list(dict.fromkeys([*BASE_MARKET_COLUMNS, *self.market_feature_columns])) + for column in numeric_columns: + if column not in frame.columns: + frame[column] = 0.0 + frame[column] = pd.to_numeric(frame[column], errors="coerce") + frame[numeric_columns] = frame[numeric_columns].ffill().bfill().fillna(0.0) + frame = frame.dropna(subset=BASE_MARKET_COLUMNS) + frame = frame[frame["close"] > 0].reset_index(drop=True) + if frame.empty: + raise ValueError(f"Episode CSV has no valid rows: {path}") + return frame + + def _close_at(self, idx: int) -> float: + return float(self.frame["close"].iloc[int(idx)]) + + def _horizon_return(self, idx: int) -> float: + current = self._close_at(idx) + horizon = self._close_at(idx + self.config.reward_horizon_seconds) + return float((horizon - current) / current) + + def _unrealized_return(self, price: Optional[float] = None) -> float: + if self.position == 0 or not self.entry_price: + return 0.0 + price = self._close_at(self.current_idx) if price is None else price + return float((price - self.entry_price) / self.entry_price) + + def _time_in_position(self) -> float: + if self.position == 0 or self.entry_idx is None: + return 0.0 + return float(max(0, self.current_idx - self.entry_idx)) + + def _observation_frame(self) -> pd.DataFrame: + start = self.current_idx - self.config.lookback_window + end = self.current_idx + if start < 0: + raise RuntimeError("current_idx is smaller than lookback_window.") + window = self.frame.iloc[start:end][self.market_feature_columns].copy().reset_index(drop=True) + window["position"] = float(self.position) + window["unrealized_return"] = self._unrealized_return() + window["time_in_position"] = self._time_in_position() + return window[self.feature_columns] + + def _observation(self) -> np.ndarray: + obs = self._observation_frame().to_numpy(dtype=np.float32) + if not self.config.normalize_observation: + return obs + market_width = len(self.market_feature_columns) + market = obs[:, :market_width] + mean = market.mean(axis=0) + std = market.std(axis=0) + obs[:, :market_width] = np.clip((market - mean) / (std + 1e-6), -10.0, 10.0) + return obs + + def _timestamp_at(self, idx: int) -> str: + return pd.Timestamp(self.frame["date"].iloc[int(idx)]).isoformat() + + def _last_observation_timestamp(self) -> str: + return self._timestamp_at(self.current_idx - 1) + + def _action_timestamp(self) -> str: + idx = min(self.current_idx, len(self.frame) - 1) + return self._timestamp_at(idx) + + def _horizon_timestamp(self) -> str: + idx = min(self.current_idx + self.config.reward_horizon_seconds, len(self.frame) - 1) + return self._timestamp_at(idx) + + def _base_info(self) -> Dict[str, Any]: + action_idx = min(self.current_idx, len(self.frame) - 1) + return { + "episode_id": self.episode.get("episode_id"), + "symbol": self.episode.get("symbol"), + "session": self.episode.get("session"), + "split": self.episode.get("split"), + "current_idx": int(self.current_idx), + "max_action_idx": int(self.max_action_idx), + "last_observation_timestamp": self._last_observation_timestamp(), + "action_timestamp": self._action_timestamp(), + "horizon_timestamp": self._horizon_timestamp(), + "close": self._close_at(action_idx), + "horizon_return": self._horizon_return(action_idx) + if action_idx + self.config.reward_horizon_seconds < len(self.frame) + else None, + "position": self.position, + "entry_price": self.entry_price, + "unrealized_return": self._unrealized_return(self._close_at(action_idx)), + "realized_return": self.realized_return, + "reward_mode": self.config.reward_mode, + "feature_columns": list(self.feature_columns), + } + + +def make_env_from_manifest( + manifest_path: str = str(DEFAULT_OUTPUT_DIR / "episode_manifest.json"), + **overrides: Any, +) -> StomTickTradingEnv: + """Convenience factory used by tests, scripts, and future dashboard code.""" + + return StomTickTradingEnv(StomTickTradingEnvConfig(manifest_path=manifest_path, **overrides)) + + +def _demo_payload(env: StomTickTradingEnv) -> Dict[str, Any]: + observation, reset_info = env.reset(seed=env.config.seed) + next_observation, reward, terminated, truncated, step_info = env.step(ACTION_HOLD) + return { + "config": asdict(env.config), + "reset": { + "observation_shape": list(observation.shape), + "info": reset_info, + }, + "first_step": { + "observation_shape": list(next_observation.shape), + "reward": reward, + "terminated": terminated, + "truncated": truncated, + "info": step_info, + }, + } + + +def main(argv: Optional[Sequence[str]] = None) -> int: + import argparse + + parser = argparse.ArgumentParser(description="Smoke-test the STOM trading environment.") + parser.add_argument("--manifest", default=str(DEFAULT_OUTPUT_DIR / "episode_manifest.json")) + parser.add_argument("--split", default="train") + parser.add_argument("--episode-id", default=None) + parser.add_argument("--episode-index", type=int, default=None) + parser.add_argument("--lookback-window", type=int, default=300) + parser.add_argument("--reward-horizon-seconds", type=int, default=300) + parser.add_argument("--seed", type=int, default=100) + args = parser.parse_args(argv) + + env = StomTickTradingEnv( + StomTickTradingEnvConfig( + manifest_path=args.manifest, + split=args.split, + episode_id=args.episode_id, + episode_index=args.episode_index, + lookback_window=args.lookback_window, + reward_horizon_seconds=args.reward_horizon_seconds, + seed=args.seed, + ) + ) + print(json.dumps(_demo_payload(env), ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/stom_rl/walk_forward.py b/stom_rl/walk_forward.py new file mode 100644 index 000000000..b9b0aa9e1 --- /dev/null +++ b/stom_rl/walk_forward.py @@ -0,0 +1,446 @@ +"""Walk-forward (time-period) validation of saved STOM SB3 models (no retraining). + +Evaluates a SAVED Stable-Baselines3 model across sequential, contiguous time +periods (folds) to detect overfitting / regime sensitivity. No training happens +here -- the model is loaded and replayed fold by fold. +""" + +from __future__ import annotations + +import argparse +import json +from dataclasses import asdict, dataclass +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Tuple + +import numpy as np + +from .episode_manifest import DEFAULT_OUTPUT_DIR +from .sb3_smoke import ( + DEFAULT_ALGORITHMS, + Sb3SmokeConfig, + _evaluate_model, + _make_env, + _max_drawdown_pct, + _safe_compounded_return_pct, + _torch_runtime, + _write_csv, + _write_json, +) + + +DEFAULT_WALK_FORWARD_OUTPUT_DIR = Path("webui") / "rl_runs" / "stom_1s_2025_walk_forward" + + +@dataclass(frozen=True) +class WalkForwardConfig: + """Configuration for walk-forward time-period validation of saved SB3 models.""" + + model_dir: str + algorithms: Tuple[str, ...] = ("dqn", "ppo") + eval_split: str = "test" + n_folds: int = 6 + max_episodes_per_fold: int = 30 + max_eval_steps_per_episode: int = 2048 + manifest_path: str = str(DEFAULT_OUTPUT_DIR / "episode_manifest.json") + seed: int = 100 + lookback_window: int = 300 + reward_horizon_seconds: int = 300 + cost_bps: float = 25.0 + slippage_bps: float = 0.0 + device: str = "auto" + output_dir: Optional[str] = None + write_artifacts: bool = True + source_run: Optional[str] = None + + +def _parse_algorithms(raw: str) -> Tuple[str, ...]: + algorithms = tuple(part.strip().lower() for part in raw.split(",") if part.strip()) + unknown = sorted(set(algorithms) - set(DEFAULT_ALGORITHMS)) + if unknown: + raise ValueError(f"Unknown SB3 algorithms: {unknown}. Available: {sorted(DEFAULT_ALGORITHMS)}") + return algorithms + + +def _source_model_name(algorithm: str, training_timesteps: int, fallback: Optional[str]) -> str: + """Mirror performance_leaderboard._sb3_model_name suffix logic for the source model.""" + + timesteps = int(training_timesteps) + if timesteps >= 1000: + suffix = f"{timesteps // 1000}k" if timesteps % 1000 == 0 else str(timesteps) + return f"{algorithm}_{suffix}" + return str(fallback) if fallback else f"{algorithm}_smoke" + + +def _resolve_output_dir(config: WalkForwardConfig) -> Path: + if config.output_dir: + return Path(config.output_dir) + return DEFAULT_WALK_FORWARD_OUTPUT_DIR + + +def _load_source_summary(model_dir: Path) -> Dict[str, Any]: + summary_path = model_dir / "sb3_smoke_summary.json" + if not summary_path.is_file(): + return {} + try: + return json.loads(summary_path.read_text(encoding="utf-8-sig")) + except (json.JSONDecodeError, OSError): + return {} + + +def _source_row_for_algorithm(source_summary: Dict[str, Any], algorithm: str) -> Dict[str, Any]: + for row in source_summary.get("models") or []: + if str(row.get("algorithm") or "").lower() == algorithm: + return dict(row) + return {} + + +def _load_model(algorithm: str, model_path: Path, device: str) -> Any: + from stable_baselines3 import DQN, PPO + + loaders = {"dqn": DQN, "ppo": PPO} + loader = loaders.get(algorithm) + if loader is None: + raise ValueError(f"Unknown algorithm: {algorithm}") + return loader.load(str(model_path), device=device) + + +def _subsample_indices(indices: Sequence[int], cap: int) -> List[int]: + """Evenly (deterministically) subsample ``indices`` down to at most ``cap`` items. + + Preserves order and always keeps the first and last entries when cap >= 2. + """ + + indices = list(indices) + if cap <= 0 or len(indices) <= cap: + return indices + positions = np.linspace(0, len(indices) - 1, num=cap) + chosen = sorted({int(round(pos)) for pos in positions}) + return [indices[pos] for pos in chosen] + + +def _build_session_folds( + episodes: Sequence[Dict[str, Any]], + n_folds: int, + max_episodes_per_fold: int, +) -> List[Dict[str, Any]]: + """Partition time-ordered episodes into ``n_folds`` contiguous folds by session date. + + Folds are split on unique sorted session boundaries so a single session never + spans two folds. Each fold gets a roughly equal contiguous bin of sessions. + Folds exceeding ``max_episodes_per_fold`` are evenly subsampled (deterministic). + Empty folds (when there are fewer unique sessions than folds) are dropped. + """ + + if n_folds <= 0: + raise ValueError("n_folds must be positive") + + # episode index -> session, in time order (episodes already time-ordered by session). + sessions = [str(ep.get("session")) for ep in episodes] + unique_sessions = sorted(set(sessions)) + if not unique_sessions: + return [] + + n_bins = min(n_folds, len(unique_sessions)) + # Contiguous, roughly-equal session bins via numpy array_split. + session_bins = [list(bin_) for bin_ in np.array_split(np.asarray(unique_sessions), n_bins)] + + folds: List[Dict[str, Any]] = [] + for fold_index, bin_sessions in enumerate(session_bins): + bin_set = set(bin_sessions) + fold_indices = [idx for idx, session in enumerate(sessions) if session in bin_set] + if not fold_indices: + continue + fold_indices = _subsample_indices(fold_indices, max_episodes_per_fold) + fold_sessions = sorted({sessions[idx] for idx in fold_indices}) + folds.append( + { + "fold_index": fold_index, + "episode_indices": fold_indices, + "period_start": fold_sessions[0], + "period_end": fold_sessions[-1], + "episode_count": len(fold_indices), + } + ) + return folds + + +# Consistency verdict rule: +# * "consistent" -> every fold's avg_net is positive AND the spread is tight, +# i.e. folds_positive == n_folds and std <= 0.5 * abs(mean). +# * "regime_sensitive"-> some but not all folds positive (1 <= folds_positive <= n_folds-1). +# * "unstable" -> folds_positive <= 1 OR mean_fold_avg_net <= 0. +def _consistency_verdict(fold_avg_nets: Sequence[float]) -> str: + n = len(fold_avg_nets) + if n == 0: + return "unstable" + values = np.asarray(fold_avg_nets, dtype=np.float64) + folds_positive = int(np.sum(values > 0.0)) + mean = float(np.mean(values)) + std = float(np.std(values)) + if folds_positive <= 1 or mean <= 0.0: + return "unstable" + if folds_positive == n and std <= 0.5 * abs(mean): + return "consistent" + return "regime_sensitive" + + +def _summarize_fold( + episode_rows: Sequence[Dict[str, Any]], + trade_rows: Sequence[Dict[str, Any]], + aggregate_equity_curve: Sequence[float], + config: WalkForwardConfig, +) -> Dict[str, Any]: + """Summarize a single fold's evaluation results (mirrors _summarize_model gates).""" + + returns = np.asarray([float(row["episode_return_pct"]) for row in episode_rows], dtype=np.float64) + final_equities = [float(row["final_equity"]) for row in episode_rows] + trade_returns = np.asarray([float(row["net_return_pct"]) for row in trade_rows], dtype=np.float64) + avg_net = float(np.mean(returns)) if len(returns) else 0.0 + max_dd = _max_drawdown_pct(aggregate_equity_curve) + trade_count = len(trade_rows) + episode_count = len(episode_rows) + # Same gate logic as sb3_smoke._summarize_model (defaults: min_avg=0, max_dd=20, min_trade=1). + passes_cost_gate = bool( + avg_net > 0.0 + and max_dd >= -20.0 + and trade_count >= 1 + ) + return { + "episode_count": episode_count, + "trade_count": trade_count, + "trades_per_episode": float(trade_count / episode_count) if episode_count else 0.0, + "avg_episode_net_return_pct": avg_net, + "median_episode_net_return_pct": float(np.median(returns)) if len(returns) else 0.0, + "compounded_return_pct": _safe_compounded_return_pct(final_equities), + "avg_trade_net_return_pct": float(np.mean(trade_returns)) if len(trade_returns) else 0.0, + "hit_rate": float(np.mean(trade_returns > 0.0)) if len(trade_returns) else 0.0, + "max_drawdown_pct": max_dd, + "passes_cost_gate": passes_cost_gate, + } + + +def _smoke_config_for_walk_forward(config: WalkForwardConfig, algorithm: str, output_dir: Path) -> Sb3SmokeConfig: + return Sb3SmokeConfig( + manifest_path=config.manifest_path, + output_dir=str(output_dir), + eval_split=config.eval_split, + algorithms=(algorithm,), + total_timesteps=0, + # Large cap so explicit episode_indices are never truncated by the gate inside _evaluate_model. + max_eval_episodes=10_000_000, + max_eval_steps_per_episode=config.max_eval_steps_per_episode, + seed=config.seed, + lookback_window=config.lookback_window, + reward_horizon_seconds=config.reward_horizon_seconds, + cost_bps=config.cost_bps, + slippage_bps=config.slippage_bps, + device=config.device, + write_artifacts=False, + write_live_events=False, + ) + + +def run_walk_forward(config: WalkForwardConfig) -> Dict[str, Any]: + """Evaluate saved SB3 models across contiguous time-period folds (no training).""" + + algorithms = tuple(config.algorithms) + if not algorithms: + raise ValueError("At least one SB3 algorithm is required.") + + model_dir = Path(config.model_dir) + source_run = config.source_run or model_dir.name + source_summary = _load_source_summary(model_dir) + output_dir = _resolve_output_dir(config) + runtime = _torch_runtime() + + # 1. Build one probe env to read the (time-ordered) eval-split episodes. + probe_smoke_config = _smoke_config_for_walk_forward(config, algorithms[0], output_dir) + probe = _make_env(probe_smoke_config, split=config.eval_split) + episodes = list(probe.raw_env.episodes) + probe.close() + + # 2. Partition into contiguous session-date folds. + folds = _build_session_folds(episodes, config.n_folds, config.max_episodes_per_fold) + + fold_rows: List[Dict[str, Any]] = [] + per_algorithm: List[Dict[str, Any]] = [] + evaluated_algorithms: List[str] = [] + skipped: List[Dict[str, str]] = [] + + for algorithm in algorithms: + model_path = model_dir / f"{algorithm}_model.zip" + if not model_path.is_file(): + message = f"Skipping {algorithm}: model file not found at {model_path}" + print(message) + skipped.append({"algorithm": algorithm, "reason": str(model_path)}) + continue + + source_row = _source_row_for_algorithm(source_summary, algorithm) + source_timesteps = int(float(source_row.get("training_timesteps") or 0)) + source_model = _source_model_name(algorithm, source_timesteps, source_row.get("model")) + + smoke_config = _smoke_config_for_walk_forward(config, algorithm, output_dir) + model = _load_model(algorithm, model_path, config.device) + + algo_fold_metrics: List[Dict[str, Any]] = [] + for fold in folds: + evaluation = _evaluate_model( + model, + algorithm, + smoke_config, + episode_indices=fold["episode_indices"], + ) + metrics = _summarize_fold( + evaluation["episodes"], + evaluation["trades"], + evaluation["aggregate_equity_curve"], + config, + ) + row = { + "algorithm": algorithm, + "source_run": source_run, + "source_model": source_model, + "fold_index": fold["fold_index"], + "period_start": fold["period_start"], + "period_end": fold["period_end"], + **metrics, + } + fold_rows.append(row) + algo_fold_metrics.append(row) + + fold_avg_nets = [float(row["avg_episode_net_return_pct"]) for row in algo_fold_metrics] + n_evaluated_folds = len(fold_avg_nets) + folds_positive = int(sum(1 for value in fold_avg_nets if value > 0.0)) + mean_avg = float(np.mean(fold_avg_nets)) if fold_avg_nets else 0.0 + std_avg = float(np.std(fold_avg_nets)) if fold_avg_nets else 0.0 + worst_dd = ( + float(min(float(row["max_drawdown_pct"]) for row in algo_fold_metrics)) + if algo_fold_metrics + else 0.0 + ) + per_algorithm.append( + { + "algorithm": algorithm, + "source_run": source_run, + "source_model": source_model, + "n_folds": n_evaluated_folds, + "folds_positive": folds_positive, + "mean_fold_avg_net": mean_avg, + "std_fold_avg_net": std_avg, + "min_fold_avg_net": float(min(fold_avg_nets)) if fold_avg_nets else 0.0, + "max_fold_avg_net": float(max(fold_avg_nets)) if fold_avg_nets else 0.0, + "worst_fold_max_drawdown_pct": worst_dd, + "consistency": _consistency_verdict(fold_avg_nets), + } + ) + evaluated_algorithms.append(algorithm) + + ranking = sorted(per_algorithm, key=lambda row: float(row["mean_fold_avg_net"]), reverse=True) + summary: Dict[str, Any] = { + "algorithm_count": len(evaluated_algorithms), + "algorithms": list(evaluated_algorithms), + "skipped_algorithms": skipped, + "source_run": source_run, + "n_folds": len(folds), + "fold_periods": [ + { + "fold_index": fold["fold_index"], + "period_start": fold["period_start"], + "period_end": fold["period_end"], + "episode_count": fold["episode_count"], + } + for fold in folds + ], + "per_algorithm": per_algorithm, + "best_algorithm_by_mean_fold_avg_net": ranking[0]["algorithm"] if ranking else None, + "best_model": ranking[0]["source_model"] if ranking else None, + "device_requested": config.device, + "cuda_available": runtime["cuda_available"], + } + + payload: Dict[str, Any] = { + "mode": "stom_rl_walk_forward", + "config": asdict(config), + "runtime": runtime, + "summary": summary, + "folds": fold_rows, + "artifacts": { + "output_dir": str(output_dir), + "report_json": str(output_dir / "walk_forward_report.json"), + "folds_csv": str(output_dir / "walk_forward_folds.csv"), + }, + } + + if config.write_artifacts: + _write_json(output_dir / "walk_forward_report.json", payload) + _write_csv( + output_dir / "walk_forward_folds.csv", + fold_rows, + [ + "algorithm", + "source_run", + "source_model", + "fold_index", + "period_start", + "period_end", + "episode_count", + "trade_count", + "trades_per_episode", + "avg_episode_net_return_pct", + "median_episode_net_return_pct", + "compounded_return_pct", + "avg_trade_net_return_pct", + "hit_rate", + "max_drawdown_pct", + "passes_cost_gate", + ], + ) + return payload + + +def _parse_args(argv: Optional[Sequence[str]] = None) -> WalkForwardConfig: + parser = argparse.ArgumentParser( + description="Walk-forward (time-period) validation of saved STOM SB3 models without retraining.", + ) + parser.add_argument("--model-dir", required=True, help="Directory containing {algo}_model.zip and source summary.") + parser.add_argument("--algorithms", default=",".join(("dqn", "ppo"))) + parser.add_argument("--n-folds", type=int, default=6) + parser.add_argument("--max-episodes-per-fold", type=int, default=30) + parser.add_argument("--eval-split", default="test") + parser.add_argument("--max-eval-steps-per-episode", type=int, default=2048) + parser.add_argument("--manifest", default=str(DEFAULT_OUTPUT_DIR / "episode_manifest.json")) + parser.add_argument("--seed", type=int, default=100) + parser.add_argument("--cost-bps", type=float, default=25.0) + parser.add_argument("--slippage-bps", type=float, default=0.0) + parser.add_argument("--device", default="auto") + parser.add_argument("--output-dir", default=None) + parser.add_argument("--no-write", action="store_true") + parser.add_argument("--source-run", default=None) + args = parser.parse_args(argv) + return WalkForwardConfig( + model_dir=args.model_dir, + algorithms=_parse_algorithms(args.algorithms), + n_folds=args.n_folds, + max_episodes_per_fold=args.max_episodes_per_fold, + eval_split=args.eval_split, + max_eval_steps_per_episode=args.max_eval_steps_per_episode, + manifest_path=args.manifest, + seed=args.seed, + cost_bps=args.cost_bps, + slippage_bps=args.slippage_bps, + device=args.device, + output_dir=args.output_dir, + write_artifacts=not args.no_write, + source_run=args.source_run, + ) + + +def main(argv: Optional[Sequence[str]] = None) -> int: + payload = run_walk_forward(_parse_args(argv)) + print(json.dumps(payload, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/AGENTS.md b/tests/AGENTS.md new file mode 100644 index 000000000..7267e8072 --- /dev/null +++ b/tests/AGENTS.md @@ -0,0 +1,48 @@ +# tests Knowledge + +## Overview + +`tests/` contains pytest coverage for STOM rules/RL, dashboard APIs, official +dashboard routing/static markers, training monitors, and model regressions. + +## Conventions + +- Prefer targeted regression tests next to the feature being changed. +- For trading research, test accounting and guardrails, not only happy-path + output files. +- Add tests for negative controls, path traversal, invalid actions, and + baseline comparison whenever those risks are touched. +- Do not rely on whole-repo `pytest -q` as the only signal; some torch/qlib + imports can be environment-sensitive. +- Keep synthetic fixtures small and deterministic. +- If a test encodes a trading claim, include cost assumptions in the assertion + or fixture name. +- Use targeted tests before long DB/full-universe runs. + +## Useful Test Groups + +```powershell +# RL dashboard +py -3.11 -m pytest tests/test_stom_rl_dashboard_api.py tests/test_stom_rl_dashboard_tab.py -q + +# Orderbook RL +py -3.11 -m pytest tests/test_stom_rl_orderbook_env.py tests/test_stom_rl_orderbook_sb3.py -q + +# Rule/gate accounting +py -3.11 -m pytest tests/test_stom_rl_gap_up_backtest.py tests/test_stom_rl_skip_gate.py tests/test_stom_rl_state_exit_gate.py tests/test_stom_rl_marketable_fill.py -q + +# Official dashboard shell/static build markers +py -3.11 -m pytest tests/test_v2_route.py tests/test_v2_dist_marker.py -q +``` + +## Reporting + +When reporting results, include exact command, pass/fail count, and any skipped +or environment-gated tests. + +## Gotchas + +- Some dashboard tests inspect source/dist markers rather than launching a full + browser. +- Whole-repo collection may include generated or environment-heavy modules; use + focused test groups for debugging. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..fc6e791f0 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Project test package.""" diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 000000000..0120f37ce --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Shared deterministic test fixtures.""" diff --git a/tests/fixtures/stom_opening_rl.py b/tests/fixtures/stom_opening_rl.py new file mode 100644 index 000000000..da38e2144 --- /dev/null +++ b/tests/fixtures/stom_opening_rl.py @@ -0,0 +1,117 @@ +"""Deterministic opening-window fixtures for STOM RL tests.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Final + +import pandas as pd # noqa: PANDAS_OK - matches existing STOM orderbook test fixtures + + +OPENING_SECONDS: Final[tuple[int, ...]] = (0, 1, 2, 3, 4, 5) +KOREAN_COLUMNS: Final[tuple[str, ...]] = ( + "symbol", + "session", + "index", + "현재가", + "체결강도", + "초당매수금액", + "초당매도금액", + "초당매수수량", + "초당매도수량", + "초당거래대금", + "매수총잔량", + "매도총잔량", + "매수호가1", + "매도호가1", + "매수잔량1", + "매도잔량1", +) + + +def _timestamp(session: str, second: int) -> int: + return int(f"{session}0900{second:02d}") + + +def opening_orderbook_frame( + *, + symbol: str, + session: str, + missing_quote: bool = False, + spread_edge: bool = False, +) -> pd.DataFrame: + """Return a tiny deterministic STOM-like opening orderbook frame.""" + + rows: list[dict[str, str | int | float]] = [] + for offset, second in enumerate(OPENING_SECONDS): + price = 1000.0 + offset * 3.0 + bid1 = price - 1.0 + ask1 = price + (0.0 if spread_edge and offset == 2 else 1.0) + bid_qty = 600.0 + offset * 25.0 + ask_qty = 420.0 - min(offset * 10.0, 50.0) + if missing_quote and offset in {2, 4}: + bid1 = 0.0 + ask1 = 0.0 + bid_qty = 0.0 + ask_qty = 0.0 + buy_amount = 1_200_000.0 + offset * 150_000.0 + sell_amount = 900_000.0 + offset * 50_000.0 + rows.append( + { + "symbol": symbol, + "session": session, + "index": _timestamp(session, second), + "현재가": price, + "체결강도": 115.0 + offset * 4.0, + "초당매수금액": buy_amount, + "초당매도금액": sell_amount, + "초당매수수량": 100.0 + offset * 5.0, + "초당매도수량": 80.0 + offset * 2.0, + "초당거래대금": (buy_amount + sell_amount) / 1_000_000.0, + "매수총잔량": 1_800.0 + offset * 60.0, + "매도총잔량": 1_200.0 - offset * 20.0, + "매수호가1": bid1, + "매도호가1": ask1, + "매수잔량1": bid_qty, + "매도잔량1": ask_qty, + } + ) + return pd.DataFrame(rows, columns=list(KOREAN_COLUMNS)) + + +def build_opening_fixture_frames() -> list[pd.DataFrame]: + """Build chronological multi-session fixtures with preserved stock codes.""" + + return [ + opening_orderbook_frame(symbol="000250", session="20250103"), + opening_orderbook_frame(symbol="005930", session="20250106", spread_edge=True), + opening_orderbook_frame(symbol="000660", session="20250107", missing_quote=True), + ] + + +def quote_coverage_report(frame: pd.DataFrame) -> dict[str, float | int]: + """Summarize best-quote coverage for a fixture frame.""" + + has_quote = ( + frame["매수호가1"].astype(float).gt(0.0) + & frame["매도호가1"].astype(float).gt(0.0) + & frame["매수잔량1"].astype(float).gt(0.0) + & frame["매도잔량1"].astype(float).gt(0.0) + ) + total_rows = int(len(frame)) + quote_rows = int(has_quote.sum()) + missing_rows = total_rows - quote_rows + coverage = quote_rows / total_rows if total_rows else 0.0 + return { + "total_rows": total_rows, + "quote_rows": quote_rows, + "missing_quote_rows": missing_rows, + "quote_coverage": coverage, + } + + +def write_opening_fixture_csv(path: Path, frame: pd.DataFrame) -> None: + """Write fixture data with UTF-8-SIG for Korean column compatibility.""" + + path.parent.mkdir(parents=True, exist_ok=True) + frame.to_csv(path, index=False, encoding="utf-8-sig") diff --git a/tests/test_cli_import_paths.py b/tests/test_cli_import_paths.py new file mode 100644 index 000000000..aa1a761b7 --- /dev/null +++ b/tests/test_cli_import_paths.py @@ -0,0 +1,20 @@ +import subprocess +import sys + + +def test_train_sequential_direct_script_help_imports_project_model(): + result = subprocess.run( + [ + sys.executable, + "finetune_csv/train_sequential.py", + "--help", + ], + check=False, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + ) + + assert result.returncode == 0 + assert "Kronos Model Sequential Fine-tuning Training" in result.stdout diff --git a/tests/test_daily_ohlcv_dashboard_api.py b/tests/test_daily_ohlcv_dashboard_api.py new file mode 100644 index 000000000..e21c87f9d --- /dev/null +++ b/tests/test_daily_ohlcv_dashboard_api.py @@ -0,0 +1,2765 @@ +import json +import hashlib +import csv +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from webui.app import app as flask_app # noqa: E402 + + +def test_daily_ohlcv_db_summary_api_is_read_only_and_bounded(): + client = flask_app.test_client() + response = client.get('/api/daily-ohlcv/db-summary?table_limit=2&flag_limit=2&window_limit=2') + assert response.status_code == 200 + payload = response.get_json() + assert payload['read_only'] is True + assert payload['query_only'] is True + assert payload['table_count'] == 4727 + assert payload['total_rows'] == 14691020 + assert payload['prefix_counts']['A'] == 4166 + assert payload['prefix_counts']['Q'] == 561 + assert payload['price_basis'] == 'unknown' + assert payload['price_basis_status'] == 'UNKNOWN_CONFIRMED' + assert payload['decision_grade_return_status'] == 'BLOCKED_UNTIL_PRICE_BASIS_VERIFIED' + assert payload['price_basis_audit']['status'] == 'UNKNOWN_CONFIRMED' + assert set(payload['price_basis_audit']['component_status']) == { + 'adjusted_price', + 'raw_price', + 'split_adjustment', + 'dividend_adjustment', + } + assert payload['price_basis_audit']['blocking_implications'] + assert 'model_build_or_candidate_promotion' in payload['price_basis_blocked_uses'] + assert payload['price_basis_user_guidance'][0]['section'] == 'D0 summary' + assert payload['quality_scan_scope'] == 'all_tables' + assert len(payload['table_summaries']) <= 2 + assert len(payload['quality_flags']) <= 2 + assert len(payload['material_unknown_adjustment_windows']) <= 2 + assert 'no live/broker/orders' in payload['guardrail'] + +def test_daily_ohlcv_db_summary_stale_artifact_fails_closed(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / 'db_summary' + run_dir = root / 'stale' + run_dir.mkdir(parents=True) + (run_dir / 'db_summary.json').write_text( + json.dumps({ + 'schema_version': 1, + 'table_count': 1, + 'total_rows': 1, + 'read_only': False, + 'query_only': False, + 'guardrail': 'unsafe optimistic stale artifact', + 'price_basis': 'raw', + 'price_basis_status': 'RAW_VERIFIED', + 'decision_grade_return_status': 'READY', + 'price_basis_audit': { + 'status': 'RAW_VERIFIED', + 'blocked_uses': [], + }, + }), + encoding='utf-8', + ) + monkeypatch.setattr(daily_dashboard, 'DB_SUMMARY_ROOT', root) + + payload = daily_dashboard.load_daily_db_summary(run='stale', table_limit=0, flag_limit=0, window_limit=0) + + assert payload['price_basis'] == 'unknown' + assert payload['price_basis_status'] == 'UNKNOWN_CONFIRMED' + assert payload['decision_grade_return_status'] == 'BLOCKED_UNTIL_PRICE_BASIS_VERIFIED' + assert payload['price_basis_audit']['status'] == 'UNKNOWN_CONFIRMED' + assert payload['price_basis_audit']['normalized_from_artifact']['price_basis'] == 'raw' + assert payload['price_basis_audit']['blocked_uses'] == [ + 'decision_grade_return_labels', + 'model_build_or_candidate_promotion', + 'paper_forward_or_live_readiness_claims', + ] + assert payload['read_only'] is True + assert payload['query_only'] is True + assert 'no DB mutation' in payload['guardrail'] + assert 'no live/broker/orders' in payload['guardrail'] + assert 'no DB mutation' in payload['read_only_dashboard_note'] + assert payload['artifact_status'] == 'LOADED_GENERATED_ARTIFACT' + + +def test_daily_ohlcv_symbol_preserves_leading_zero_and_rejects_unsafe_code(): + client = flask_app.test_client() + ok = client.get('/api/daily-ohlcv/symbol/000250?limit=1') + assert ok.status_code == 200 + payload = ok.get_json() + assert payload['table'] == 'A000250' + assert payload['code'] == '000250' + assert payload['price_basis'] == 'unknown' + assert len(payload['sample_rows_desc']) == 1 + + bad = client.get('/api/daily-ohlcv/symbol/..%2FA000250') + assert bad.status_code == 400 + + +def test_daily_ohlcv_universe_preview_api_exposes_watch_counts_and_required_fields(): + client = flask_app.test_client() + response = client.get('/api/daily-ohlcv/universe/preview?limit=3') + assert response.status_code == 200 + payload = response.get_json() + assert payload['verdict'] == 'WATCH_HEURISTIC_UNIVERSE' + assert payload['table_count'] == 4727 + assert payload['stockinfo_matched_table_count'] == 4229 + assert payload['stockinfo_unmatched_table_count'] == 498 + assert payload['unmatched_quarantine_count'] == 498 + assert payload['q_product_count'] == 561 + assert payload['official_metadata_status'] == 'MISSING' + assert payload['official_metadata_coverage_status'] == 'MISSING' + assert payload['universe_certification_status'] == 'BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW' + assert 'model_build_or_candidate_promotion' in payload['universe_blocked_uses'] + assert payload['universe_user_guidance'][0]['section'] == 'D1 summary' + assert 'official_metadata_required_columns' in payload + assert 'official_metadata_unmatched_table_count' in payload + assert len(payload['symbols']) <= 3 + assert set(payload['required_fields']) == { + 'classification_source', + 'classification_confidence', + 'exclusion_reason', + 'metadata_sha', + 'review_status', + 'official_metadata_status', + 'official_metadata_coverage_status', + 'universe_certification_status', + } + assert 'no live/broker/orders' in payload['guardrail'] +def test_daily_ohlcv_universe_preview_no_artifact_keeps_full_counts(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + monkeypatch.setattr(daily_dashboard, "DEFAULT_UNIVERSE_ROOT", tmp_path / "empty_universe_root") + payload = daily_dashboard.load_universe_preview(limit=0) + assert payload["artifact_status"] == "GENERATED_ON_DEMAND_READ_ONLY" + assert payload["table_count"] == 4727 + assert payload["stockinfo_unmatched_table_count"] == 498 + assert payload["official_metadata_status"] == "MISSING" + assert payload["official_metadata_coverage_status"] == "MISSING" + assert payload["universe_certification_status"] == "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW" + assert payload["symbols_total"] == 4727 + assert payload["symbols"] == [] +def test_daily_ohlcv_universe_preview_stale_artifact_fails_closed(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / "universe" + run_dir = root / "optimistic" + run_dir.mkdir(parents=True) + (run_dir / "universe.json").write_text( + json.dumps({ + "verdict": "OFFICIAL_OR_MANUAL_REVIEWED", + "review_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "universe_review_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "official_metadata_status": "OFFICIAL_VERIFIED", + "official_metadata_coverage_status": "COMPLETE", + "universe_certification_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "official_metadata_path": str(tmp_path / "missing_krx.csv"), + "table_count": 7, + "symbols": [], + "exclusions": [], + "required_fields": [], + }), + encoding="utf-8", + ) + monkeypatch.setattr(daily_dashboard, "DEFAULT_UNIVERSE_ROOT", root) + + payload = daily_dashboard.load_universe_preview(run="optimistic", limit=0) + + assert payload["verdict"] == "WATCH_HEURISTIC_UNIVERSE" + assert payload["review_status"] == "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW" + assert payload["official_metadata_status"] == "MISSING" + assert payload["official_metadata_coverage_status"] == "MISSING" + assert payload["universe_certification_status"] == "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW" + assert payload["official_metadata_matched_table_count"] == 0 + assert payload["official_metadata_unmatched_table_count"] == 7 + assert "model_build_or_candidate_promotion" in payload["universe_blocked_uses"] + + + + + +def test_daily_ohlcv_artifacts_api_is_bounded(): + client = flask_app.test_client() + response = client.get('/api/daily-ohlcv/artifacts?limit=1') + assert response.status_code == 200 + payload = response.get_json() + assert payload['read_only'] is True + assert payload['limit'] == 1 + assert len(payload['artifacts']) <= 1 + assert 'artifacts_total' in payload + assert 'artifacts_truncated' in payload + +def test_daily_ohlcv_dataset_api_exposes_d2_guardrails_and_samples(): + client = flask_app.test_client() + latest = client.get('/api/daily-ohlcv/dataset/latest?limit=2') + assert latest.status_code == 200 + payload = latest.get_json() + assert payload['status'] == 'PASS' + assert payload['artifact_scope'] == 'BOUNDED_PREVIEW' + assert payload['price_basis'] == 'unknown' + assert payload['universe_verdict'] == 'WATCH_HEURISTIC_UNIVERSE' + assert payload['price_basis_status'] == 'UNKNOWN_CONFIRMED' + assert payload['decision_grade_return_status'] == 'BLOCKED_UNTIL_PRICE_BASIS_VERIFIED' + assert payload['official_metadata_status'] == 'MISSING' + assert payload['official_metadata_coverage_status'] == 'MISSING' + assert payload['universe_certification_status'] == 'BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW' + assert payload['upstream_gate_blockers'] == [ + 'D0_PRICE_BASIS_NOT_VERIFIED', + 'D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED', + ] + assert payload['model_readiness'] == 'DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS' + assert 'model_build_or_candidate_promotion' in payload['dataset_blocked_uses'] + assert payload['dataset_user_guidance'][0]['section'] == 'D2 summary' + assert payload['leakage_status'] == 'PASS' + assert payload['split_chronology_status'] == 'PASS' + assert payload['row_counts']['feature_rows'] == 80000 + assert len(payload['samples']['split_assignments']) <= 2 + assert 'no training/order/live/profit' in payload['read_only_dashboard_note'] + + chart = client.get('/api/daily-ohlcv/charts/dataset').get_json() + assert chart['status'] == 'PASS' + assert chart['split_chronology_status'] == 'PASS' + assert chart['upstream_gate_blockers'] == [ + 'D0_PRICE_BASIS_NOT_VERIFIED', + 'D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED', + ] + assert chart['model_readiness'] == 'DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS' + assert 'not a profit' in chart['guardrail'] + + artifacts = client.get('/api/daily-ohlcv/dataset/artifacts?limit=1').get_json() + assert artifacts['runs'][0]['kind'] == 'daily_ohlcv_dataset' + +def test_daily_ohlcv_dataset_stale_artifact_fails_closed(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / "dataset" + run_dir = root / "stale_optimistic" + run_dir.mkdir(parents=True) + (run_dir / "dataset_manifest.json").write_text( + json.dumps( + { + "schema_version": 1, + "run_id": "stale_optimistic", + "artifact_scope": "BOUNDED_PREVIEW", + "price_basis": "raw", + "price_basis_status": "RAW_VERIFIED", + "decision_grade_return_status": "READY", + "universe_verdict": "OFFICIAL_OR_MANUAL_REVIEWED", + "universe_review_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "official_metadata_status": "OFFICIAL_VERIFIED", + "official_metadata_coverage_status": "COMPLETE", + "universe_certification_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "upstream_gate_blockers": [], + "dataset_blocked_uses": [], + "model_readiness": "DATASET_RESEARCH_READY_FOR_BASELINE_ONLY", + "decision_grade_status": "PASS", + "leakage_status": "PASS", + "split_chronology_status": "PASS", + "row_counts": {"feature_rows": 3, "eligible_rows": 3}, + "split_summary": {"row_counts": {"train": 1, "val": 1, "test": 1}}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(daily_dashboard, "DEFAULT_DATASET_ROOT", root) + + latest = daily_dashboard.load_dataset_latest(run="stale_optimistic", sample_limit=0) + assert latest["upstream_gate_blockers"] == [ + "D0_PRICE_BASIS_NOT_VERIFIED", + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED", + ] + assert latest["model_readiness"] == "DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS" + assert latest["decision_grade_status"] == "BLOCKED_BY_UPSTREAM_D0_D1_GUARDRAILS" + assert latest["price_basis"] == "unknown" + assert latest["universe_verdict"] == "WATCH_HEURISTIC_UNIVERSE" + assert { + "decision_grade_return_labels", + "model_build_or_candidate_promotion", + "paper_forward_or_live_readiness_claims", + } <= set(latest["dataset_blocked_uses"]) + assert latest["dataset_user_guidance"][0]["section"] == "D2 summary" + + chart = daily_dashboard.load_dataset_chart(run="stale_optimistic") + assert chart["upstream_gate_blockers"] == [ + "D0_PRICE_BASIS_NOT_VERIFIED", + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED", + ] + assert chart["model_readiness"] == "DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS" + + artifacts = daily_dashboard.list_dataset_artifacts(limit=1) + row = artifacts["runs"][0] + assert row["model_readiness"] == "DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS" + assert row["upstream_gate_blockers"] == [ + "D0_PRICE_BASIS_NOT_VERIFIED", + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED", + ] + assert "model_build_or_candidate_promotion" in row["dataset_blocked_uses"] + +def test_daily_ohlcv_scenario_lab_generates_research_only_assumptions(): + client = flask_app.test_client() + + response = client.get('/api/daily-ohlcv/scenarios') + assert response.status_code == 200 + payload = response.get_json() + + assert payload['mode'] == 'daily_ohlcv_scenario_lab' + assert payload['platform_stage'] == 'SCENARIO_GENERATOR_MVP' + assert payload['read_only'] is True + assert payload['scenario_generation_available'] is True + assert payload['model_run_generation_available'] is False + assert payload['status'] == 'RESEARCH_ONLY' + assert payload['assumption_dimensions']['cost_bps'] == [0, 23, 46] + assert payload['assumption_dimensions']['purge_days_min'] == 5 + assert payload['assumption_dimensions']['embargo_days_min'] == 5 + assert payload['current_evidence']['d5_status'] == 'NO-GO' + assert 'D5_WALK_FORWARD_NOT_PASS' in payload['current_evidence']['effective_gate_blockers'] + assert payload['model_input_contract']['required_outputs'] == [ + 'scenario_manifest.json', + 'candidate_generation_config.json', + 'fresh_oos_walk_forward_manifest.json', + 'gate_verdict.json', + ] + rows = payload['scenario_rows'] + assert payload['scenario_count'] == len(rows) >= 7 + by_id = {row['scenario_id']: row for row in rows} + assert by_id['cost_23bp_current_evidence']['cost_bps'] == 23 + assert by_id['cost_23bp_current_evidence']['status'] == 'NO-GO' + assert by_id['cost_23bp_current_evidence']['model_build_allowed'] is False + assert by_id['cost_23bp_data_repaired_hypothesis']['status'] == 'HYPOTHESIS_ONLY' + assert 'D0_PRICE_BASIS_NOT_VERIFIED' not in by_id['cost_23bp_data_repaired_hypothesis']['blocking_reasons'] + assert by_id['model_generated_candidate_contract']['readiness_status'] == 'MODEL_SCENARIO_GENERATION_CONTRACT_ONLY' + assert 'MODEL_CANDIDATE_NOT_GENERATED' in by_id['model_generated_candidate_contract']['blocking_reasons'] + assert all(row['paper_forward_allowed'] is False for row in rows) + assert all(row['live_broker_order_allowed'] is False for row in rows) + assert 'not a profit/live/broker/order' in payload['guardrail'] + + +def test_daily_ohlcv_scenario_run_ledger_api_lists_research_only_batches(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + scenario_root = tmp_path / "scenarios" + batch_root = tmp_path / "batches" + scenario_dir = scenario_root / "batch_unit__seed7" + batch_dir = batch_root / "batch_unit" + scenario_dir.mkdir(parents=True) + batch_dir.mkdir(parents=True) + (scenario_dir / "scenario_manifest.json").write_text( + json.dumps( + { + "run_id": "batch_unit__seed7", + "generated_at": "2026-06-15T00:00:00Z", + "status": "NO-GO", + "readiness_status": "D5_NO_GO_RESEARCH_ONLY_GATE", + "guardrail": "Research-only scenario/model experiment; no profit guarantee, no live/broker/orders.", + "config": {"n_folds": 5, "purge_days": 5, "embargo_days": 5}, + "artifact_paths": {"scenario_manifest": "scenario_manifest.json"}, + "gate_verdict_summary": { + "selected_strategy": "equal_weight_topk_momentum", + "n_folds": 5, + "purge_days": 5, + "embargo_days": 5, + "cost_sensitivity_bp": [0, 23, 46], + "reasons": ["RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM"], + }, + } + ), + encoding="utf-8", + ) + (batch_dir / "scenario_batch_manifest.json").write_text( + json.dumps( + { + "batch_id": "batch_unit", + "generated_at": "2026-06-15T00:01:00Z", + "mode": "daily_ohlcv_model_scenario_batch", + "platform_stage": "SCENARIO_BATCH_RUNNER_MVP", + "status": "COMPLETED_RESEARCH_ONLY", + "scenario_count": 1, + "completed_count": 1, + "failed_count": 0, + "gate_status_counts": {"NO-GO": 1}, + "artifact_paths": {"scenario_batch_manifest": "scenario_batch_manifest.json"}, + "comparison_rows": [ + { + "scenario_id": "seed7", + "run_id": "batch_unit__seed7", + "status": "NO-GO", + "cost_sensitivity_bp": [0, 23, 46], + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + ], + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(daily_dashboard, "DEFAULT_SCENARIO_ROOT", scenario_root) + monkeypatch.setattr(daily_dashboard, "DEFAULT_SCENARIO_BATCH_ROOT", batch_root) + + response = flask_app.test_client().get("/api/daily-ohlcv/scenario-runs?limit=5") + assert response.status_code == 200 + payload = response.get_json() + + assert payload["mode"] == "daily_ohlcv_scenario_run_ledger" + assert payload["platform_stage"] == "SCENARIO_BATCH_RUNNER_MVP" + assert payload["read_only"] is True + assert payload["dashboard_mutation_available"] is False + assert payload["cli_model_run_generation_available"] is True + assert payload["scenario_run_count"] == 1 + assert payload["batch_count"] == 1 + assert payload["runs"][0]["run_id"] == "batch_unit__seed7" + assert payload["runs"][0]["cost_sensitivity_bp"] == [0, 23, 46] + assert payload["runs"][0]["model_build_allowed"] is False + assert payload["batches"][0]["batch_id"] == "batch_unit" + assert payload["batches"][0]["gate_status_counts"] == {"NO-GO": 1} + assert payload["batches"][0]["comparison_rows"][0]["paper_forward_allowed"] is False + assert "stom_rl.daily_scenario_batch" in payload["quick_start_commands"][0] + assert "no profit/live/broker/order" in payload["guardrail"] + + +def test_daily_ohlcv_rl_env_guide_explains_research_only_environment(): + response = flask_app.test_client().get("/api/daily-ohlcv/rl-env-guide") + assert response.status_code == 200 + payload = response.get_json() + + assert payload["mode"] == "daily_ohlcv_rl_environment_guide" + assert payload["platform_stage"] == "RL_ENV_VISUAL_GUIDE_MVP" + assert payload["read_only"] is True + assert payload["schema_version"] == "daily_rl_env_guide.v2" + assert payload["source_run_id"] + assert payload["policy_type"] == "tabular_q" + assert payload["model_build_allowed"] is False + assert payload["paper_forward_allowed"] is False + assert payload["live_broker_order_allowed"] is False + assert "BLOCKED_MODEL_BUILD_LOCKED" in payload["blockers"] + assert payload["environment_built"] is True + assert payload["maturity"] == "RESEARCH_ONLY_ENV_BUILT_NOT_PROFIT_READY" + assert payload["state_contract"]["fields"] == ["position_count", "top_score_bucket"] + assert payload["action_space"] == {"0": "hold", "1": "buy", "2": "add", "3": "sell", "4": "reduce"} + assert "future_return_1d" in payload["what_rl_means_here"]["reward"] + assert payload["cost_round_trip_bp"] == 23 + assert payload["observation_manifest_validation"]["status"] == "PASS" + assert payload["current_artifact_evidence"]["d4_observation_manifest_validation_status"] == "PASS" + performance = payload["learning_performance"] + assert performance["status"] == "RESEARCH_ONLY_PERFORMANCE_DIAGNOSTIC" + assert performance["display_capital_krw"] == 10_000_000 + assert performance["policy"]["label"] == "D4 RL 정책" + assert "total_return_pct" in performance["policy"] + assert "simulated_profit_krw" in performance["policy"] + assert performance["best_d3_baseline"]["split"] == "best_d3_baseline" + assert performance["delta_vs_best_d3"]["label"] == "D4 RL - best D3 baseline" + assert "수익금" in {item["metric"] for item in performance["metric_definitions"]} + assert "no profit guarantee" in performance["guardrail"] + replay = payload["active_replay"] + assert replay["schema_version"] == "daily_rl_active_replay.v1" + assert replay["policy_type"] == "tabular_q" + assert replay["policy_network_available"] is False + assert replay["policy_network_status"] == "MISSING_POLICY_ARTIFACT" + assert replay["frames"][0]["status"] == "LOADED_GENERATED_ARTIFACT" + assert replay["frames"][0]["state"]["future_label_exposed"] == "False" + assert "fake neural/probability fallback" in replay["guardrail"] + catalog = payload["research_process_catalog"] + assert catalog["schema_version"] == "daily_rl_research_process_catalog.v1" + lane_ids = {lane["id"] for lane in catalog["lanes"]} + assert {"D3_BASELINE_FREEZE", "D4_RL_RISK_OVERLAY", "REWARD_ABLATION_LAB", "REGIME_DIAGNOSTICS", "SCENARIO_AUTOMATION"} <= lane_ids + d4_lane = next(lane for lane in catalog["lanes"] if lane["id"] == "D4_RL_RISK_OVERLAY") + assert "tabular Q" in d4_lane["current_limitations"][0] + assert d4_lane["ai_guidance_format"]["baseline"] == catalog["headline"]["best_d3_strategy"] + assert "D5_NO_GO" in d4_lane["ai_guidance_format"]["stop_conditions"] + generator = payload["scenario_generator"] + assert generator["schema_version"] == "daily_rl_scenario_generator.v1" + assert generator["read_only"] is True + assert generator["execution_allowed"] is False + template_ids = {template["template_id"] for template in generator["templates"]} + assert {"D3_D4_SIGNAL_QUALITY_AUDIT", "PAST_ONLY_MARKET_REGIME_AUDIT", "D4_RL_OVERLAY_ABLATION"} <= template_ids + signal_template = next(template for template in generator["templates"] if template["template_id"] == "D3_D4_SIGNAL_QUALITY_AUDIT") + assert signal_template["plan_json_draft"]["draft_only"] is True + assert signal_template["plan_json_draft"]["default_cost_bp"] == 23 + assert "no_live_broker_orders" in signal_template["plan_json_draft"]["guardrails"] + workflow_catalog = payload["research_workflow_catalog"] + assert workflow_catalog["schema_version"] == "daily_ohlcv_research_workflow_catalog.v1" + assert workflow_catalog["read_only"] is True + assert workflow_catalog["execution_allowed_from_browser"] is False + assert workflow_catalog["job_intent_mode"] == "APPROVAL_GATED_INTENT_RECORD_ONLY" + assert workflow_catalog["workflow_count"] == 6 + workflow_ids = {workflow["workflow_id"] for workflow in workflow_catalog["workflows"]} + assert {"PAST_ONLY_MARKET_REGIME_AUDIT", "HYPOTHESIS_REJECTION_AUDIT", "D4_RL_OVERLAY_ABLATION"} <= workflow_ids + rejection_workflow = next(workflow for workflow in workflow_catalog["workflows"] if workflow["workflow_id"] == "HYPOTHESIS_REJECTION_AUDIT") + assert rejection_workflow["execution_allowed_from_browser"] is False + assert "false_negative_candidates.csv" in rejection_workflow["artifact_dependencies"] + assert "command" in workflow_catalog["forbidden_fields"] + assert "paper_forward" in workflow_catalog["forbidden_fields"] + intent_ledger = payload["research_job_intent_ledger"] + assert intent_ledger["schema_version"] == "daily_ohlcv_research_job_intent_ledger.v1" + assert intent_ledger["execution_allowed_from_browser"] is False + assert intent_ledger["model_build_allowed"] is False + rejection_analytics = payload["rejection_analytics"] + assert rejection_analytics["schema_version"] == "daily_ohlcv_rejection_analytics.v1" + assert rejection_analytics["status"] == "COMPLETED_RESEARCH_ONLY" + assert rejection_analytics["promotion_allowed"] is False + assert rejection_analytics["summary"]["false_negative_candidate_count"] >= 1 + assert rejection_analytics["false_negative_candidates"][0]["review_status"] == "REVIEW_ONLY" + assert rejection_analytics["false_negative_candidates"][0]["promotion_allowed"] is False + assert "no_no_go_reversal" in rejection_analytics["audit_manifest"]["guardrails"] + completion_report = payload["dashboard_first_completion_report"] + assert completion_report["schema_version"] == "daily_ohlcv_dashboard_first_completion_report.v1" + assert completion_report["status"] == "NON_LIVE_RESEARCH_PLATFORM_COMPLETE" + assert completion_report["non_live_goal_completion_pct"] == 100 + assert completion_report["live_trading_readiness_pct"] == 0 + assert completion_report["model_build_readiness_pct"] == 0 + assert completion_report["paper_forward_readiness_pct"] == 0 + assert completion_report["execution_allowed_from_browser"] is False + assert completion_report["model_build_allowed"] is False + assert completion_report["paper_forward_allowed"] is False + assert completion_report["live_broker_order_allowed"] is False + completion_surfaces = {surface["id"] for surface in completion_report["completed_surfaces"]} + assert {"workflow_center", "workflow_inspector", "intent_ledger", "rejection_analytics", "docs_governance"} <= completion_surfaces + + + signal_summary = payload["signal_quality_audit_summary"] + assert signal_summary["schema_version"] == "daily_rl_signal_quality_summary.v1" + assert signal_summary["run_id"] == "signal_quality_audit_2026_06_18_001" + assert signal_summary["promotion_status"] == "NO-GO_RESEARCH_ONLY" + assert signal_summary["row_counts"]["risk_proxy_metrics"] == 219 + assert signal_summary["batch_manifest"]["gate_status_counts"] == {"WATCH": 5} + assert signal_summary["batch_manifest"]["failed_count"] == 0 + assert "future_return_1d is evaluation_label_only" in signal_summary["no_future_label_policy"] + + regime_readiness = payload["market_regime_audit_readiness"] + assert regime_readiness["schema_version"] == "daily_rl_market_regime_readiness.v1" + assert regime_readiness["maturity_score_pct"] == 72 + assert "D0_PRICE_BASIS" in regime_readiness["blocked_gates"] + assert regime_readiness["ai_guidance_format"]["next_research_lane"] == "past_only_market_regime_data_quality_audit" + + improvement_queue = payload["improvement_queue"] + assert improvement_queue["schema_version"] == "daily_rl_ai_improvement_queue.v1" + assert len(improvement_queue["items"]) == 5 + assert improvement_queue["items"][0]["blocker_status"] == "BLOCKED_D0_D1_DATA_GOVERNANCE" + assert "profit_claim" in improvement_queue["ai_guidance_format"]["blocked_actions"] + + comparison = payload["scenario_comparison"] + assert comparison["schema_version"] == "daily_rl_scenario_comparison.v1" + assert comparison["scenario_count"] == 5 + assert comparison["completed_count"] == 5 + assert comparison["failed_count"] == 0 + assert len(comparison["cards"]) >= 5 + + maturity = payload["page_maturity_report"] + assert maturity["schema_version"] == "daily_rl_page_maturity_report.v1" + assert maturity["implementation_completion_pct"] == 100 + assert maturity["page_maturity_pct"] == 88 + assert maturity["scenario_platform_maturity_pct"] == 86 + assert maturity["live_trading_readiness_pct"] == 0 + assert len(maturity["priority_completion"]) == 5 + assert replay["frames"][0]["join_key"]["date"] == replay["frames"][0]["date"] + checks = {row["check"]: row for row in payload["well_built_checks"]} + assert checks["state_has_no_future_label"]["status"] == "PASS" + assert checks["action_mask_available"]["status"] == "PASS" + assert checks["d5_consumes_d4_state_contract"]["status"] in {"PASS", "WATCH"} + assert "수익성 주장" in payload["not_good_enough_for"] + assert "no profit guarantee" in payload["guardrail"] + assert "no live/broker/orders" in payload["guardrail"] + + + +def test_daily_ohlcv_research_workflow_catalog_and_detail_are_read_only(): + client = flask_app.test_client() + + catalog_response = client.get("/api/daily-ohlcv/research-workflows") + assert catalog_response.status_code == 200 + catalog = catalog_response.get_json() + assert catalog["schema_version"] == "daily_ohlcv_research_workflow_catalog.v1" + assert catalog["read_only"] is True + assert catalog["execution_allowed_from_browser"] is False + assert catalog["job_intent_mode"] == "APPROVAL_GATED_INTENT_RECORD_ONLY" + assert catalog["workflow_count"] == 6 + assert "command" in catalog["forbidden_fields"] + assert "broker" in catalog["forbidden_fields"] + assert "paper_forward" in catalog["forbidden_fields"] + assert "HYPOTHESIS_REJECTION_AUDIT" in catalog["allowed_workflow_ids"] + + detail_response = client.get("/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT") + assert detail_response.status_code == 200 + detail = detail_response.get_json() + assert detail["schema_version"] == "daily_ohlcv_research_workflow_detail.v1" + assert detail["read_only"] is True + assert detail["execution_allowed_from_browser"] is False + assert detail["workflow"]["live_broker_order_allowed"] is False + assert detail["config_preview_contract"]["status"] == "INTENT_CREATION_AVAILABLE_G003" + assert "false_negative_candidates.csv" in detail["workflow"]["artifact_dependencies"] + + unknown = client.get("/api/daily-ohlcv/research-workflows/UNKNOWN_WORKFLOW") + assert unknown.status_code == 404 + assert unknown.get_json()["status"] == "UNKNOWN_WORKFLOW_ID" + + +def test_daily_ohlcv_research_job_intents_are_approval_gated_and_idempotent(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + intent_root = tmp_path / "research_intents" + monkeypatch.setattr(daily_dashboard, "DEFAULT_RESEARCH_INTENT_ROOT", intent_root) + approval_ref = ".gjc/plans/ralplan/2026-06-11-0158-38ea/pending-approval.md" + approval_path = REPO_ROOT / approval_ref + approval_sha = hashlib.sha256(approval_path.read_bytes()).hexdigest() + client = flask_app.test_client() + + def payload(**overrides): + base = { + "workflow_id": "HYPOTHESIS_REJECTION_AUDIT", + "approval_ref": approval_ref, + "approval_ref_sha256": approval_sha, + "approval_status": "APPROVED_FOR_RESEARCH_INTENT", + "idempotency_key": "unit-intent-001", + "requested_by": "pytest-local-dashboard", + "config": { + "workflow_id": "HYPOTHESIS_REJECTION_AUDIT", + "hypothesis_id": "reject-audit-unit", + "default_cost_bp": 23, + "cost_sensitivity_bp": [0, 23, 46], + "controls": ["no_trade", "shuffle_control", "frozen_d3_baseline"], + "artifact_dependencies": ["gate_funnel_metrics.csv", "audit_manifest.json"], + }, + } + base.update(overrides) + return base + + created = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json=payload(), + ) + assert created.status_code == 201 + intent = created.get_json() + assert intent["schema_version"] == "daily_ohlcv_research_job_intent.v1" + assert intent["status"] == "INTENT_RECORDED" + assert intent["idempotency_result"] == "created" + assert intent["model_build_allowed"] is False + assert intent["paper_forward_allowed"] is False + assert intent["live_broker_order_allowed"] is False + assert intent["authz_decision"] == "ALLOW_RESEARCH_INTENT_RECORD_ONLY" + assert "no_arbitrary_shell" in intent["guardrails"] + assert (intent_root / intent["intent_id"] / "intent.json").is_file() + assert sorted(path.name for path in (intent_root / intent["intent_id"]).iterdir()) == ["intent.json"] + + duplicate = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json=payload(), + ) + assert duplicate.status_code == 200 + assert duplicate.get_json()["idempotency_result"] == "existing_intent_returned" + + conflict_payload = payload(config={**payload()["config"], "reviewer_notes": "different plan"}) + conflict = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json=conflict_payload, + ) + assert conflict.status_code == 409 + assert conflict.get_json()["status"] == "IDEMPOTENCY_CONFLICT" + + ledger = client.get("/api/daily-ohlcv/research-jobs?limit=5") + assert ledger.status_code == 200 + ledger_payload = ledger.get_json() + assert ledger_payload["schema_version"] == "daily_ohlcv_research_job_intent_ledger.v1" + assert ledger_payload["count"] == 1 + assert ledger_payload["execution_allowed_from_browser"] is False + assert ledger_payload["intents"][0]["intent_id"] == intent["intent_id"] + + detail = client.get(f"/api/daily-ohlcv/research-jobs/{intent['intent_id']}") + assert detail.status_code == 200 + assert detail.get_json()["status"] == "INTENT_RECORDED" + + +def test_daily_ohlcv_research_job_intents_fail_closed_for_unsafe_payloads(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + monkeypatch.setattr(daily_dashboard, "DEFAULT_RESEARCH_INTENT_ROOT", tmp_path / "research_intents") + approval_ref = ".gjc/plans/ralplan/2026-06-11-0158-38ea/pending-approval.md" + approval_path = REPO_ROOT / approval_ref + approval_sha = hashlib.sha256(approval_path.read_bytes()).hexdigest() + client = flask_app.test_client() + + base_payload = { + "workflow_id": "HYPOTHESIS_REJECTION_AUDIT", + "approval_ref": approval_ref, + "approval_ref_sha256": approval_sha, + "approval_status": "APPROVED_FOR_RESEARCH_INTENT", + "idempotency_key": "unsafe-unit", + "config": { + "workflow_id": "HYPOTHESIS_REJECTION_AUDIT", + "default_cost_bp": 23, + "cost_sensitivity_bp": [0, 23, 46], + }, + } + + forbidden = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json={**base_payload, "command": "python train.py"}, + ) + assert forbidden.status_code == 400 + assert "FORBIDDEN_FIELDS:command" in forbidden.get_json()["errors"] + + forbidden_variants = { + "shell": {"shell": "py -3.11 -m pytest"}, + "argv": {"argv": ["py", "-3.11"]}, + "env": {"env": {"TOKEN": "x"}}, + "cwd": {"cwd": "webui"}, + "broker": {"broker": {"name": "demo"}}, + "order": {"order": {"code": "000250", "side": "buy"}}, + "live": {"live": True}, + "paper_forward": {"paper_forward": {"unlock": True}}, + "model_build": {"model_build": {"unlock": True}}, + "model_build_allowed": {"model_build_allowed": True}, + "paper_forward_allowed": {"paper_forward_allowed": True}, + "live_broker_order_allowed": {"live_broker_order_allowed": True}, + } + for key, extra in forbidden_variants.items(): + response = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json={**base_payload, **extra, "idempotency_key": f"unsafe-unit-{key}"}, + ) + assert response.status_code == 400 + assert any(key in error for error in response.get_json()["errors"]) + + pending_status = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json={**base_payload, "approval_status": "PENDING_APPROVAL_ACCEPTED", "idempotency_key": "unsafe-pending"}, + ) + assert pending_status.status_code == 400 + assert "APPROVAL_STATUS_NOT_APPROVED" in pending_status.get_json()["errors"] + stale = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json={**base_payload, "approval_ref_sha256": "0" * 64}, + ) + assert stale.status_code == 400 + assert "APPROVAL_REF_SHA_MISMATCH" in stale.get_json()["errors"] + + traversal = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json={**base_payload, "approval_ref": "../secrets.md"}, + ) + assert traversal.status_code == 400 + assert any(error.startswith("UNSAFE_PATH") for error in traversal.get_json()["errors"]) + + unknown = client.post( + "/api/daily-ohlcv/research-workflows/UNKNOWN_WORKFLOW/job-intents", + json={**base_payload, "workflow_id": "UNKNOWN_WORKFLOW", "config": {"workflow_id": "UNKNOWN_WORKFLOW"}}, + ) + assert unknown.status_code == 400 + assert "UNKNOWN_WORKFLOW_ID" in unknown.get_json()["errors"] + + artifact_traversal = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json={**base_payload, "config": {**base_payload["config"], "artifact_dependencies": ["../escape.csv"]}}, + ) + assert artifact_traversal.status_code == 400 + assert any(error.startswith("UNSAFE_ARTIFACT_DEPENDENCY") for error in artifact_traversal.get_json()["errors"]) + + +def test_daily_ohlcv_research_job_intents_conflict_when_approval_hash_changes(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + intent_root = tmp_path / "research_intents" + approval_path = tmp_path / "approval.md" + approval_path.write_text("approved revision one", encoding="utf-8") + monkeypatch.setattr(daily_dashboard, "DEFAULT_RESEARCH_INTENT_ROOT", intent_root) + monkeypatch.setattr(daily_dashboard, "_safe_relative_repo_path", lambda value: approval_path) + monkeypatch.setattr(daily_dashboard, "_approval_path_allowed", lambda path: True) + client = flask_app.test_client() + + first_sha = hashlib.sha256(approval_path.read_bytes()).hexdigest() + base_payload = { + "workflow_id": "HYPOTHESIS_REJECTION_AUDIT", + "approval_ref": "approval.md", + "approval_ref_sha256": first_sha, + "approval_status": "APPROVED_FOR_RESEARCH_INTENT", + "idempotency_key": "approval-hash-conflict", + "config": { + "workflow_id": "HYPOTHESIS_REJECTION_AUDIT", + "default_cost_bp": 23, + "cost_sensitivity_bp": [0, 23, 46], + }, + } + + first = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json=base_payload, + ) + assert first.status_code == 201 + + approval_path.write_text("approved revision two", encoding="utf-8") + second_sha = hashlib.sha256(approval_path.read_bytes()).hexdigest() + second = client.post( + "/api/daily-ohlcv/research-workflows/HYPOTHESIS_REJECTION_AUDIT/job-intents", + json={**base_payload, "approval_ref_sha256": second_sha}, + ) + assert second.status_code == 409 + assert second.get_json()["status"] == "IDEMPOTENCY_CONFLICT" + + +def test_daily_ohlcv_rejection_analytics_api_is_research_only(): + client = flask_app.test_client() + + response = client.get("/api/daily-ohlcv/rejection-analytics?limit=10") + assert response.status_code == 200 + payload = response.get_json() + + assert payload["schema_version"] == "daily_ohlcv_rejection_analytics.v1" + assert payload["status"] == "COMPLETED_RESEARCH_ONLY" + assert payload["run_id"] == "hypothesis_rejection_audit_2026_06_18_001" + assert payload["read_only"] is True + assert payload["promotion_allowed"] is False + assert payload["model_build_allowed"] is False + assert payload["paper_forward_allowed"] is False + assert payload["live_broker_order_allowed"] is False + assert payload["row_counts"] == { + "gate_funnel_metrics": 3, + "rejection_reason_taxonomy": 3, + "calibration_metrics": 3, + "threshold_sensitivity": 4, + "false_negative_candidates": 1, + } + assert payload["summary"]["rejected_total"] == 5 + assert payload["summary"]["early_dropout_total"] == 3 + assert payload["denominator_policy"].startswith("denominator_count is preregistered") + assert "decision_time_utc" in payload["timing_policy"] + assert "separately timestamped follow-up review evidence manifest" in payload["independent_evidence_policy"] + assert len(payload["artifact_hashes"]["gate_funnel_metrics.csv"]) == 64 + assert payload["false_negative_candidates"][0]["review_status"] == "REVIEW_ONLY" + assert payload["false_negative_candidates"][0]["promotion_allowed"] is False + assert payload["false_negative_candidates"][0]["requires_new_preregistration"] is True + assert "cannot reverse NO-GO" in payload["guardrail"] + assert len(payload["false_negative_candidates"][0]["later_independent_evidence_sha256"]) == 64 + limited = client.get("/api/daily-ohlcv/rejection-analytics?limit=1").get_json() + assert limited["status"] == "COMPLETED_RESEARCH_ONLY" + assert limited["row_counts"]["gate_funnel_metrics"] == 3 + assert len(limited["gate_funnel_metrics"]) == 1 + + invalid = client.get("/api/daily-ohlcv/rejection-analytics?run=../escape") + assert invalid.status_code == 200 + assert invalid.get_json()["status"] == "INVALID_RUN_ID" + + +def test_daily_ohlcv_rejection_analytics_invalid_artifacts_fail_closed(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / "rejection_audit" + run_dir = root / "optimistic" + run_dir.mkdir(parents=True) + (run_dir / "audit_manifest.json").write_text( + json.dumps( + { + "schema_version": "daily_ohlcv_rejection_audit_manifest.v1", + "status": "COMPLETED_RESEARCH_ONLY", + "audit_run_id": "optimistic", + "artifact_hashes": {}, + "row_counts": {}, + "guardrails": ["research_only"], + "promotion_allowed": True, + "model_build_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(daily_dashboard, "DEFAULT_REJECTION_AUDIT_ROOT", root) + + payload = daily_dashboard.load_rejection_analytics(run="optimistic") + + assert payload["status"] == "INVALID_REJECTION_AUDIT_ARTIFACTS" + assert payload["promotion_allowed"] is False + assert payload["model_build_allowed"] is False + assert "MISSING_REQUIRED_ARTIFACT:gate_funnel_metrics.csv" in payload["errors"] + assert "PROMOTION_ALLOWED_NOT_FALSE" in payload["errors"] + + +def test_daily_ohlcv_rejection_analytics_manifest_status_required(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / "rejection_audit" + run_dir = root / "missing_status" + run_dir.mkdir(parents=True) + csv_specs = { + "gate_funnel_metrics.csv": ["run_id", "entered_count", "rejected_count", "early_dropout_count"], + "rejection_reason_taxonomy.csv": ["reason_id"], + "calibration_metrics.csv": ["run_id"], + "threshold_sensitivity.csv": ["run_id"], + "false_negative_candidates.csv": ["candidate_id"], + } + for name, fieldnames in csv_specs.items(): + with (run_dir / name).open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + artifact_hashes = { + name: hashlib.sha256((run_dir / name).read_bytes()).hexdigest() + for name in csv_specs + } + (run_dir / "audit_manifest.json").write_text( + json.dumps( + { + "schema_version": "daily_ohlcv_rejection_audit_manifest.v1", + "audit_run_id": "missing_status", + "artifact_hashes": artifact_hashes, + "row_counts": { + "gate_funnel_metrics": 0, + "rejection_reason_taxonomy": 0, + "calibration_metrics": 0, + "threshold_sensitivity": 0, + "false_negative_candidates": 0, + }, + "guardrails": [ + "research_only", + "review_only_false_negatives", + "no_no_go_reversal", + "no_model_build", + "no_paper_forward", + "no_live_broker_orders", + ], + "promotion_allowed": False, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(daily_dashboard, "DEFAULT_REJECTION_AUDIT_ROOT", root) + + payload = daily_dashboard.load_rejection_analytics(run="missing_status") + + assert payload["status"] == "INVALID_REJECTION_AUDIT_ARTIFACTS" + assert "INVALID_MANIFEST_STATUS" in payload["errors"] + + +def test_daily_ohlcv_rejection_analytics_false_negative_rows_fail_closed(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / "rejection_audit" + run_dir = root / "optimistic_false_negative" + run_dir.mkdir(parents=True) + + def write_csv_file(name, fieldnames, rows): + with (run_dir / name).open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + + write_csv_file( + "gate_funnel_metrics.csv", + ["run_id", "entered_count", "rejected_count", "early_dropout_count"], + [{"run_id": "optimistic_false_negative", "entered_count": 1, "rejected_count": 1, "early_dropout_count": 0}], + ) + write_csv_file("rejection_reason_taxonomy.csv", ["reason_id"], [{"reason_id": "R001"}]) + write_csv_file("calibration_metrics.csv", ["run_id"], [{"run_id": "optimistic_false_negative"}]) + write_csv_file("threshold_sensitivity.csv", ["run_id"], [{"run_id": "optimistic_false_negative"}]) + follow_up = { + "schema_version": "daily_ohlcv_false_negative_followup_evidence.v1", + "status": "FOLLOW_UP_PREREGISTRATION_REQUIRED_REVIEW_ONLY", + "candidate_ids": ["FN_BAD"], + "promotion_allowed": False, + "requires_new_preregistration": True, + "guardrails": [ + "review_only_false_negatives", + "no_no_go_reversal", + "no_model_build", + "no_paper_forward", + "no_live_broker_orders", + ], + } + follow_up_path = run_dir / "follow_up_review_evidence_manifest.json" + follow_up_path.write_text(json.dumps(follow_up), encoding="utf-8") + follow_up_sha = hashlib.sha256(follow_up_path.read_bytes()).hexdigest() + write_csv_file( + "false_negative_candidates.csv", + [ + "candidate_id", + "review_status", + "promotion_allowed", + "requires_new_preregistration", + "later_independent_evidence_manifest_path", + "later_independent_evidence_sha256", + ], + [ + { + "candidate_id": "FN_BAD", + "review_status": "REVIEW_ONLY", + "promotion_allowed": "true", + "requires_new_preregistration": "true", + "later_independent_evidence_manifest_path": "follow_up_review_evidence_manifest.json", + "later_independent_evidence_sha256": follow_up_sha, + } + ], + ) + artifact_hashes = { + name: hashlib.sha256((run_dir / name).read_bytes()).hexdigest() + for name in [ + "gate_funnel_metrics.csv", + "rejection_reason_taxonomy.csv", + "calibration_metrics.csv", + "threshold_sensitivity.csv", + "false_negative_candidates.csv", + "follow_up_review_evidence_manifest.json", + ] + } + (run_dir / "audit_manifest.json").write_text( + json.dumps( + { + "schema_version": "daily_ohlcv_rejection_audit_manifest.v1", + "status": "COMPLETED_RESEARCH_ONLY", + "audit_run_id": "optimistic_false_negative", + "artifact_hashes": artifact_hashes, + "row_counts": { + "gate_funnel_metrics": 1, + "rejection_reason_taxonomy": 1, + "calibration_metrics": 1, + "threshold_sensitivity": 1, + "false_negative_candidates": 1, + }, + "guardrails": [ + "research_only", + "review_only_false_negatives", + "no_no_go_reversal", + "no_model_build", + "no_paper_forward", + "no_live_broker_orders", + ], + "promotion_allowed": False, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(daily_dashboard, "DEFAULT_REJECTION_AUDIT_ROOT", root) + monkeypatch.setattr(daily_dashboard, "_safe_relative_repo_path", lambda value: follow_up_path) + + payload = daily_dashboard.load_rejection_analytics(run="optimistic_false_negative") + + assert payload["status"] == "INVALID_REJECTION_AUDIT_ARTIFACTS" + assert "FALSE_NEGATIVE_PROMOTION_ALLOWED" in payload["errors"] +def test_daily_rl_market_regime_readiness_fails_closed_without_signal_artifacts(): + import webui.daily_ohlcv_dashboard as daily_dashboard + + missing_signal = { + "status": "MISSING_SIGNAL_QUALITY_AUDIT", + "run_id": "MISSING_SIGNAL_QUALITY_AUDIT", + "manifest_path": None, + "promotion_status": "NO-GO_RESEARCH_ONLY", + "batch_manifest": {"scenario_count": 0, "completed_count": 0, "failed_count": 0}, + "scenario_cards": [], + } + + readiness = daily_dashboard._build_rl_guide_market_regime_readiness(missing_signal) + assert readiness["status"] == "BLOCKED_MISSING_SIGNAL_QUALITY_AUDIT" + assert readiness["source_signal_quality_run_id"] is None + checks = {row["check"]: row for row in readiness["readiness_checks"]} + assert checks["signal_quality_artifacts_available"]["status"] == "BLOCKED" + assert checks["signal_quality_artifacts_available"]["completion_pct"] == 0 + assert checks["past_only_proxy_contract"]["status"] == "BLOCKED" + + generator = daily_dashboard._build_rl_guide_scenario_generator(missing_signal) + queue = daily_dashboard._build_rl_guide_improvement_queue(missing_signal) + comparison = daily_dashboard._build_rl_guide_scenario_comparison(missing_signal) + maturity = daily_dashboard._build_rl_guide_page_maturity_report( + scenario_generator=generator, + signal_summary=missing_signal, + market_regime_readiness=readiness, + improvement_queue=queue, + scenario_comparison=comparison, + ) + assert maturity["implementation_completion_pct"] < 100 + assert maturity["priority_completion"][1]["status"] == "MISSING_SIGNAL_QUALITY_ARTIFACT" + assert maturity["score_inputs"]["raw_research_readiness_pct"] < 74 + +def test_daily_rl_active_replay_fails_closed_on_mismatched_join_keys(): + import webui.daily_ohlcv_dashboard as daily_dashboard + + replay = daily_dashboard._build_rl_guide_active_replay( + { + "run_id": "unit_replay_mismatch", + "artifact_hashes": {"state_observations": "abc"}, + "baseline_comparison": {"policy_strategy": "pretend_policy_network"}, + "samples": { + "state_observations": [ + { + "split": "train", + "date": "20250102", + "observation_position_count": "0", + "observation_top_score_bucket": "1", + "future_label_exposed": "False", + } + ], + "reward_breakdown": [ + { + "split": "train", + "date": "20250103", + "action": "buy", + "reward": "0.1", + } + ], + }, + }, + {"schema_version": 1}, + ) + + assert replay["status"] == "MISSING_REPLAY_ARTIFACT" + assert replay["frames"][0]["status"] == "MISSING_REPLAY_JOIN_KEY" + assert replay["policy_type"] == "tabular_q" + assert replay["policy_network_status"] == "MISSING_POLICY_ARTIFACT" + + +def test_daily_rl_active_replay_never_promotes_non_tabular_policy_without_artifact(): + import webui.daily_ohlcv_dashboard as daily_dashboard + + replay = daily_dashboard._build_rl_guide_active_replay( + { + "run_id": "unit_non_tabular_policy", + "artifact_hashes": {"state_observations": "abc", "reward_breakdown": "def"}, + "baseline_comparison": {"policy_strategy": "policy_network_candidate"}, + "samples": { + "state_observations": [ + { + "split": "test", + "date": "20250102", + "observation_position_count": "1", + "observation_top_score_bucket": "1", + "future_label_exposed": "False", + } + ], + "reward_breakdown": [ + { + "split": "test", + "date": "20250102", + "action": "hold", + "executed_action": "hold", + "reward": "0.0", + } + ], + }, + }, + {"schema_version": 1}, + ) + + assert replay["status"] == "LOADED_GENERATED_ARTIFACT" + assert replay["policy_strategy"] == "policy_network_candidate" + assert replay["policy_type"] == "tabular_q" + assert replay["policy_network_available"] is False + assert replay["policy_network_status"] == "MISSING_POLICY_ARTIFACT" + + +def test_daily_rl_active_replay_does_not_attach_mismatched_nav_rows(): + import webui.daily_ohlcv_dashboard as daily_dashboard + + replay = daily_dashboard._build_rl_guide_active_replay( + { + "run_id": "unit_nav_mismatch", + "artifact_hashes": {"state_observations": "abc", "reward_breakdown": "def", "policy_nav": "ghi"}, + "baseline_comparison": {"policy_strategy": "tabular_q_constrained_daily_portfolio_rl"}, + "samples": { + "state_observations": [ + { + "split": "test", + "date": "20250102", + "observation_position_count": "1", + "observation_top_score_bucket": "1", + "future_label_exposed": "False", + } + ], + "reward_breakdown": [ + { + "split": "test", + "date": "20250102", + "action": "hold", + "executed_action": "hold", + "reward": "0.0", + } + ], + "policy_nav": [ + { + "split": "test", + "date": "20250103", + "policy_nav": "1.23", + } + ], + }, + }, + {"schema_version": 1}, + ) + + assert replay["status"] == "LOADED_GENERATED_ARTIFACT" + assert replay["frames"][0]["join_key"] == {"split": "test", "date": "20250102"} + assert replay["frames"][0]["nav"]["policy_nav"] is None + +def test_daily_ohlcv_model_result_apis_expose_d3_d5_guardrails(): + client = flask_app.test_client() + + prediction = client.get('/api/daily-ohlcv/prediction/latest?limit=2') + assert prediction.status_code == 200 + prediction_payload = prediction.get_json() + assert prediction_payload['status'] == 'WATCH' + assert prediction_payload['verdict']['go_summary_allowed'] is False + assert prediction_payload['price_basis'] == 'unknown' + assert 1 <= len(prediction_payload['baseline_metrics']) <= 2 + assert prediction_payload['baseline_delta_summary']['shuffle_control_strategy'] == 'shuffle_control' + assert prediction_payload['baseline_delta_summary']['model_build_allowed'] is False + assert prediction_payload['baseline_delta_summary']['go_summary_allowed'] is False + assert prediction_payload['baseline_delta_summary']['readiness_status'] == 'D3_WATCH_RESEARCH_ONLY' + assert prediction_payload['d3_gate_blockers'] == [ + 'D0_PRICE_BASIS_NOT_VERIFIED', + 'D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED', + 'D5_WALK_FORWARD_NOT_PASS', + 'D3_BASELINE_WATCH_RESEARCH_ONLY', + ] + assert 'model_build_or_candidate_promotion' in prediction_payload['d3_blocked_uses'] + assert {row['strategy'] for row in prediction_payload['baseline_metrics']} == {'no_trade_cash', 'shuffle_control'} + assert len(prediction_payload['samples']['predictions']) <= 2 + prediction_full = client.get('/api/daily-ohlcv/prediction/latest?limit=25').get_json() + families = {row['strategy_family'] for row in prediction_full['baseline_metrics']} + assert {'control', 'rule_baseline', 'supervised'} <= families + assert {'equal_weight_topk_momentum', 'vol_adjusted_momentum', 'supervised_direction_classifier'} <= { + row['strategy'] for row in prediction_full['baseline_metrics'] + } + assert all('delta_vs_shuffle_control_total_net_return' in row for row in prediction_full['baseline_metrics']) + assert prediction_full['baseline_delta_summary']['best_supervised_delta_vs_best_rule_baseline'] < 0 + + + portfolio = client.get('/api/daily-ohlcv/portfolio/latest?limit=2') + assert portfolio.status_code == 200 + portfolio_payload = portfolio.get_json() + assert portfolio_payload['status'] == 'RESEARCH_ONLY' + assert portfolio_payload['verdict']['implementation_unlocked'] is False + assert portfolio_payload['verdict']['go_summary_allowed'] is False + assert portfolio_payload['verdict']['model_build_allowed'] is False + assert portfolio_payload['verdict']['readiness_status'] == 'D4_RESEARCH_ONLY_DIAGNOSTICS' + assert portfolio_payload['model_build_allowed'] is False + assert portfolio_payload['paper_forward_allowed'] is False + assert portfolio_payload['live_broker_order_allowed'] is False + assert portfolio_payload['readiness_status'] == 'D4_RESEARCH_ONLY_DIAGNOSTICS' + assert len(portfolio_payload['prediction_manifest_sha']) == 64 + assert portfolio_payload['prediction_artifact_hashes']['prediction_manifest'] == portfolio_payload['prediction_manifest_sha'] + assert portfolio_payload['go_summary_allowed'] is False + assert portfolio_payload['prediction_artifact_hashes']['predictions'] + assert portfolio_payload['prediction_artifact_hashes']['baseline_metrics'] + assert portfolio_payload['prediction_artifact_hashes']['verdict'] + assert portfolio_payload['baseline_comparison']['delta_vs_best_d3_total_net_return'] < 0 + assert len(portfolio_payload['samples']['reward_breakdown']) <= 2 + assert len(portfolio_payload['samples']['invalid_actions']) <= 2 + assert portfolio_payload['telemetry']['status'] == 'READY_RESEARCH_ONLY' + assert 'learning_curve.csv' in portfolio_payload['telemetry']['canonical_artifacts'] + assert 'observation_manifest.json' in portfolio_payload['telemetry']['canonical_artifacts'] + assert 'state_observations.csv' in portfolio_payload['telemetry']['canonical_artifacts'] + assert portfolio_payload['observation_manifest']['gate'] == 'D4_OBSERVATION_STATE_MANIFEST' + assert portfolio_payload['observation_manifest']['reward_action_telemetry_sufficient_for_d4'] is False + assert len(portfolio_payload['samples']['learning_curve']) <= 2 + assert len(portfolio_payload['samples']['action_distribution']) <= 2 + assert len(portfolio_payload['samples']['turnover']) <= 2 + assert len(portfolio_payload['samples']['drawdown']) <= 2 + assert len(portfolio_payload['samples']['policy_baseline_comparison']) <= 2 + assert len(portfolio_payload['samples']['policy_nav']) <= 2 + assert len(portfolio_payload['samples']['state_observations']) <= 2 + assert portfolio_payload['policy_evaluation']['required_frozen_baselines'] == [ + 'no_trade_cash', + 'shuffle_control', + 'equal_weight_topk_momentum', + 'vol_adjusted_momentum', + 'supervised_linear_ranker', + 'supervised_direction_classifier', + ] + assert portfolio_payload['reward_component_summary']['by_split'] + assert 'policy_baseline_comparison.csv' in portfolio_payload['telemetry']['canonical_artifacts'] + assert 'policy_nav.csv' in portfolio_payload['telemetry']['canonical_artifacts'] + + gate = client.get('/api/daily-ohlcv/walk-forward/latest?limit=2') + assert gate.status_code == 200 + gate_payload = gate.get_json() + assert gate_payload['status'] == 'NO-GO' + assert gate_payload['verdict']['model_build_allowed'] is False + assert gate_payload['verdict']['go_summary_allowed'] is False + assert gate_payload['model_build_allowed'] is False + assert gate_payload['go_summary_allowed'] is False + assert gate_payload['paper_forward_allowed'] is False + assert gate_payload['live_broker_order_allowed'] is False + assert gate_payload['no_live_broker_order_readiness'] is True + assert gate_payload['readiness_status'] == 'D5_NO_GO_RESEARCH_ONLY_GATE' + assert gate_payload['verdict']['paper_forward_allowed'] is False + assert gate_payload['verdict']['live_broker_order_allowed'] is False + assert len(gate_payload['prediction_manifest_sha']) == 64 + assert gate_payload['prediction_artifact_hashes']['prediction_manifest'] == gate_payload['prediction_manifest_sha'] + assert len(gate_payload['portfolio_manifest_sha']) == 64 + assert gate_payload['portfolio_artifact_hashes']['rl_manifest'] == gate_payload['portfolio_manifest_sha'] + assert 'RL_POLICY_UNDERPERFORMS_D3_BASELINE' in gate_payload['verdict']['reasons'] + assert len(gate_payload['samples']['fold_metrics']) <= 2 + assert gate_payload['d4_state_contract']['status'] == 'PASS' + assert gate_payload['verdict']['d4_state_contract_artifacts_consumed'] is True + assert gate_payload['verdict']['d4_observation_manifest_gate'] == 'D4_OBSERVATION_STATE_MANIFEST' + + registry = client.get('/api/daily-ohlcv/registry/latest?limit=2') + assert registry.status_code == 200 + registry_payload = registry.get_json() + assert registry_payload['status'] == 'RESEARCH_ONLY_BLOCKED' + assert registry_payload['promotion_status'] == 'BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER' + assert registry_payload['model_build_allowed'] is False + assert registry_payload['paper_forward_allowed'] is False + assert registry_payload['live_broker_order_allowed'] is False + assert 'no live/broker/orders' in registry_payload['guardrail'] + assert len(registry_payload['config_hash']) == 64 + assert len(registry_payload['data_hash']) == 64 + assert len(registry_payload['code_hash']) == 64 + assert registry_payload['candidate_registry']['candidates'][0]['promotion_status'] == 'BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER' + assert registry_payload['candidate_registry']['candidates'][0]['model_build_allowed'] is False + assert registry_payload['candidate_registry']['candidates'][0]['paper_forward_allowed'] is False + assert registry_payload['candidate_registry']['candidates'][0]['live_broker_order_allowed'] is False + assert registry_payload['candidate_registry']['candidates'][0]['no_live_broker_order_readiness'] is True + assert 'D5_WALK_FORWARD_NOT_PASS' in registry_payload['effective_gate_blockers'] + assert registry_payload['samples']['drawdown'][0]['source'] == 'research_policy_nav_not_live_account' + assert registry_payload['read_only_dashboard_note'].startswith('GET-only D8/D9') + assert len(registry_payload['samples']['paper_selected']) <= 2 + assert registry_payload['samples']['paper_selected'][0]['selection_status'] == 'BLOCKED_BY_D5_NO_GO' + assert len(registry_payload['samples']['realized_returns']) <= 2 + assert len(registry_payload['samples']['drift']) <= 2 + assert len(registry_payload['samples']['drawdown']) <= 2 + assert len(registry_payload['samples']['decision_log']) <= 2 + + chart = client.get('/api/daily-ohlcv/charts/walk-forward').get_json() + prediction_chart = client.get('/api/daily-ohlcv/charts/prediction').get_json() + assert prediction_chart['baseline_delta_summary']['shuffle_control_strategy'] == 'shuffle_control' + assert any(row['strategy'] == 'shuffle_control' for row in prediction_chart['baseline_series']) + chart_families = {row['strategy_family'] for row in prediction_chart['baseline_series']} + assert {'control', 'rule_baseline', 'supervised'} <= chart_families + assert prediction_chart['baseline_delta_summary']['cost_round_trip_bp'] == 23 + assert prediction_chart['d3_gate_blockers'] == [ + 'D0_PRICE_BASIS_NOT_VERIFIED', + 'D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED', + 'D5_WALK_FORWARD_NOT_PASS', + 'D3_BASELINE_WATCH_RESEARCH_ONLY', + ] + assert prediction_chart['baseline_freeze_contract']['deterministic_shuffle_method'] == 'sha256(date:code)_ascending' + portfolio_chart = client.get('/api/daily-ohlcv/charts/portfolio').get_json() + assert portfolio_chart['training_status'] == 'TABULAR_Q_TELEMETRY_RECORDED' + assert portfolio_chart['model_build_allowed'] is False + assert portfolio_chart['readiness_status'] == 'D4_RESEARCH_ONLY_DIAGNOSTICS' + assert portfolio_chart['paper_forward_allowed'] is False + assert portfolio_chart['live_broker_order_allowed'] is False + assert portfolio_chart['learning_curve'] + assert portfolio_chart['action_distribution'] + assert portfolio_chart['reward_component_summary']['by_split'] + assert portfolio_chart['observation_manifest']['gate'] == 'D4_OBSERVATION_STATE_MANIFEST' + assert portfolio_chart['observation_manifest']['reward_action_telemetry_sufficient_for_d4'] is False + assert portfolio_chart['state_observations'] + assert portfolio_chart['invalid_actions'] + assert portfolio_chart['portfolio_trajectory'] + assert portfolio_chart['reward_stack'] + assert portfolio_chart['turnover_series'] + assert portfolio_chart['drawdown_series'] + assert 'invalid_action' in portfolio_chart['action_distribution'][0] + reward_row = portfolio_chart['reward_component_summary']['by_split'][0] + assert {'exposure_penalty', 'concentration_penalty', 'invalid_action_penalty', 'churn_penalty', 'drawdown_penalty'} <= set(reward_row) + assert 'turnover_cost' in portfolio_chart['turnover_series'][0] + assert 'current_drawdown' in portfolio_chart['drawdown_series'][0] + assert portfolio_chart['policy_baseline_comparison'] + assert portfolio_chart['policy_nav'] + assert portfolio_chart['policy_nav'] == portfolio_chart['portfolio_trajectory'] + assert portfolio_chart['policy_evaluation']['policy_baseline_comparison_rows'] >= 6 + assert len(portfolio_chart['prediction_manifest_sha']) == 64 + assert portfolio_chart['prediction_artifact_hashes']['predictions'] + assert portfolio_chart['artifact_hashes']['policy_nav'] + assert portfolio_chart['go_summary_allowed'] is False + assert {'baseline_strategy', 'policy_nav', 'baseline_nav', 'baseline_delta_total_net_return'} <= set(portfolio_chart['policy_baseline_comparison'][0]) + assert 'RESEARCH_ONLY diagnostics' in portfolio_chart['guardrail'] + assert chart['status'] == 'NO-GO' + assert chart['model_build_allowed'] is False + assert 'NO-GO/WATCH reasons' in chart['guardrail'] + assert chart['readiness_status'] == 'D5_NO_GO_RESEARCH_ONLY_GATE' + assert chart['go_summary_allowed'] is False + assert chart['paper_forward_allowed'] is False + assert chart['live_broker_order_allowed'] is False + assert chart['no_live_broker_order_readiness'] is True + assert len(chart['prediction_manifest_sha']) == 64 + assert chart['prediction_artifact_hashes']['predictions'] + assert len(chart['portfolio_manifest_sha']) == 64 + assert chart['portfolio_artifact_hashes']['state_observations'] + assert chart['artifact_hashes']['fold_metrics'] + assert chart['artifact_hashes']['gate_verdict'] + assert chart['d4_state_contract']['status'] == 'PASS' + assert chart['d4_state_contract_artifacts_consumed'] is True + assert chart['d4_state_observation_rows'] > 0 + assert chart['d4_observation_manifest_gate'] == 'D4_OBSERVATION_STATE_MANIFEST' + assert chart['d4_observation_manifest_validation_status'] == 'PASS' + assert chart['d4_reward_action_telemetry_sufficient_for_d4'] is False + assert chart['d4_reward_action_ablation_rows'] > 0 + assert chart['d4_source_hash_count'] > 0 + assert chart['fold_windows'] + assert chart['no_trade_control'] + assert chart['selected_fold_metrics'] + assert chart['cost_sensitivity_bp'] == [0, 23, 46] + assert chart['purge_days'] == 5 + assert chart['embargo_days'] == 5 + assert chart['min_required_purge_days'] == 5 + assert chart['min_required_embargo_days'] == 5 + assert chart['no_oos_retuning'] is True + assert 'worst_fold_max_drawdown' in chart['fold_consistency'] + assert 'mean_fold_turnover' in chart['fold_consistency'] + + + +def test_daily_walk_forward_stale_optimistic_artifact_fails_closed(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / "walk_forward" + run_dir = root / "optimistic_d5" + run_dir.mkdir(parents=True) + manifest = { + "schema_version": 1, + "run_id": "optimistic_d5", + "status": "READY", + "readiness_status": "D5_GO_READY", + "model_build_allowed": True, + "go_summary_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + "no_live_broker_order_readiness": False, + "d4_state_contract_artifacts_consumed": True, + "d4_state_contract_status": "PASS", + "d4_observation_manifest_gate": "D4_OBSERVATION_STATE_MANIFEST", + "d4_observation_manifest_validation_status": "PASS", + "d4_state_observation_rows": 100, + "d4_reward_action_ablation_rows": 10, + "d4_source_hash_count": 3, + "d4_artifact_issues": [], + "verdict": { + "status": "LIVE_READY", + "ui_badge": "GO", + "implementation_unlocked": True, + "model_build_allowed": True, + "go_summary_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + "no_live_broker_order_readiness": False, + "readiness_status": "D5_GO_READY", + "selected_strategy": "selected_rl", + "no_oos_retuning": False, + "purge_days": 0, + "embargo_days": 0, + "min_required_purge_days": -10, + "min_required_embargo_days": -10, + "d4_state_contract_artifacts_consumed": True, + "d4_state_contract_status": "PASS", + "d4_observation_manifest_gate": "D4_OBSERVATION_STATE_MANIFEST", + "d4_observation_manifest_validation_status": "PASS", + "d4_state_observation_rows": 100, + "d4_reward_action_ablation_rows": 10, + "d4_source_hash_count": 3, + "d4_artifact_issues": [], + "reasons": ["OPTIMISTIC_STALE", "D4_OBSERVATION_STATE_MANIFEST_CONSUMED"], + }, + } + (run_dir / "walk_forward_manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + (run_dir / "gate_verdict.json").write_text(json.dumps(manifest["verdict"]), encoding="utf-8") + (run_dir / "d4_state_contract.json").write_text(json.dumps({}), encoding="utf-8") + (run_dir / "cost_sensitivity.csv").write_text( + "fold_id,strategy,cost_bp,total_net_return,max_drawdown\n" + "F01,other_strategy,0,0.01,-0.01\n" + "F01,other_strategy,23,0.01,-0.01\n" + "F01,other_strategy,46,0.01,-0.01\n", + encoding="utf-8", + ) + monkeypatch.setattr(daily_dashboard, "DEFAULT_WALK_FORWARD_ROOT", root) + monkeypatch.setattr( + daily_dashboard, + "load_daily_db_summary", + lambda **_: { + "price_basis": "unknown", + "price_basis_status": "UNKNOWN_CONFIRMED", + "decision_grade_return_status": "BLOCKED_UNTIL_PRICE_BASIS_VERIFIED", + }, + ) + monkeypatch.setattr( + daily_dashboard, + "load_universe_preview", + lambda **_: {"verdict": "WATCH_HEURISTIC_UNIVERSE", "official_metadata_status": "MISSING"}, + ) + monkeypatch.setattr( + daily_dashboard, + "load_prediction_latest", + lambda **_: { + "baseline_delta_summary": {"model_build_allowed": False}, + "verdict": {"go_summary_allowed": False}, + }, + ) + + latest = daily_dashboard.load_walk_forward_latest(run="optimistic_d5", sample_limit=0) + chart = daily_dashboard.load_walk_forward_chart(run="optimistic_d5") + + for payload in (latest, chart): + assert payload["status"] == "NO-GO" + assert payload["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert payload["model_build_allowed"] is False + assert payload["go_summary_allowed"] is False + assert payload["paper_forward_allowed"] is False + assert payload["live_broker_order_allowed"] is False + assert payload["no_live_broker_order_readiness"] is True + assert latest["verdict"]["ui_badge"] == "NO-GO" + assert latest["verdict"]["implementation_unlocked"] is False + assert latest["verdict"]["no_live_broker_order_readiness"] is True + assert latest["verdict"]["d4_state_contract_artifacts_consumed"] is False + assert latest["verdict"]["d4_state_contract_status"] is None + assert "D4_STATE_CONTRACT_EVIDENCE_MISSING_OR_STALE" in latest["verdict"]["d4_artifact_issues"] + assert latest["d4_state_contract_artifacts_consumed"] is False + assert latest["d4_state_contract_status"] is None + assert "D4_STATE_CONTRACT_EVIDENCE_MISSING_OR_STALE" in latest["d4_artifact_issues"] + assert "D4_OBSERVATION_STATE_MANIFEST_CONSUMED" not in latest["verdict"]["reasons"] + assert "D5_EFFECTIVE_MODEL_BUILD_LOCK" in latest["verdict"]["reasons"] + assert chart["cost_sensitivity_bp"] == [] + assert chart["no_oos_retuning"] is False + assert chart["purge_days"] == 0 + assert chart["embargo_days"] == 0 + assert chart["min_required_purge_days"] == 5 + assert chart["min_required_embargo_days"] == 5 + assert chart["d4_state_contract_artifacts_consumed"] is False + assert "D5_COST_SENSITIVITY_INCOMPLETE" in chart["reasons"] + assert "D5_NO_OOS_RETUNING_NOT_PROVEN" in chart["reasons"] + assert "PURGE_DAYS_BELOW_REQUIRED_MIN" in chart["reasons"] + assert "EMBARGO_DAYS_BELOW_REQUIRED_MIN" in chart["reasons"] + assert "D4_STATE_CONTRACT_EVIDENCE_MISSING_OR_STALE" in chart["d4_artifact_issues"] + assert "D4_OBSERVATION_STATE_MANIFEST_CONSUMED" not in chart["reasons"] + + +def test_daily_ohlcv_portfolio_stale_optimistic_artifact_fails_closed(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / "portfolio" + run_dir = root / "optimistic" + run_dir.mkdir(parents=True) + stale_manifest = { + "schema_version": 1, + "run_id": "optimistic", + "status": "PASS", + "readiness_status": "D4_GO_READY", + "model_build_allowed": True, + "go_summary_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + "no_live_broker_order_readiness": False, + "verdict": { + "status": "GO", + "ui_badge": "GO", + "implementation_unlocked": True, + "model_build_allowed": True, + "go_summary_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + "readiness_status": "D4_GO_READY", + }, + "telemetry": {"status": "READY_RESEARCH_ONLY"}, + } + (run_dir / "rl_manifest.json").write_text(json.dumps(stale_manifest), encoding="utf-8") + (run_dir / "verdict.json").write_text( + json.dumps( + { + "status": "GO", + "ui_badge": "GO", + "implementation_unlocked": True, + "model_build_allowed": True, + "go_summary_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + "readiness_status": "D4_GO_READY", + "no_live_broker_order_readiness": False, + } + ), + encoding="utf-8", + ) + (run_dir / "policy_evaluation_manifest.json").write_text( + json.dumps( + { + "status": "PASS", + "model_build_allowed": True, + "go_summary_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + } + ), + encoding="utf-8", + ) + (run_dir / "training_manifest.json").write_text( + json.dumps( + { + "status": "PASS", + "readiness_status": "D4_GO_READY", + "model_build_allowed": True, + "go_summary_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + "verdict": { + "status": "GO", + "ui_badge": "GO", + "implementation_unlocked": True, + "model_build_allowed": True, + "go_summary_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + "readiness_status": "D4_GO_READY", + "no_live_broker_order_readiness": False, + }, + "telemetry": {"canonical_artifacts": []}, + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(daily_dashboard, "DEFAULT_PORTFOLIO_ROOT", root) + + latest = daily_dashboard.load_portfolio_latest(run="optimistic", sample_limit=0) + chart = daily_dashboard.load_portfolio_chart(run="optimistic") + + assert latest["status"] == "RESEARCH_ONLY" + assert latest["readiness_status"] == "D4_RESEARCH_ONLY_DIAGNOSTICS" + assert latest["model_build_allowed"] is False + assert latest["go_summary_allowed"] is False + assert latest["paper_forward_allowed"] is False + assert latest["live_broker_order_allowed"] is False + assert latest["no_live_broker_order_readiness"] is True + assert latest["verdict"]["status"] == "RESEARCH_ONLY" + assert latest["verdict"]["ui_badge"] == "RESEARCH_ONLY" + assert latest["verdict"]["implementation_unlocked"] is False + assert latest["verdict"]["model_build_allowed"] is False + assert latest["verdict"]["go_summary_allowed"] is False + assert latest["verdict"]["paper_forward_allowed"] is False + assert latest["verdict"]["live_broker_order_allowed"] is False + assert latest["verdict"]["no_live_broker_order_readiness"] is True + assert latest["policy_evaluation"]["model_build_allowed"] is False + assert latest["policy_evaluation"]["paper_forward_allowed"] is False + assert latest["training_manifest"]["status"] == "RESEARCH_ONLY" + assert latest["training_manifest"]["readiness_status"] == "D4_RESEARCH_ONLY_DIAGNOSTICS" + assert latest["training_manifest"]["model_build_allowed"] is False + assert latest["training_manifest"]["go_summary_allowed"] is False + assert latest["training_manifest"]["paper_forward_allowed"] is False + assert latest["training_manifest"]["live_broker_order_allowed"] is False + assert latest["training_manifest"]["verdict"]["implementation_unlocked"] is False + assert latest["training_manifest"]["verdict"]["no_live_broker_order_readiness"] is True + assert chart["status"] == "RESEARCH_ONLY" + assert chart["readiness_status"] == "D4_RESEARCH_ONLY_DIAGNOSTICS" + assert chart["model_build_allowed"] is False + assert chart["go_summary_allowed"] is False + assert chart["paper_forward_allowed"] is False + assert chart["live_broker_order_allowed"] is False + +def test_daily_ohlcv_prediction_stale_artifact_fails_closed(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / 'prediction' + run_dir = root / 'stale_go' + run_dir.mkdir(parents=True) + (run_dir / 'prediction_manifest.json').write_text( + json.dumps({ + 'schema_version': 1, + 'run_id': 'stale_go', + 'status': 'MODEL_READY', + 'readiness_status': 'PRODUCTION_READY', + 'model_build_allowed': True, + 'go_summary_allowed': True, + 'price_basis': 'raw', + 'price_basis_status': 'RAW_VERIFIED', + 'decision_grade_return_status': 'READY', + 'universe_verdict': 'OFFICIAL_OR_MANUAL_REVIEWED', + 'universe_review_status': 'OFFICIAL_OR_MANUAL_REVIEWED', + 'official_metadata_status': 'OFFICIAL_VERIFIED', + 'official_metadata_coverage_status': 'COMPLETE', + 'universe_certification_status': 'OFFICIAL_OR_MANUAL_REVIEWED', + 'baseline_delta_summary': { + 'status': 'LIVE_READY', + 'readiness_status': 'PRODUCTION_READY', + 'model_build_allowed': True, + 'go_summary_allowed': True, + 'shuffle_control_strategy': 'shuffle_control', + }, + 'verdict': { + 'status': 'READY', + 'readiness_status': 'PRODUCTION_READY', + 'go_summary_allowed': True, + 'model_build_allowed': True, + 'reasons': [], + }, + }), + encoding='utf-8', + ) + (run_dir / 'baseline_delta_summary.json').write_text( + json.dumps({ + 'status': 'MODEL_READY', + 'readiness_status': 'PRODUCTION_READY', + 'model_build_allowed': True, + 'go_summary_allowed': True, + 'shuffle_control_strategy': 'shuffle_control', + }), + encoding='utf-8', + ) + (run_dir / 'verdict.json').write_text( + json.dumps({'status': 'LIVE_READY', 'readiness_status': 'PRODUCTION_READY', 'go_summary_allowed': True, 'model_build_allowed': True, 'reasons': []}), + encoding='utf-8', + ) + monkeypatch.setattr(daily_dashboard, 'DEFAULT_PREDICTION_ROOT', root) + + payload = daily_dashboard.load_prediction_latest(run='stale_go', sample_limit=0) + chart = daily_dashboard.load_prediction_chart(run='stale_go') + + assert payload['status'] == 'WATCH' + assert payload['readiness_status'] == 'D3_WATCH_RESEARCH_ONLY' + assert payload['model_build_allowed'] is False + assert payload['go_summary_allowed'] is False + assert payload['verdict']['go_summary_allowed'] is False + assert payload['verdict']['model_build_allowed'] is False + assert payload['verdict']['status'] == 'WATCH' + assert payload['verdict']['readiness_status'] == 'D3_WATCH_RESEARCH_ONLY' + assert payload['price_basis'] == 'unknown' + assert payload['universe_verdict'] == 'WATCH_HEURISTIC_UNIVERSE' + assert payload['baseline_delta_summary']['model_build_allowed'] is False + assert payload['baseline_delta_summary']['go_summary_allowed'] is False + assert payload['baseline_delta_summary']['status'] == 'WATCH' + assert payload['baseline_delta_summary']['readiness_status'] == 'D3_WATCH_RESEARCH_ONLY' + assert payload['d3_gate_blockers'] == [ + 'D0_PRICE_BASIS_NOT_VERIFIED', + 'D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED', + 'D5_WALK_FORWARD_NOT_PASS', + 'D3_BASELINE_WATCH_RESEARCH_ONLY', + ] + assert 'model_build_or_candidate_promotion' in payload['d3_blocked_uses'] + assert chart['go_summary_allowed'] is False + assert chart['model_build_allowed'] is False + assert chart['readiness_status'] == 'D3_WATCH_RESEARCH_ONLY' + assert chart['baseline_delta_summary']['status'] == 'WATCH' + assert chart['baseline_delta_summary']['readiness_status'] == 'D3_WATCH_RESEARCH_ONLY' + assert chart['d3_gate_blockers'] == payload['d3_gate_blockers'] + + +def test_daily_ohlcv_visual_chart_apis_expose_read_only_gate_payloads(): + client = flask_app.test_client() + + decision = client.get('/api/daily-ohlcv/charts/decision-cockpit') + assert decision.status_code == 200 + decision_payload = decision.get_json() + assert decision_payload['model_build_allowed'] is False + assert decision_payload['go_summary_allowed'] is False + blocker_ids = {row['id'] for row in decision_payload['blockers']} + assert 'RL_POLICY_UNDERPERFORMS_D3_BASELINE' in blocker_ids + assert 'PRICE_BASIS_UNKNOWN' in blocker_ids + assert 'not a profit/live/broker/order readiness claim' in decision_payload['guardrail'] + assert decision_payload['usage_guide'][0]['stage'] == 'D6' + assert decision_payload['usage_guide'][1]['stage'] == 'D7' + assert 'Decision Cockpit' in decision_payload['usage_guide'][0]['can_do'] + + flow = client.get('/api/daily-ohlcv/charts/flow').get_json() + assert [node['id'] for node in flow['nodes']] == ['D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8', 'D9'] + assert len(flow['edges']) == 9 + assert flow['model_build_allowed'] is False + assert flow['nodes'][6]['usage_guide']['stage'] == 'D6' + assert flow['nodes'][7]['usage_guide']['stage'] == 'D7' + assert flow['nodes'][8]['severity'] == 'block' + assert flow['nodes'][9]['severity'] == 'block' + + glossary = client.get('/api/daily-ohlcv/charts/glossary').get_json() + terms = {row['term'] for row in glossary['items']} + assert {'NO-GO', 'RESEARCH_ONLY', 'Shuffle control', 'model_build_allowed'} <= terms + assert {'Learning curve', 'Action distribution', 'Portfolio trajectory', 'Symbol drilldown'} <= terms + + diagnostics = client.get('/api/daily-ohlcv/charts/research-diagnostics').get_json() + assert diagnostics['status'] == 'WATCH' + assert diagnostics['model_build_allowed'] is False + assert diagnostics['go_summary_allowed'] is False + diagnostic_ids = {row['id'] for row in diagnostics['cards']} + assert { + 'D7_FEATURE_DIAGNOSTICS', + 'D7_REGIME_DIAGNOSTICS', + 'D7_CORRELATION_RISK', + 'D7_FAILURE_ANALYSIS', + } <= diagnostic_ids + assert '읽기 전용' in diagnostics['summary']['korean'] + assert 'no profit, live, broker, order' in diagnostics['guardrail'] + assert diagnostics['usage_guide'][0]['stage'] == 'D7' + assert all(row['allowed_use'] for row in diagnostics['cards']) + assert all(row['blocked_use'] for row in diagnostics['cards']) + assert all(row['how_to_read_ko'] for row in diagnostics['cards']) + assert all(row['current_gap'] for row in diagnostics['cards']) + next_artifacts = {row['next_artifact'] for row in diagnostics['cards']} + assert { + 'feature_importance_by_fold.csv', + 'regime_bucket_metrics.csv', + 'correlation_cluster_summary.csv', + 'failure_reason_attribution.csv', + } <= next_artifacts + assert '연구 노트' in diagnostics['summary']['how_to_use'] + + equity = client.get('/api/daily-ohlcv/charts/equity-overlay').get_json() + assert equity['status'] == 'NO-GO' + assert any(curve['kind'] == 'daily_baseline' for curve in equity['curves']) + assert any(curve['kind'] == 'rl_research_only' for curve in equity['curves']) + assert 'not a profit' in equity['guardrail'] + + heatmap = client.get('/api/daily-ohlcv/charts/walk-forward-heatmap').get_json() + assert heatmap['status'] == 'NO-GO' + assert heatmap['model_build_allowed'] is False + assert heatmap['cells'] + assert any(row['cost_bp'] == 23 for row in heatmap['cost_series']) + + scatter = client.get('/api/daily-ohlcv/charts/run-scatter').get_json() + kinds = {row['kind'] for row in scatter['points']} + assert {'daily_baseline', 'daily_rl', 'walk_forward_gate'} <= kinds + + universe = client.get('/api/daily-ohlcv/charts/universe-breakdown').get_json() + assert universe['status'] == 'WATCH_HEURISTIC_UNIVERSE' + assert universe['summary']['include_count'] == 2599 + assert universe['summary']['exclude_count'] == 2128 + assert universe['summary']['universe_certification_status'] == 'BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW' + + symbol = client.get('/api/daily-ohlcv/charts/symbol/000250?limit=5').get_json() + assert symbol['code'] == '000250' + assert symbol['table'] == 'A000250' + assert len(symbol['ohlcv']) <= 5 + assert 'not a prediction' in symbol['guardrail'] + assert symbol['usage_guide'][0]['section'] == 'symbol_ohlcv_preview' + assert '매수/매도 추천' in symbol['usage_guide'][0]['must_not'] + + +def test_daily_effective_model_gate_blocks_optimistic_d5_artifact(monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + monkeypatch.setattr( + daily_dashboard, + 'load_daily_db_summary', + lambda **_: { + 'table_count': 1, + 'total_rows': 10, + 'price_basis': 'unknown', + 'price_basis_status': 'UNKNOWN_CONFIRMED', + 'decision_grade_return_status': 'BLOCKED_UNTIL_PRICE_BASIS_VERIFIED', + 'quality_scan_scope': 'synthetic', + }, + ) + monkeypatch.setattr( + daily_dashboard, + 'load_universe_preview', + lambda **_: { + 'verdict': 'WATCH_HEURISTIC_UNIVERSE', + 'official_metadata_status': 'MISSING', + 'include_count': 1, + 'exclude_count': 0, + 'stockinfo_matched_table_count': 1, + 'stockinfo_unmatched_table_count': 0, + }, + ) + monkeypatch.setattr( + daily_dashboard, + 'load_dataset_latest', + lambda **_: { + 'status': 'PASS', + 'price_basis': 'unknown', + 'universe_verdict': 'WATCH_HEURISTIC_UNIVERSE', + 'leakage_status': 'PASS', + 'split_chronology_status': 'PASS', + 'row_counts': {'feature_rows': 10, 'eligible_rows': 10}, + 'artifact_scope': 'synthetic', + }, + ) + monkeypatch.setattr( + daily_dashboard, + 'load_prediction_latest', + lambda **_: { + 'status': 'WATCH', + 'price_basis': 'unknown', + 'verdict': { + 'best_strategy_by_total_net_return': 'optimistic', + 'go_summary_allowed': False, + }, + 'baseline_delta_summary': { + 'model_build_allowed': False, + 'shuffle_control_strategy': 'shuffle_control', + 'best_rule_baseline_strategy': 'equal_weight_topk_momentum', + 'cost_round_trip_bp': 23, + }, + }, + ) + monkeypatch.setattr( + daily_dashboard, + 'load_portfolio_latest', + lambda **_: { + 'status': 'RESEARCH_ONLY', + 'verdict': {'implementation_unlocked': False, 'gate_dependency': 'D3'}, + 'baseline_comparison': {'delta_vs_best_d3_total_net_return': -0.1}, + 'observation_manifest_validation': {'status': 'PASS'}, + 'samples': {}, + }, + ) + monkeypatch.setattr( + daily_dashboard, + 'load_walk_forward_latest', + lambda **_: { + 'status': 'PASS', + 'run_id': 'optimistic_d5', + 'verdict': { + 'model_build_allowed': True, + 'go_summary_allowed': True, + 'n_folds': 5, + 'no_oos_retuning': True, + 'd4_state_contract_status': 'PASS', + 'selected_strategy': 'optimistic', + 'strategy_selection_policy': 'synthetic', + 'reasons': [], + }, + 'samples': {'fold_metrics': []}, + 'd4_state_contract': {'status': 'PASS'}, + }, + ) + monkeypatch.setattr( + daily_dashboard, + 'load_registry_latest', + lambda **_: { + 'status': 'RESEARCH_ONLY_BLOCKED', + 'promotion_status': 'BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER', + 'model_build_allowed': False, + 'paper_forward_allowed': False, + 'live_broker_order_allowed': False, + 'no_live_broker_order_readiness': True, + 'code_hash': 'synthetic', + 'candidate_registry': {}, + }, + ) + + client = flask_app.test_client() + progress = client.get('/api/daily-ohlcv/progress').get_json() + gate = client.get('/api/daily-ohlcv/gate/latest').get_json() + flow = client.get('/api/daily-ohlcv/charts/flow').get_json() + decision = client.get('/api/daily-ohlcv/charts/decision-cockpit').get_json() + diagnostics = client.get('/api/daily-ohlcv/charts/research-diagnostics').get_json() + + for payload in (progress, gate, flow, decision, diagnostics): + assert payload['model_build_allowed'] is False + assert payload['go_summary_allowed'] is False + assert progress['effective_gate_blockers'] == [ + 'D0_PRICE_BASIS_NOT_VERIFIED', + 'D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED', + 'D3_BASELINE_NOT_PROMOTABLE', + ] + assert gate['effective_gate_blockers'] == progress['effective_gate_blockers'] + blocker_ids = {row['id'] for row in decision['blockers']} + assert set(progress['effective_gate_blockers']) <= blocker_ids + + +def test_daily_effective_model_gate_fails_closed_on_missing_d0_fields(): + import webui.daily_ohlcv_dashboard as daily_dashboard + + gate = daily_dashboard._effective_daily_model_gate( + db={}, + universe={'verdict': 'PASS', 'official_metadata_status': 'OFFICIAL_VERIFIED'}, + prediction={ + 'baseline_delta_summary': {'model_build_allowed': True}, + 'verdict': {'go_summary_allowed': True}, + }, + gate_verdict={'model_build_allowed': True, 'go_summary_allowed': True}, + gate_status='PASS', + ) + + assert gate['model_build_allowed'] is False + assert 'D0_PRICE_BASIS_NOT_VERIFIED' in gate['effective_gate_blockers'] + + +def test_daily_effective_model_gate_rejects_unverified_substrings(): + import webui.daily_ohlcv_dashboard as daily_dashboard + + gate = daily_dashboard._effective_daily_model_gate( + db={ + 'price_basis': 'raw', + 'price_basis_status': 'UNVERIFIED', + 'decision_grade_return_status': 'READY', + }, + universe={'verdict': 'UNOFFICIAL_VERIFIED', 'official_metadata_status': 'UNVERIFIED'}, + prediction={ + 'baseline_delta_summary': {'model_build_allowed': True}, + 'verdict': {'go_summary_allowed': True}, + }, + gate_verdict={'model_build_allowed': True, 'go_summary_allowed': True}, + gate_status='PASS', + ) + + assert gate['model_build_allowed'] is False + assert gate['effective_gate_blockers'] == [ + 'D0_PRICE_BASIS_NOT_VERIFIED', + 'D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED', + ] + +def test_daily_effective_model_gate_requires_exact_d1_statuses_and_complete_coverage(): + import webui.daily_ohlcv_dashboard as daily_dashboard + + common_kwargs = { + 'db': { + 'price_basis': 'raw', + 'price_basis_status': 'RAW_VERIFIED', + 'decision_grade_return_status': 'READY', + }, + 'prediction': { + 'baseline_delta_summary': {'model_build_allowed': True}, + 'verdict': {'go_summary_allowed': True}, + }, + 'gate_verdict': {'model_build_allowed': True, 'go_summary_allowed': True}, + 'gate_status': 'PASS', + } + generic = daily_dashboard._effective_daily_model_gate( + universe={ + 'verdict': 'PASS', + 'universe_review_status': 'PASS', + 'official_metadata_status': 'PASS', + 'official_metadata_coverage_status': 'COMPLETE', + 'universe_certification_status': 'PASS', + }, + **common_kwargs, + ) + partial = daily_dashboard._effective_daily_model_gate( + universe={ + 'verdict': 'OFFICIAL_OR_MANUAL_REVIEWED', + 'universe_review_status': 'OFFICIAL_OR_MANUAL_REVIEWED', + 'official_metadata_status': 'OFFICIAL_VERIFIED', + 'official_metadata_coverage_status': 'PARTIAL', + 'universe_certification_status': 'OFFICIAL_OR_MANUAL_REVIEWED', + }, + **common_kwargs, + ) + + assert generic['model_build_allowed'] is False + assert generic['effective_gate_blockers'] == ['D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED'] + assert partial['model_build_allowed'] is False + assert partial['effective_gate_blockers'] == ['D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED'] + + + +def test_daily_effective_model_gate_accepts_explicit_verified_d0_d1(): + import webui.daily_ohlcv_dashboard as daily_dashboard + + gate = daily_dashboard._effective_daily_model_gate( + db={ + 'price_basis': 'raw', + 'price_basis_status': 'RAW_VERIFIED', + 'decision_grade_return_status': 'READY', + }, + universe={'verdict': 'OFFICIAL_OR_MANUAL_REVIEWED', 'universe_review_status': 'OFFICIAL_OR_MANUAL_REVIEWED', 'official_metadata_status': 'OFFICIAL_VERIFIED', 'official_metadata_coverage_status': 'COMPLETE', 'universe_certification_status': 'OFFICIAL_OR_MANUAL_REVIEWED'}, + prediction={ + 'baseline_delta_summary': {'model_build_allowed': True}, + 'verdict': {'go_summary_allowed': True}, + }, + gate_verdict={'model_build_allowed': True, 'go_summary_allowed': True}, + gate_status='PASS', + ) + + assert gate['model_build_allowed'] is True + assert gate['effective_gate_blockers'] == [] + + +def test_daily_registry_invariants_require_explicit_effective_gate_and_verified_d0_d1(): + import webui.daily_ohlcv_dashboard as daily_dashboard + + errors = daily_dashboard._registry_artifact_invariant_errors( + { + 'guardrail': 'research-only no live/broker/orders no profit', + 'model_build_allowed': True, + 'paper_forward_allowed': True, + 'live_broker_order_allowed': False, + 'no_live_broker_order_readiness': True, + }, + { + 'candidates': [ + { + 'config_hash': 'a' * 64, + 'data_hash': 'b' * 64, + 'code_hash': 'c' * 64, + 'source_hashes': { + 'stom_rl/daily_rl_train.py': 'd' * 64, + 'stom_rl/daily_walk_forward.py': 'e' * 64, + 'stom_rl/daily_registry.py': 'f' * 64, + 'webui/daily_ohlcv_dashboard.py': '1' * 64, + 'webui/app.py': '2' * 64, + 'webui/v2_src/src/lib/dailyOhlcvApi.ts': '3' * 64, + 'webui/v2_src/src/tabs/DailyOhlcvTab.svelte': '4' * 64, + 'webui/v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte': '5' * 64, + 'webui/v2_src/src/tabs/dailyOhlcv/DailyVisualLabCard.svelte': '6' * 64, + }, + 'live_broker_order_allowed': False, + 'no_live_broker_order_readiness': True, + 'd4_status': 'PASS', + 'd5_status': 'PASS', + 'baseline_delta_vs_best_d3': 0.1, + 'model_build_allowed': True, + 'paper_forward_allowed': True, + } + ] + }, + ) + + assert 'REGISTRY_CANDIDATE_0_EFFECTIVE_GATE_BLOCKERS_MISSING' in errors + assert 'REGISTRY_MANIFEST_MODEL_BUILD_TRUE_WITHOUT_SAFE_CANDIDATE' in errors + assert 'REGISTRY_MANIFEST_PAPER_FORWARD_TRUE_WITHOUT_SAFE_CANDIDATE' in errors + + +def test_daily_walk_forward_heatmap_applies_effective_gate(monkeypatch, tmp_path): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / 'walk_forward' + run_dir = root / 'optimistic_heatmap' + run_dir.mkdir(parents=True) + (run_dir / 'walk_forward_manifest.json').write_text( + json.dumps({'run_id': 'optimistic_heatmap', 'verdict': {'status': 'PASS'}}), + encoding='utf-8', + ) + (run_dir / 'gate_verdict.json').write_text( + json.dumps({ + 'status': 'PASS', + 'selected_strategy': 'optimistic', + 'model_build_allowed': True, + 'go_summary_allowed': True, + 'n_folds': 5, + 'no_oos_retuning': True, + }), + encoding='utf-8', + ) + (run_dir / 'fold_metrics.csv').write_text( + 'fold_id,strategy,control,total_net_return,max_drawdown,delta_vs_no_trade_total_net_return,delta_vs_shuffled_total_net_return,mean_turnover,hit_rate\n' + 'F01,optimistic,actual,0.1,-0.02,0.1,0.1,0.1,0.6\n', + encoding='utf-8', + ) + (run_dir / 'cost_sensitivity.csv').write_text( + 'fold_id,strategy,cost_bp,total_net_return,max_drawdown\n' + 'F01,optimistic,23,0.1,-0.02\n', + encoding='utf-8', + ) + monkeypatch.setattr(daily_dashboard, 'DEFAULT_WALK_FORWARD_ROOT', root) + monkeypatch.setattr( + daily_dashboard, + 'load_daily_db_summary', + lambda **_: { + 'price_basis': 'unknown', + 'price_basis_status': 'UNKNOWN_CONFIRMED', + 'decision_grade_return_status': 'BLOCKED_UNTIL_PRICE_BASIS_VERIFIED', + }, + ) + monkeypatch.setattr( + daily_dashboard, + 'load_universe_preview', + lambda **_: {'verdict': 'WATCH_HEURISTIC_UNIVERSE', 'official_metadata_status': 'MISSING'}, + ) + monkeypatch.setattr( + daily_dashboard, + 'load_prediction_latest', + lambda **_: { + 'baseline_delta_summary': {'model_build_allowed': True}, + 'verdict': {'go_summary_allowed': True}, + }, + ) + + payload = daily_dashboard.load_walk_forward_heatmap_chart(run='optimistic_heatmap') + + assert payload['status'] == 'NO-GO' + assert payload['readiness_status'] == 'D5_NO_GO_RESEARCH_ONLY_GATE' + assert payload['model_build_allowed'] is False + assert payload['go_summary_allowed'] is False + assert payload['paper_forward_allowed'] is False + assert payload['live_broker_order_allowed'] is False + assert payload['effective_gate_blockers'] == [ + 'D0_PRICE_BASIS_NOT_VERIFIED', + 'D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED', + 'D5_WALK_FORWARD_NOT_PASS', + ] + + +def _registry_required_sources(fake_hash: str) -> dict[str, str]: + return { + 'stom_rl/daily_rl_train.py': fake_hash, + 'stom_rl/daily_walk_forward.py': fake_hash, + 'stom_rl/daily_registry.py': fake_hash, + 'webui/daily_ohlcv_dashboard.py': fake_hash, + 'webui/app.py': fake_hash, + 'webui/v2_src/src/lib/dailyOhlcvApi.ts': fake_hash, + 'webui/v2_src/src/tabs/DailyOhlcvTab.svelte': fake_hash, + 'webui/v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte': fake_hash, + 'webui/v2_src/src/tabs/dailyOhlcv/DailyVisualLabCard.svelte': fake_hash, + } + + +def _write_registry_safe_manifest_and_candidate(run_dir: Path, fake_hash: str) -> None: + (run_dir / 'registry_manifest.json').write_text( + json.dumps( + { + 'schema_version': 1, + 'run_id': run_dir.name, + 'status': 'RESEARCH_ONLY_BLOCKED', + 'guardrail': 'Research-only no live/broker/orders no profit claim evidence', + 'model_build_allowed': False, + 'paper_forward_allowed': False, + 'live_broker_order_allowed': False, + 'no_live_broker_order_readiness': True, + 'config_hash': fake_hash, + 'data_hash': fake_hash, + 'code_hash': fake_hash, + 'effective_gate_blockers': ['D5_WALK_FORWARD_NOT_PASS'], + } + ), + encoding='utf-8', + ) + (run_dir / 'candidate_registry.json').write_text( + json.dumps( + { + 'candidates': [ + { + 'candidate_id': 'blocked-candidate', + 'd4_status': 'RESEARCH_ONLY', + 'd5_status': 'NO-GO', + 'model_build_allowed': False, + 'paper_forward_allowed': False, + 'live_broker_order_allowed': False, + 'no_live_broker_order_readiness': True, + 'config_hash': fake_hash, + 'data_hash': fake_hash, + 'code_hash': fake_hash, + 'source_hashes': _registry_required_sources(fake_hash), + 'effective_gate_blockers': ['D5_WALK_FORWARD_NOT_PASS'], + } + ] + } + ), + encoding='utf-8', + ) + + +def _write_registry_nonempty_evidence(run_dir: Path) -> None: + (run_dir / 'paper_selected.csv').write_text( + 'date,code,rank,paper_weight,paper_only_selected,selection_status,strategy,reason\n' + '20260614,000250,1,0,false,BLOCKED_BY_D5_NO_GO,equal_weight_topk_momentum,D5_WALK_FORWARD_NOT_PASS\n', + encoding='utf-8', + ) + (run_dir / 'realized_returns.csv').write_text( + 'date,split,paper_nav,realized_return,policy_reward,current_drawdown,evidence_status,numeric_error,source\n' + '20260614,test,1.0,0.0,0.0,0.0,COMPLETE_NUMERIC_EVIDENCE,,policy_nav_research_artifact_not_live_trade\n', + encoding='utf-8', + ) + (run_dir / 'drift.csv').write_text( + 'metric,value,reference,status,action\n' + 'd5_gate_status,NO-GO,NO-GO blocks model build,BLOCKED,block promotion when D5 is not PASS\n', + encoding='utf-8', + ) + (run_dir / 'drawdown.csv').write_text( + 'date,split,paper_nav,paper_forward_drawdown,computed_drawdown,evidence_status,numeric_error,source\n' + '20260614,test,1.0,0.0,0.0,COMPLETE_NUMERIC_EVIDENCE,,research_policy_nav_not_live_account\n', + encoding='utf-8', + ) + (run_dir / 'decision_log.jsonl').write_text( + json.dumps({'event': 'live_broker_order_blocked', 'status': 'BLOCKED'}) + '\n', + encoding='utf-8', + ) + + +def test_daily_registry_api_blocks_unsafe_generated_artifact(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + root = tmp_path / "registry" + run_dir = root / "unsafe_registry" + run_dir.mkdir(parents=True) + (run_dir / "registry_manifest.json").write_text( + json.dumps( + { + "schema_version": 1, + "run_id": "unsafe_registry", + "status": "PAPER_ONLY_REVIEW_REQUIRED", + "guardrail": "unsafe optimistic artifact", + "model_build_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + "no_live_broker_order_readiness": False, + } + ), + encoding="utf-8", + ) + (run_dir / "candidate_registry.json").write_text( + json.dumps( + { + "candidates": [ + { + "candidate_id": "unsafe", + "d4_status": "RESEARCH_ONLY", + "d5_status": "NO-GO", + "model_build_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": True, + "no_live_broker_order_readiness": False, + } + ] + } + ), + encoding="utf-8", + ) + for name in ["paper_selected.csv", "realized_returns.csv", "drift.csv", "drawdown.csv"]: + (run_dir / name).write_text("\n", encoding="utf-8") + (run_dir / "decision_log.jsonl").write_text("", encoding="utf-8") + monkeypatch.setattr(daily_dashboard, "DEFAULT_DAILY_REGISTRY_ROOT", root) + + client = flask_app.test_client() + response = client.get("/api/daily-ohlcv/registry/latest?run=unsafe_registry") + assert response.status_code == 200 + payload = response.get_json() + candidate = payload["candidate_registry"]["candidates"][0] + assert payload["status"] == "BLOCKED_UNSAFE_REGISTRY_ARTIFACT" + assert payload["model_build_allowed"] is False + assert payload["paper_forward_allowed"] is False + assert payload["live_broker_order_allowed"] is False + assert payload["no_live_broker_order_readiness"] is True + assert candidate["model_build_allowed"] is False + assert candidate["paper_forward_allowed"] is False + assert candidate["live_broker_order_allowed"] is False + assert candidate["no_live_broker_order_readiness"] is True + assert "REGISTRY_MANIFEST_LIVE_BROKER_ORDER_NOT_FALSE" in payload["invariant_errors"] + assert "no live/broker/orders" in payload["guardrail"] + + + +def test_daily_registry_api_blocks_malformed_json_artifacts(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + fake_hash = 'e' * 64 + root = tmp_path / 'registry' + run_dir = root / 'malformed_registry' + run_dir.mkdir(parents=True) + (run_dir / 'registry_manifest.json').write_text('{not-json', encoding='utf-8') + (run_dir / 'candidate_registry.json').write_text( + json.dumps({'candidates': [{'candidate_id': 'blocked', 'model_build_allowed': False, 'paper_forward_allowed': False, 'live_broker_order_allowed': False, 'no_live_broker_order_readiness': True, 'config_hash': fake_hash, 'data_hash': fake_hash, 'code_hash': fake_hash, 'source_hashes': _registry_required_sources(fake_hash), 'effective_gate_blockers': ['D5_WALK_FORWARD_NOT_PASS']}]}), + encoding='utf-8', + ) + _write_registry_nonempty_evidence(run_dir) + monkeypatch.setattr(daily_dashboard, 'DEFAULT_DAILY_REGISTRY_ROOT', root) + + payload = flask_app.test_client().get('/api/daily-ohlcv/registry/latest?run=malformed_registry').get_json() + + assert payload['status'] == 'BLOCKED_UNSAFE_REGISTRY_ARTIFACT' + assert payload['model_build_allowed'] is False + assert payload['paper_forward_allowed'] is False + assert 'REGISTRY_MANIFEST_JSON_INVALID' in payload['invariant_errors'] + + +def test_daily_registry_api_blocks_malformed_decision_log_jsonl(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + fake_hash = 'f' * 64 + root = tmp_path / 'registry' + run_dir = root / 'malformed_decision_log' + run_dir.mkdir(parents=True) + _write_registry_safe_manifest_and_candidate(run_dir, fake_hash) + _write_registry_nonempty_evidence(run_dir) + (run_dir / 'decision_log.jsonl').write_text('{not-json\n', encoding='utf-8') + monkeypatch.setattr(daily_dashboard, 'DEFAULT_DAILY_REGISTRY_ROOT', root) + + payload = flask_app.test_client().get('/api/daily-ohlcv/registry/latest?run=malformed_decision_log').get_json() + + assert payload['status'] == 'BLOCKED_UNSAFE_REGISTRY_ARTIFACT' + assert 'REGISTRY_DECISION_LOG_JSONL_INVALID' in payload['invariant_errors'] + assert payload['samples']['decision_log'] == [] + + +def test_daily_registry_api_blocks_missing_or_empty_evidence_files(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + fake_hash = '0' * 64 + root = tmp_path / 'registry' + run_dir = root / 'empty_evidence_registry' + run_dir.mkdir(parents=True) + _write_registry_safe_manifest_and_candidate(run_dir, fake_hash) + (run_dir / 'paper_selected.csv').write_text('date,selection_status\n', encoding='utf-8') + (run_dir / 'realized_returns.csv').write_text('', encoding='utf-8') + (run_dir / 'drift.csv').write_text('metric,value\nprice_basis,unknown\n', encoding='utf-8') + (run_dir / 'decision_log.jsonl').write_text('', encoding='utf-8') + monkeypatch.setattr(daily_dashboard, 'DEFAULT_DAILY_REGISTRY_ROOT', root) + + payload = flask_app.test_client().get('/api/daily-ohlcv/registry/latest?run=empty_evidence_registry').get_json() + + assert payload['status'] == 'BLOCKED_UNSAFE_REGISTRY_ARTIFACT' + assert 'REGISTRY_EVIDENCE_PAPER_SELECTED_EMPTY' in payload['invariant_errors'] + assert 'REGISTRY_EVIDENCE_REALIZED_RETURNS_HEADER_MISSING' in payload['invariant_errors'] + assert 'REGISTRY_EVIDENCE_DRAWDOWN_MISSING' in payload['invariant_errors'] + assert 'REGISTRY_EVIDENCE_DECISION_LOG_EMPTY' in payload['invariant_errors'] + + +def test_daily_registry_api_blocks_wrong_column_and_invalid_csv_evidence(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + fake_hash = '1' * 64 + root = tmp_path / 'registry' + run_dir = root / 'bad_csv_registry' + run_dir.mkdir(parents=True) + _write_registry_safe_manifest_and_candidate(run_dir, fake_hash) + (run_dir / 'paper_selected.csv').write_text('foo\nbar\n', encoding='utf-8') + (run_dir / 'realized_returns.csv').write_bytes(b'\xff\xfe\x00\x00') + (run_dir / 'drift.csv').write_text('metric,value,reference,status,action\nprice_basis,unknown,required,BLOCKED,keep locked\n', encoding='utf-8') + (run_dir / 'drawdown.csv').write_text( + 'date,split,paper_nav,paper_forward_drawdown,computed_drawdown,evidence_status,numeric_error,source\n' + '20260614,test,1.0,0.0,0.0,COMPLETE_NUMERIC_EVIDENCE,,research_policy_nav_not_live_account\n', + encoding='utf-8', + ) + (run_dir / 'decision_log.jsonl').write_text(json.dumps({'event': 'registry_created', 'status': 'RESEARCH_ONLY'}) + '\n', encoding='utf-8') + monkeypatch.setattr(daily_dashboard, 'DEFAULT_DAILY_REGISTRY_ROOT', root) + + payload = flask_app.test_client().get('/api/daily-ohlcv/registry/latest?run=bad_csv_registry').get_json() + + assert payload['status'] == 'BLOCKED_UNSAFE_REGISTRY_ARTIFACT' + assert 'REGISTRY_EVIDENCE_PAPER_SELECTED_COLUMNS_INVALID' in payload['invariant_errors'] + assert 'REGISTRY_EVIDENCE_REALIZED_RETURNS_INVALID' in payload['invariant_errors'] + assert payload['samples']['realized_returns'] == [] + +def test_daily_registry_api_blocks_missing_hash_evidence(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + fake_hash = "a" * 64 + root = tmp_path / "registry" + run_dir = root / "missing_hash_registry" + run_dir.mkdir(parents=True) + (run_dir / "registry_manifest.json").write_text( + json.dumps( + { + "schema_version": 1, + "run_id": "missing_hash_registry", + "status": "RESEARCH_ONLY_BLOCKED", + "guardrail": "Research-only no live/broker/orders evidence", + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "config_hash": fake_hash, + "data_hash": fake_hash, + "code_hash": "not-a-sha", + } + ), + encoding="utf-8", + ) + (run_dir / "candidate_registry.json").write_text( + json.dumps( + { + "candidates": [ + { + "candidate_id": "missing-hash", + "d4_status": "RESEARCH_ONLY", + "d5_status": "NO-GO", + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "config_hash": fake_hash, + "data_hash": fake_hash, + "code_hash": fake_hash, + "source_hashes": {"stom_rl/daily_registry.py": fake_hash}, + } + ] + } + ), + encoding="utf-8", + ) + for name in ["paper_selected.csv", "realized_returns.csv", "drift.csv", "drawdown.csv"]: + (run_dir / name).write_text("\n", encoding="utf-8") + (run_dir / "decision_log.jsonl").write_text("", encoding="utf-8") + monkeypatch.setattr(daily_dashboard, "DEFAULT_DAILY_REGISTRY_ROOT", root) + + client = flask_app.test_client() + payload = client.get("/api/daily-ohlcv/registry/latest?run=missing_hash_registry").get_json() + assert payload["status"] == "BLOCKED_UNSAFE_REGISTRY_ARTIFACT" + assert "REGISTRY_MANIFEST_CODE_HASH_INVALID" in payload["invariant_errors"] + assert any(error.startswith("REGISTRY_CANDIDATE_0_SOURCE_HASH_INVALID_webui/app.py") for error in payload["invariant_errors"]) + + +def test_daily_registry_api_blocks_manifest_only_optimistic_flags(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + fake_hash = "b" * 64 + required_sources = { + "stom_rl/daily_rl_train.py": fake_hash, + "stom_rl/daily_walk_forward.py": fake_hash, + "stom_rl/daily_registry.py": fake_hash, + "webui/daily_ohlcv_dashboard.py": fake_hash, + "webui/app.py": fake_hash, + } + root = tmp_path / "registry" + run_dir = root / "manifest_optimistic_registry" + run_dir.mkdir(parents=True) + (run_dir / "registry_manifest.json").write_text( + json.dumps( + { + "schema_version": 1, + "run_id": "manifest_optimistic_registry", + "status": "PAPER_ONLY_REVIEW_REQUIRED", + "guardrail": "Research-only no live/broker/orders evidence", + "model_build_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "config_hash": fake_hash, + "data_hash": fake_hash, + "code_hash": fake_hash, + } + ), + encoding="utf-8", + ) + (run_dir / "candidate_registry.json").write_text( + json.dumps( + { + "candidates": [ + { + "candidate_id": "blocked-candidate", + "d4_status": "RESEARCH_ONLY", + "d5_status": "NO-GO", + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "config_hash": fake_hash, + "data_hash": fake_hash, + "code_hash": fake_hash, + "source_hashes": required_sources, + } + ] + } + ), + encoding="utf-8", + ) + for name in ["paper_selected.csv", "realized_returns.csv", "drift.csv", "drawdown.csv"]: + (run_dir / name).write_text("\n", encoding="utf-8") + (run_dir / "decision_log.jsonl").write_text("", encoding="utf-8") + monkeypatch.setattr(daily_dashboard, "DEFAULT_DAILY_REGISTRY_ROOT", root) + + client = flask_app.test_client() + payload = client.get("/api/daily-ohlcv/registry/latest?run=manifest_optimistic_registry").get_json() + assert payload["status"] == "BLOCKED_UNSAFE_REGISTRY_ARTIFACT" + assert payload["model_build_allowed"] is False + assert payload["paper_forward_allowed"] is False + assert "REGISTRY_MANIFEST_MODEL_BUILD_TRUE_WITHOUT_SAFE_CANDIDATE" in payload["invariant_errors"] + assert "REGISTRY_MANIFEST_PAPER_FORWARD_TRUE_WITHOUT_SAFE_CANDIDATE" in payload["invariant_errors"] + + +def test_daily_registry_api_blocks_candidate_optimistic_without_d4_pass(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + fake_hash = "c" * 64 + required_sources = { + "stom_rl/daily_rl_train.py": fake_hash, + "stom_rl/daily_walk_forward.py": fake_hash, + "stom_rl/daily_registry.py": fake_hash, + "webui/daily_ohlcv_dashboard.py": fake_hash, + "webui/app.py": fake_hash, + } + root = tmp_path / "registry" + run_dir = root / "candidate_optimistic_registry" + run_dir.mkdir(parents=True) + (run_dir / "registry_manifest.json").write_text( + json.dumps( + { + "schema_version": 1, + "run_id": "candidate_optimistic_registry", + "status": "PAPER_ONLY_REVIEW_REQUIRED", + "guardrail": "Research-only no live/broker/orders evidence", + "model_build_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "config_hash": fake_hash, + "data_hash": fake_hash, + "code_hash": fake_hash, + } + ), + encoding="utf-8", + ) + (run_dir / "candidate_registry.json").write_text( + json.dumps( + { + "candidates": [ + { + "candidate_id": "unsafe-d4-watch", + "d4_status": "WATCH", + "d5_status": "PASS", + "model_build_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "config_hash": fake_hash, + "data_hash": fake_hash, + "code_hash": fake_hash, + "source_hashes": required_sources, + } + ] + } + ), + encoding="utf-8", + ) + for name in ["paper_selected.csv", "realized_returns.csv", "drift.csv", "drawdown.csv"]: + (run_dir / name).write_text("\n", encoding="utf-8") + (run_dir / "decision_log.jsonl").write_text("", encoding="utf-8") + monkeypatch.setattr(daily_dashboard, "DEFAULT_DAILY_REGISTRY_ROOT", root) + + client = flask_app.test_client() + payload = client.get("/api/daily-ohlcv/registry/latest?run=candidate_optimistic_registry").get_json() + assert payload["status"] == "BLOCKED_UNSAFE_REGISTRY_ARTIFACT" + assert payload["model_build_allowed"] is False + assert payload["paper_forward_allowed"] is False + assert "REGISTRY_CANDIDATE_0_MODEL_BUILD_TRUE_WITH_LOCKED_GATES" in payload["invariant_errors"] + assert "REGISTRY_CANDIDATE_0_PAPER_FORWARD_TRUE_WITH_LOCKED_GATES" in payload["invariant_errors"] + +def test_daily_registry_api_blocks_candidate_optimistic_with_missing_baseline_delta(tmp_path, monkeypatch): + import webui.daily_ohlcv_dashboard as daily_dashboard + + fake_hash = "d" * 64 + required_sources = { + "stom_rl/daily_rl_train.py": fake_hash, + "stom_rl/daily_walk_forward.py": fake_hash, + "stom_rl/daily_registry.py": fake_hash, + "webui/daily_ohlcv_dashboard.py": fake_hash, + "webui/app.py": fake_hash, + "webui/v2_src/src/lib/dailyOhlcvApi.ts": fake_hash, + "webui/v2_src/src/tabs/DailyOhlcvTab.svelte": fake_hash, + "webui/v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte": fake_hash, + "webui/v2_src/src/tabs/dailyOhlcv/DailyVisualLabCard.svelte": fake_hash, + } + root = tmp_path / "registry" + run_dir = root / "candidate_missing_baseline_delta" + run_dir.mkdir(parents=True) + (run_dir / "registry_manifest.json").write_text( + json.dumps( + { + "schema_version": 1, + "run_id": "candidate_missing_baseline_delta", + "status": "PAPER_ONLY_REVIEW_REQUIRED", + "guardrail": "Research-only no live/broker/orders evidence", + "model_build_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "config_hash": fake_hash, + "data_hash": fake_hash, + "code_hash": fake_hash, + } + ), + encoding="utf-8", + ) + (run_dir / "candidate_registry.json").write_text( + json.dumps( + { + "candidates": [ + { + "candidate_id": "unsafe-missing-d3", + "d4_status": "PASS", + "d5_status": "PASS", + "model_build_allowed": True, + "paper_forward_allowed": True, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "price_basis": "adjusted_verified", + "universe_review_status": "OFFICIAL_COMMON_EQUITY_REVIEWED", + "config_hash": fake_hash, + "data_hash": fake_hash, + "code_hash": fake_hash, + "source_hashes": required_sources, + } + ] + } + ), + encoding="utf-8", + ) + for name in ["paper_selected.csv", "realized_returns.csv", "drift.csv", "drawdown.csv"]: + (run_dir / name).write_text("\n", encoding="utf-8") + (run_dir / "decision_log.jsonl").write_text("", encoding="utf-8") + monkeypatch.setattr(daily_dashboard, "DEFAULT_DAILY_REGISTRY_ROOT", root) + + client = flask_app.test_client() + payload = client.get("/api/daily-ohlcv/registry/latest?run=candidate_missing_baseline_delta").get_json() + assert payload["status"] == "BLOCKED_UNSAFE_REGISTRY_ARTIFACT" + assert payload["model_build_allowed"] is False + assert payload["paper_forward_allowed"] is False + assert "REGISTRY_CANDIDATE_0_MODEL_BUILD_TRUE_WITH_LOCKED_GATES" in payload["invariant_errors"] + assert "REGISTRY_CANDIDATE_0_PAPER_FORWARD_TRUE_WITH_LOCKED_GATES" in payload["invariant_errors"] + + + +def test_daily_ohlcv_unsafe_run_and_mutating_methods_rejected(): + client = flask_app.test_client() + assert client.get('/api/daily-ohlcv/db-summary?run=..').status_code == 400 + assert client.get('/api/daily-ohlcv/universe/preview?run=..').status_code == 400 + assert client.get('/api/daily-ohlcv/dataset/latest?run=..').status_code == 400 + assert client.get('/api/daily-ohlcv/prediction/latest?run=..').status_code == 400 + assert client.get('/api/daily-ohlcv/portfolio/latest?run=..').status_code == 400 + assert client.get('/api/daily-ohlcv/walk-forward/latest?run=..').status_code == 400 + assert client.get('/api/daily-ohlcv/registry/latest?run=..').status_code == 400 + assert client.get('/api/daily-ohlcv/charts/equity-overlay?run=..').status_code == 400 + assert client.get('/api/daily-ohlcv/charts/walk-forward-heatmap?run=..').status_code == 400 + for path in [ + '/api/daily-ohlcv/db-summary', + '/api/daily-ohlcv/universe/preview', + '/api/daily-ohlcv/progress', + '/api/daily-ohlcv/dataset/latest', + '/api/daily-ohlcv/prediction/latest', + '/api/daily-ohlcv/portfolio/latest', + '/api/daily-ohlcv/walk-forward/latest', + '/api/daily-ohlcv/registry/latest', + '/api/daily-ohlcv/gate/latest', + '/api/daily-ohlcv/charts/dataset', + '/api/daily-ohlcv/charts/prediction', + '/api/daily-ohlcv/charts/portfolio', + '/api/daily-ohlcv/charts/walk-forward', + '/api/daily-ohlcv/charts/decision-cockpit', + '/api/daily-ohlcv/charts/flow', + '/api/daily-ohlcv/charts/glossary', + '/api/daily-ohlcv/charts/research-diagnostics', + '/api/daily-ohlcv/charts/equity-overlay', + '/api/daily-ohlcv/charts/walk-forward-heatmap', + '/api/daily-ohlcv/charts/run-scatter', + '/api/daily-ohlcv/charts/universe-breakdown', + '/api/daily-ohlcv/charts/symbol/000250', + ]: + assert client.post(path).status_code == 405 + assert client.put(path).status_code == 405 + assert client.patch(path).status_code == 405 + assert client.delete(path).status_code == 405 + + +def test_daily_ohlcv_progress_and_model_surfaces_are_locked(): + client = flask_app.test_client() + progress = client.get('/api/daily-ohlcv/progress').get_json() + assert progress['overall_status'] == 'D0_D9_EVIDENCE_VISIBLE_MODEL_BUILD_NO_GO' + assert progress['model_build_allowed'] is False + assert progress['go_summary_allowed'] is False + statuses = {stage['id']: stage['status'] for stage in progress['stages']} + assert statuses['D0'] == 'PASS' + assert statuses['D1'] == 'WATCH' + assert statuses['D2'] == 'PASS' + assert statuses['D3'] == 'WATCH' + assert statuses['D4'] == 'RESEARCH_ONLY' + assert statuses['D5'] == 'NO-GO' + assert statuses['D6'] == 'PASS' + assert statuses['D7'] == 'WATCH' + assert statuses['D8'] == 'RESEARCH_ONLY_BLOCKED' + assert statuses['D9'] == 'BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER' + assert 'no live/broker/orders' in progress['guardrail'] + assert len(progress['page_usage_guide']) == 10 + assert progress['stages'][6]['usage_guide']['stage'] == 'D6' + assert progress['stages'][7]['usage_guide']['stage'] == 'D7' + assert '시각화 곡선을 수익 보장' in progress['stages'][6]['must_not'] + flow = client.get('/api/daily-ohlcv/charts/flow').get_json() + assert flow['nodes'][8]['severity'] == 'block' + assert flow['nodes'][9]['severity'] == 'block' + provenance = {row['id']: row for row in progress['provenance_matrix']} + assert {'D0', 'D1', 'D2', 'D3', 'D4', 'D5', 'D6', 'D7', 'D8', 'D9'} <= set(provenance) + assert 'UNKNOWN_CONFIRMED' in provenance['D0']['lock_labels'] + assert 'WATCH_HEURISTIC_UNIVERSE' in provenance['D1']['lock_labels'] + assert 'NO_FUTURE_LEAKAGE' in provenance['D2']['lock_labels'] + assert 'SHUFFLE_CONTROL' in provenance['D3']['lock_labels'] + assert 'RESEARCH_DIAGNOSTICS' in provenance['D7']['lock_labels'] + assert 'REGISTRY_HASHES' in provenance['D8']['lock_labels'] + assert 'PAPER_FORWARD_BLOCKED' in provenance['D9']['lock_labels'] + assert 'tests/test_stom_rl_daily_ohlcv_db.py' in progress['verification_commands']['D0'][0] + assert 'tests/test_stom_rl_daily_prediction.py' in progress['verification_commands']['D3'][0] + assert 'price_basis=unknown' in progress['stages'][0]['evidence'] + assert 'control=shuffle_control' in progress['stages'][3]['evidence'] + + gate = client.get('/api/daily-ohlcv/gate/latest').get_json() + assert gate['status'] == 'NO-GO' + assert gate['model_build_allowed'] is False + assert 'NO-GO/WATCH reasons' in gate['guardrail'] diff --git a/tests/test_daily_ohlcv_dashboard_tab.py b/tests/test_daily_ohlcv_dashboard_tab.py new file mode 100644 index 000000000..470df86b4 --- /dev/null +++ b/tests/test_daily_ohlcv_dashboard_tab.py @@ -0,0 +1,374 @@ +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC = REPO_ROOT / 'webui' / 'v2_src' / 'src' + + +def test_daily_ohlcv_tab_and_navigation_markers_present(): + app = (SRC / 'App.svelte').read_text(encoding='utf-8') + sidebar = (SRC / 'layout' / 'Sidebar.svelte').read_text(encoding='utf-8') + header = (SRC / 'layout' / 'Header.svelte').read_text(encoding='utf-8') + tab = (SRC / 'tabs' / 'DailyOhlcvTab.svelte').read_text(encoding='utf-8') + api = (SRC / 'lib' / 'dailyOhlcvApi.ts').read_text(encoding='utf-8') + guide = (SRC / 'tabs' / 'DailyRlGuideTab.svelte').read_text(encoding='utf-8') + + assert "DailyOhlcvTab" in app + assert "'/daily-ohlcv'" in app + assert "daily-ohlcv" in sidebar + assert "Daily OHLCV" in header + assert "data-daily-ohlcv-tab" in tab + assert "READ_ONLY" in tab + assert "no live/broker/orders" in tab + assert "/api/daily-ohlcv/db-summary" in api + assert "/api/daily-ohlcv/universe/preview" in api + assert "/api/daily-ohlcv/symbol/" in api + assert "/api/daily-ohlcv/dataset/latest" in api + assert "/api/daily-ohlcv/charts/dataset" in api + assert "/api/daily-ohlcv/prediction/latest" in api + assert "/api/daily-ohlcv/portfolio/latest" in api + assert "/api/daily-ohlcv/walk-forward/latest" in api + assert "/api/daily-ohlcv/charts/walk-forward" in api + assert "/api/daily-ohlcv/registry/latest" in api + assert "/api/daily-ohlcv/charts/decision-cockpit" in api + assert "/api/daily-ohlcv/scenarios" in api + assert "/api/daily-ohlcv/scenario-runs" in api + assert "/api/daily-ohlcv/rl-env-guide" in api + assert "/api/daily-ohlcv/research-workflows" in api + assert "/api/daily-ohlcv/charts/research-diagnostics" in api + assert "/api/daily-ohlcv/charts/equity-overlay" in api + assert "/api/daily-ohlcv/charts/walk-forward-heatmap" in api + assert "/api/daily-ohlcv/charts/run-scatter" in api + assert "/api/daily-ohlcv/charts/universe-breakdown" in api + assert "/api/daily-ohlcv/charts/symbol/" in api + assert "'/daily-rl-guide'" in app + assert "'/daily-ohlcv/rl-guide'" in app + assert "DailyModelResultsCard" in tab + assert "DailyVisualLabCard" in tab + assert "DailyScenarioLabCard" in tab + assert "DailyScenarioRunLedgerCard" in tab + assert "DailyRlGuideTab" in app + assert "일봉 RL 설명서" in sidebar + assert "일봉 RL 설명서" in header + assert "NO-GO/RESEARCH_ONLY" in tab + assert "data-daily-api-error" in tab + assert "API_UNAVAILABLE" in tab + assert "DailyDatasetBuilderCard" in tab + assert "data-daily-symbol-panel" in tab + assert "000250" in tab + + +def test_daily_ohlcv_cards_expose_guardrail_markers(): + progress = (SRC / 'tabs' / 'dailyOhlcv' / 'DailyProgressTimeline.svelte').read_text(encoding='utf-8') + guide = (SRC / 'tabs' / 'DailyRlGuideTab.svelte').read_text(encoding='utf-8') + api = (SRC / 'lib' / 'dailyOhlcvApi.ts').read_text(encoding='utf-8') + db_card = (SRC / 'tabs' / 'dailyOhlcv' / 'DailyDbQualityCard.svelte').read_text(encoding='utf-8') + universe_card = (SRC / 'tabs' / 'dailyOhlcv' / 'DailyUniverseCard.svelte').read_text(encoding='utf-8') + dataset_card = (SRC / 'tabs' / 'dailyOhlcv' / 'DailyDatasetBuilderCard.svelte').read_text(encoding='utf-8') + model_card = (SRC / 'tabs' / 'dailyOhlcv' / 'DailyModelResultsCard.svelte').read_text(encoding='utf-8') + visual_card = (SRC / 'tabs' / 'dailyOhlcv' / 'DailyVisualLabCard.svelte').read_text(encoding='utf-8') + scenario_card = (SRC / 'tabs' / 'dailyOhlcv' / 'DailyScenarioLabCard.svelte').read_text(encoding='utf-8') + scenario_run_card = (SRC / 'tabs' / 'dailyOhlcv' / 'DailyScenarioRunLedgerCard.svelte').read_text(encoding='utf-8') + progress_without_comments = progress.replace("", "") + + assert "data-daily-ohlcv-progress" in progress + assert "NOT_STARTED" in progress_without_comments + assert "D0-D9 Progress" in progress + assert "data-daily-progress-empty" in progress + assert "data-daily-d0-d9-provenance-matrix" in progress + assert "lock_labels" in progress + assert "verification_commands" in progress + assert "D0-D9 provenance" in progress + assert "command-list" in progress + assert "slice(0, 4)" not in progress + assert "data-daily-ohlcv-db-card" in db_card + assert "price_basis" in db_card + assert "price_basis_status" in db_card + assert "decision_grade_return_status" in db_card + assert "UNKNOWN_CONFIRMED" in db_card + assert "material_unknown_adjustment_windows" in db_card + assert "data-daily-price-basis-usage" in db_card + assert "price_basis_user_guidance" in db_card + assert "price_basis_required_evidence" in db_card + assert "data-daily-ohlcv-universe-card" in universe_card + assert "WATCH_HEURISTIC_UNIVERSE" in universe_card + assert "unmatched_quarantine_count" in universe_card + assert "official_metadata_status" in universe_card + assert "official_metadata_coverage_status" in universe_card + assert "universe_certification_status" in universe_card + assert "quarantine_artifact_count" in universe_card + assert "code,name,market,instrument_type" in universe_card + assert "data-daily-universe-review-contract" in universe_card + assert "data-daily-universe-user-guidance" in universe_card + assert "model_build_or_candidate_promotion" in universe_card + assert "data-daily-symbol-drilldown" in universe_card + assert "data-daily-ohlcv-dataset-card" in dataset_card + assert "leakage_status" in dataset_card + assert "split_chronology_status" in dataset_card + assert "no training/order/live/profit" in dataset_card + assert "data-daily-dataset-upstream-blockers" in dataset_card + assert "data-daily-dataset-user-guidance" in dataset_card + assert "upstream_gate_blockers" in dataset_card + assert "dataset_blocked_uses" in dataset_card + assert "universe_certification_status" in dataset_card + assert "DATASET_RESEARCH_PREVIEW_BLOCKED_BY_MISSING_READINESS_EVIDENCE" in dataset_card + assert "DATASET_RESEARCH_READY_WITH_WATCH_GUARDRAILS" not in dataset_card + assert "data-daily-dataset-leakage-detail" in dataset_card + assert "data-daily-dataset-normalization-detail" in dataset_card + assert "forbidden_feature_columns" in dataset_card + assert "fit_split" in dataset_card + assert "fit_row_count" in dataset_card + assert "data-daily-dataset-manifest-detail" in dataset_card + assert "data-daily-dataset-split-intervals" in dataset_card + assert "data-daily-dataset-blocked-windows" in dataset_card + assert "manifest_sha" in dataset_card + assert "universe_manifest_sha" in dataset_card + assert "purge_days" in dataset_card + assert "embargo_days" in dataset_card + assert "data-daily-model-results-card" in model_card + assert "data-daily-prediction-chart" in model_card + assert "data-daily-portfolio-chart" in model_card + assert "data-daily-walk-forward-chart" in model_card + assert "data-daily-decision-panel" in model_card + assert "RESEARCH_ONLY" in model_card + assert "NO-GO" in model_card + assert "PRICE_BASIS_UNKNOWN" in model_card + assert "UNIVERSE_WATCH_HEURISTIC" in model_card + assert "model_build_allowed" in model_card + assert "go_summary_allowed" in model_card + assert "shuffle_control_strategy" in model_card + assert "best_rule_baseline_strategy" in model_card + assert "best_supervised_delta_vs_shuffle_control" in model_card + assert "data-daily-d3-cost-assumption" in model_card + assert "data-daily-d3-gate-blockers" in model_card + assert "data-daily-d3-user-guidance" in model_card + assert "D3_BASELINE_WATCH_RESEARCH_ONLY" in model_card + assert "D0_PRICE_BASIS_NOT_VERIFIED" in model_card + assert "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED" in model_card + assert "D5_WALK_FORWARD_NOT_PASS" in model_card + assert "D3_WATCH_RESEARCH_ONLY" in model_card + assert "model_build_allowed" in model_card + assert "paper_forward_allowed" in model_card + assert "live_broker_order_allowed" in model_card + assert "data-daily-walk-forward-readiness" in model_card + assert "D5_NO_GO_RESEARCH_ONLY_GATE" in model_card + assert "data-daily-walk-forward-provenance-hashes" in model_card + assert "fold_metrics=" in model_card + assert "predictions=" in model_card + assert "D4=" in model_card + assert "model_build_or_candidate_promotion" in model_card + assert "sha256(date:code)_ascending" in model_card + assert "data-daily-rl-readiness" in model_card + assert "D4_RESEARCH_ONLY_DIAGNOSTICS" in model_card + assert "joinedFlagText" in model_card + assert "d4ModelFlag" in model_card + assert "d4GoFlag" in model_card + assert "d5ModelFlag" in model_card + assert "D5_REASONS_MISSING_OR_STALE" in model_card + assert "D4_STATE_CONTRACT_EVIDENCE_MISSING_OR_STALE" in model_card + assert "MISSING_D4_OBSERVATION_STATE_MANIFEST_GATE" in model_card + assert "D3-D5 Model Evidence" in model_card + assert "data-daily-rl-provenance-hashes" in model_card + assert "prediction_manifest_sha" in model_card + assert "portfolioPredictionHashes.predictions" in model_card + assert "portfolioArtifactHashes.policy_nav" in model_card + assert "data-daily-rl-training-status" in model_card + assert "data-daily-rl-invalid-action-rate" in model_card + assert "invalid_action_rate" in model_card + assert "data-daily-rl-learning-curve" in model_card + assert "data-daily-rl-state-contract" in model_card + assert "data-daily-rl-state-observations" in model_card + assert "data-daily-rl-leakage-checks" in model_card + assert "D4_OBSERVATION_STATE_MANIFEST" in model_card + assert "reward_action_telemetry_sufficient_for_d4" in model_card + assert "future_label_exposed" in model_card + assert "data-daily-rl-reward-return-curve" in model_card + assert "data-daily-rl-action-distribution" in model_card + assert "data-daily-rl-invalid-actions" in model_card + assert "data-daily-rl-reward-components" in model_card + assert "data-daily-rl-turnover-drawdown" in model_card + assert "invalid_action_penalty" in model_card + assert "exposure_penalty" in model_card + assert "concentration_penalty" in model_card + assert "invalid={String(row.invalid_action)}" in model_card + assert "data-daily-rl-policy-baseline-comparison" in model_card + assert "data-daily-rl-portfolio-trajectory" in model_card + assert "frozen_baseline_delta" in model_card + assert "baseline_delta_total_net_return" in model_card + assert "RESEARCH_ONLY diagnostics" in model_card + assert "data-daily-walk-forward-d4-state-contract" in model_card + assert "data-daily-walk-forward-controls" in model_card + assert "d4_reward_action_ablation_rows" in model_card + assert "d4_source_hash_count" in model_card + assert "min_required_purge_days" in model_card + assert "data-daily-walk-forward-cost-sensitivity" in model_card + assert "data-daily-walk-forward-fold-windows" in model_card + assert "D4_OBSERVATION_STATE_MANIFEST_CONSUMED" in model_card + assert "walkForwardChart?.d4_state_contract_artifacts_consumed === true" in model_card + assert "positiveNumber(d5StateObservationRowCount)" in model_card + assert "positiveNumber(d5RewardActionAblationRowCount)" in model_card + assert "positiveNumber(d5SourceHashCount)" in model_card + assert "D4_STATE_CONTRACT_EVIDENCE_MISSING_OR_STALE', ...d5ArtifactIssues.map" in model_card + assert "no_oos_retuning" in model_card + assert "purge/embargo" in model_card + assert "0/23/46bp sensitivity" in model_card + assert "data-daily-scenario-lab-card" in scenario_card + assert "SCENARIO_GENERATOR_MVP" in scenario_card + assert "model_run_generation_available" in scenario_card + assert "data-daily-scenario-grid" in scenario_card + assert "data-daily-scenario-model-contract" in scenario_card + assert "cost_23bp_current_evidence" in scenario_card + assert "scenario_manifest.json" in scenario_card + assert "fresh_oos_walk_forward_manifest.json" in scenario_card + assert "no live/broker/orders" in scenario_card + assert "data-daily-scenario-run-ledger" in scenario_run_card + assert "SCENARIO_BATCH_RUNNER_MVP" in scenario_run_card + assert "stom_rl.daily_scenario_batch" in scenario_run_card + assert "scenario_batch_manifest.json" in scenario_run_card + assert "data-daily-scenario-batch-grid" in scenario_run_card + assert "data-daily-scenario-run-grid" in scenario_run_card + assert "comparison_rows" in scenario_run_card + assert "model_run_generation_available" in scenario_run_card + assert "no live/broker/orders" in scenario_run_card + assert "data-daily-rl-guide-tab" in guide + assert "RL_ENV_VISUAL_GUIDE_MVP" in guide + assert "data-daily-rl-env-visual-map" in guide + assert "data-daily-rl-loop-diagram" in guide + assert "data-daily-rl-process-storyboard" in guide + assert "일봉 강화학습 환경 순환 다이어그램" in guide + assert "Agent ↔ Environment ↔ Reward" in guide + assert "상태 → 행동 → 보상 → 검증" in guide + assert "data-daily-rl-performance-example" in guide + assert "data-daily-rl-performance-pnl" in guide + assert "data-daily-rl-learning-curve-visual" in guide + assert "data-daily-rl-nav-preview" in guide + assert "학습 성과 예시: 수익금" in guide + assert "RESEARCH_ONLY_PERFORMANCE_DIAGNOSTIC" in guide + assert "data-daily-rl-research-process-selector" in guide + assert "data-daily-rl-process-lane-picker" in guide + assert "data-daily-rl-selected-process-detail" in guide + assert "data-daily-rl-research-limitations" in guide + assert "data-daily-rl-ai-guidance-format" in guide + assert "D4_RL_RISK_OVERLAY" in guide + assert "AI-readable / AI 개선 지시 고정 포맷" in guide + assert "AI Agent" in guide + assert "data-daily-rl-scenario-generator" in guide + assert "data-daily-rl-workflow-center" in guide + assert "data-daily-rl-workflow-picker" in guide + assert "data-daily-rl-workflow-inspector" in guide + assert "data-daily-rl-workflow-safe-config-preview" in guide + assert "APPROVAL_GATED_INTENT_RECORD_ONLY" in guide + assert "data-daily-rl-approval-trigger-surface" in guide + assert "data-daily-rl-intent-ledger" in guide + assert "researchJobIntents" in api + assert "/api/daily-ohlcv/research-jobs" in api + assert "data-daily-rl-rejection-analytics" in guide + assert "rejectionAnalytics" in api + assert "/api/daily-ohlcv/rejection-analytics" in api + assert "False-negative review queue" in guide + assert "promotion_allowed" in guide + assert "HYPOTHESIS_REJECTION_AUDIT" in guide + assert "data-daily-rl-final-completion-report" in guide + assert "NON_LIVE_RESEARCH_PLATFORM_COMPLETE" in guide + assert "non_live_goal_completion_pct" in guide + assert "live_trading_readiness_pct" in guide + assert "model_build_readiness_pct" in guide + assert "paper_forward_readiness_pct" in guide + assert "비실거래 연구 플랫폼 완료 성과" in guide + assert "실거래·브로커 주문·페이퍼 포워드·모델 빌드·수익성 주장은 계속 0%/blocked" in guide + assert "data-daily-rl-scenario-plan-json" in guide + assert "D3_D4_SIGNAL_QUALITY_AUDIT" in guide + assert "PAST_ONLY_MARKET_REGIME_AUDIT" in guide + assert "data-daily-rl-signal-quality-integration" in guide + assert "MISSING_SIGNAL_QUALITY_AUDIT" in guide + assert "data-daily-rl-scenario-comparison" in guide + assert "data-daily-rl-market-regime-readiness" in guide + assert "data-daily-rl-improvement-queue" in guide + assert "data-daily-rl-page-maturity-report" in guide + assert "scenario_platform_maturity_pct" in guide + assert "live_trading_readiness_pct" in guide + assert "MISSING_POLICY_ARTIFACT" in guide + assert "data-daily-rl-realtime-visualizer" in guide + assert "data-daily-rl-policy-network-visual" in guide + assert "data-daily-rl-action-probability-bars" in guide + assert "저장된 산출물로 움직이는 강화학습 리플레이" in guide + assert "Artifact-backed RL Replay" in guide + assert "fake" not in guide.lower() + assert "data-daily-rl-env-elements" in guide + assert "data-daily-rl-state-action-reward" in guide + assert "data-daily-rl-env-checks" in guide + assert "position_count" in guide + assert "top_score_bucket" in guide + assert "hold · buy · add · sell · reduce" in guide + assert "future_return_1d" in guide + assert "no live/broker/orders" in guide + assert "data-daily-visual-lab-card" in visual_card + assert "data-daily-d6-d7-usage-guide" in visual_card + assert "data-daily-page-progress-guide" in visual_card + assert "D6는 증거를 읽는 화면" in visual_card + assert "D7은 실패 원인과 다음 가설" in visual_card + assert "data-daily-decision-cockpit" in visual_card + assert "data-daily-flow-map" in visual_card + assert "data-daily-metric-glossary" in visual_card + assert "data-daily-research-diagnostics" in visual_card + assert "D7 Research Diagnostics" in visual_card + assert "D7_FEATURE_DIAGNOSTICS" in visual_card + assert "D7_REGIME_DIAGNOSTICS" in visual_card + assert "D7_CORRELATION_RISK" in visual_card + assert "D7_FAILURE_ANALYSIS" in visual_card + assert "PLACEHOLDER_READY" in visual_card + assert "D7_FALLBACK_DIAGNOSTICS" in visual_card + assert "feature_importance_by_fold.csv" in visual_card + assert "failure_reason_attribution.csv" in visual_card + assert "allowed_use" in visual_card + assert "blocked_use" in visual_card + assert "how_to_read_ko" in visual_card + assert "current_gap" in visual_card + assert "feature별 fold 기여도와 drift" in visual_card + assert "실패 fold를 숨기거나 성공 fold만 골라" in visual_card + assert "correlation_cluster_summary.csv가 생성되기 전까지 PLACEHOLDER_READY" in visual_card + assert "mergeD7DiagnosticCard" in visual_card + assert "allowed_use: card.allowed_use || fallback.allowed_use" in visual_card + assert "blocked_use: card.blocked_use || fallback.blocked_use" in visual_card + assert "how_to_read_ko: card.how_to_read_ko || fallback.how_to_read_ko" in visual_card + assert "current_gap: card.current_gap || fallback.current_gap" in visual_card + assert "next_artifact: card.next_artifact || fallback.next_artifact" in visual_card + assert "guardrail: card.guardrail || fallback.guardrail" in visual_card + assert "summary: card.summary || fallback.summary" in visual_card + assert "rows(decision?.blockers, 4)" not in visual_card + assert "data-daily-equity-overlay" in visual_card + assert "data-daily-walk-forward-heatmap" in visual_card + assert "data-daily-run-scatter" in visual_card + assert "data-daily-universe-breakdown" in visual_card + assert "data-daily-symbol-chart" in visual_card + assert "data-daily-symbol-usage-guide" in visual_card + assert "symbolUsageGuideRows" in visual_card + assert "data-daily-registry-paper-forward" in visual_card + assert "promotion_status" in visual_card + assert "paper_forward_allowed" in visual_card + assert "live_broker_order_allowed" in visual_card + assert "no_live_broker_order_readiness" in visual_card + assert "data-daily-registry-effective-gates" in visual_card + assert "effective_gate_blockers" in visual_card + assert "invariant_errors" in visual_card + assert "registryEvidenceText" in visual_card + assert "MISSING_EFFECTIVE_GATE_EVIDENCE" in visual_card + assert "MISSING_INVARIANT_EVIDENCE" in visual_card + assert "registryRows" in visual_card + assert "registryBlockScore" in visual_card + assert "data-daily-registry-hidden-counts" in visual_card + assert "registryFlagText" in visual_card + assert "MISSING_REGISTRY_FLAG_UNSAFE" in visual_card + assert "MISSING_SAMPLE_EVIDENCE" in visual_card + assert "items.length === 0" in visual_card + assert "drift_hidden" in visual_card + assert "registryRows(registry?.samples?.drift" in visual_card + assert "row.metric ?? row.evidence_status" in visual_card + assert "registry?.model_build_allowed ?? false" not in visual_card + assert "paper_forward_drawdown" in visual_card + assert "research_policy_nav_not_live_account" in visual_card + assert "INCOMPLETE_NUMERIC_EVIDENCE" in visual_card + assert "finiteNumber" in visual_card + assert "const asNumber" not in visual_card + assert "config_hash" in visual_card + assert "STOM-inspired Visual Lab" in visual_card + assert "수익 보장·실거래·브로커·주문 준비 상태가 아닙니다" in visual_card diff --git a/tests/test_kronos_regression.py b/tests/test_kronos_regression.py index 7fccbffd7..7b20fc558 100644 --- a/tests/test_kronos_regression.py +++ b/tests/test_kronos_regression.py @@ -1,12 +1,36 @@ +import os import random +import subprocess +import sys from pathlib import Path import numpy as np import pandas as pd import pytest -import torch from tqdm import tqdm +if os.name == "nt" and os.environ.get("KRONOS_RUN_TORCH_TESTS") != "1": + pytest.skip( + "torch regression tests are opt-in on Windows; set KRONOS_RUN_TORCH_TESTS=1 " + "in a verified PyTorch environment to run them", + allow_module_level=True, + ) + +_TORCH_PROBE = subprocess.run( + [sys.executable, "-c", "import torch"], + capture_output=True, + text=True, + timeout=30, +) +if _TORCH_PROBE.returncode != 0: + pytest.skip( + "torch is installed but cannot be initialized in this environment: " + f"{(_TORCH_PROBE.stderr or _TORCH_PROBE.stdout).strip()}", + allow_module_level=True, + ) + +import torch + from model import Kronos, KronosPredictor, KronosTokenizer TEST_DATA_ROOT = Path(__file__).parent / "data" diff --git a/tests/test_predictor_handoff.py b/tests/test_predictor_handoff.py new file mode 100644 index 000000000..300454884 --- /dev/null +++ b/tests/test_predictor_handoff.py @@ -0,0 +1,48 @@ +from pathlib import Path + +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +FINETUNE_DIR = PROJECT_ROOT / "finetune" + + +def test_predictor_handoff_applies_safe_overrides(tmp_path, monkeypatch): + import sys + + if str(FINETUNE_DIR) not in sys.path: + sys.path.insert(0, str(FINETUNE_DIR)) + + from predictor_handoff import apply_predictor_handoff_overrides + + handoff_path = tmp_path / "predictor_handoff_overrides.json" + handoff_path.write_text( + '{"enabled": true, "batch_size": 16, "num_workers": 2, "note": "ignored"}', + encoding="utf-8", + ) + monkeypatch.setenv("KRONOS_PREDICTOR_HANDOFF_OVERRIDES", str(handoff_path)) + config = {"save_path": str(tmp_path), "batch_size": 4, "num_workers": 0} + + result = apply_predictor_handoff_overrides(config) + + assert config["batch_size"] == 16 + assert config["num_workers"] == 2 + assert result["applied"] == {"batch_size": 16, "num_workers": 2} + assert result["ignored"] == {"note": "ignored"} + assert config["predictor_handoff_overrides"]["exists"] is True + + +def test_predictor_handoff_rejects_invalid_batch_size(tmp_path, monkeypatch): + import sys + + if str(FINETUNE_DIR) not in sys.path: + sys.path.insert(0, str(FINETUNE_DIR)) + + from predictor_handoff import apply_predictor_handoff_overrides + + handoff_path = tmp_path / "predictor_handoff_overrides.json" + handoff_path.write_text('{"batch_size": 0}', encoding="utf-8") + monkeypatch.setenv("KRONOS_PREDICTOR_HANDOFF_OVERRIDES", str(handoff_path)) + + with pytest.raises(ValueError, match="batch_size"): + apply_predictor_handoff_overrides({"save_path": str(tmp_path), "batch_size": 4}) diff --git a/tests/test_stom_1s_checkpoint_eval.py b/tests/test_stom_1s_checkpoint_eval.py new file mode 100644 index 000000000..40174ef34 --- /dev/null +++ b/tests/test_stom_1s_checkpoint_eval.py @@ -0,0 +1,168 @@ +import pickle +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import pandas as pd + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +FINETUNE_DIR = PROJECT_ROOT / "finetune" +if str(FINETUNE_DIR) not in sys.path: + sys.path.insert(0, str(FINETUNE_DIR)) + +from evaluate_stom_1s_checkpoint import ( # noqa: E402 + load_pickle_dataset, + persistence_predictions, + random_direction_predictions, + rows_from_predictions, + select_aligned_windows, + summarize_prediction_frame, + write_prediction_artifacts, +) +from search_stom_1s_filters import ( # noqa: E402 + rolling_validate_filters, + search_filters, + write_filter_report, + write_rolling_filter_report, +) + + +def _frame(rows: int = 12) -> pd.DataFrame: + start = datetime(2026, 1, 2, 9, 0, 0) + idx = [start + timedelta(seconds=i) for i in range(rows)] + return pd.DataFrame( + { + "open": [100 + i for i in range(rows)], + "high": [100 + i for i in range(rows)], + "low": [100 + i for i in range(rows)], + "close": [100 + i for i in range(rows)], + "vol": [10 + i for i in range(rows)], + "amt": [(100 + i) * (10 + i) for i in range(rows)], + }, + index=pd.DatetimeIndex(idx, name="datetime"), + ) + + +def test_checkpoint_eval_baselines_write_dashboard_compatible_artifacts(tmp_path): + dataset = tmp_path / "processed_datasets" + dataset.mkdir() + payload = { + "KR000001_20260102": _frame(), + "KR000002_20260102": _frame(), + } + with (dataset / "test_data.pkl").open("wb") as f: + pickle.dump(payload, f) + + data = load_pickle_dataset(dataset) + windows = select_aligned_windows(data, lookback_window=4, predict_window=3, max_symbols=2) + persistence_df = rows_from_predictions(windows, persistence_predictions(windows), mode="persistence") + random_df = rows_from_predictions(windows, random_direction_predictions(windows), mode="random") + result = write_prediction_artifacts( + {"persistence": persistence_df, "random": random_df}, + tmp_path / "predictions", + "unit_eval", + top_k=1, + ) + + assert len(windows) == 2 + assert persistence_df["symbol"].iloc[0] == "KR000001" + assert persistence_df["horizon_step"].max() == 3 + assert summarize_prediction_frame(persistence_df)["windows"] == 2 + assert Path(result["files"]["persistence"]).exists() + assert Path(result["comparison_path"]).exists() + assert result["metrics"]["random"]["topk"]["k"] == 1 + assert "history_mean_amount" in persistence_df.columns + assert "pred_path_consistency" in persistence_df.columns + + +def test_filter_search_reports_best_filter_from_prediction_csv(tmp_path): + rows = [] + for idx in range(6): + for step in range(1, 4): + rows.append( + { + "window_id": idx, + "symbol": f"KR{idx:06d}", + "session": "20260102", + "asof_timestamp": "2026-01-02T09:05:00" if idx < 3 else "2026-01-03T09:05:00", + "target_timestamp": f"2026-01-02T09:05:{step:02d}", + "horizon_step": step, + "actual_close_t0": 100, + "pred_close": 101 + idx, + "actual_close": 100 + idx, + "error": 1, + "abs_error": 1, + "pred_return_window": 0.2 if idx % 2 == 0 else -0.1, + "actual_return_window": 0.4 if idx % 2 == 0 else -0.2, + "direction_hit_window": 1, + "pred_path_consistency": 1.0 if idx % 2 == 0 else 0.4, + "pred_range_pct": 0.05, + "history_mean_amount": 1000 + idx * 100, + "history_volatility_pct": 0.05, + "mode": "kronos", + } + ) + csv_path = tmp_path / "pred.csv" + pd.DataFrame(rows).to_csv(csv_path, index=False, encoding="utf-8-sig") + + result = search_filters(csv_path, top_k=2, min_trades=1, min_periods=1, min_coverage=0.1) + result = write_filter_report(result, tmp_path, "unit") + + assert result["best_filter"]["trade_count"] >= 1 + assert "filter_search.json" in result["artifact_paths"]["json"] + assert Path(result["artifact_paths"]["csv"]).exists() + + +def test_rolling_filter_validation_uses_train_periods_then_tests_forward(tmp_path): + rows = [] + window_id = 0 + for period in range(8): + asof = datetime(2026, 1, 2, 9, 5, 0) + timedelta(minutes=period) + for symbol_idx in range(4): + pred_return = 0.2 if symbol_idx < 2 else -0.1 + actual_return = 0.3 if (period + symbol_idx) % 3 != 0 else -0.2 + for step in range(1, 4): + rows.append( + { + "window_id": window_id, + "symbol": f"KR{symbol_idx:06d}", + "session": "20260102", + "asof_timestamp": asof.isoformat(), + "target_timestamp": (asof + timedelta(seconds=step)).isoformat(), + "horizon_step": step, + "actual_close_t0": 100, + "pred_close": 100 + pred_return, + "actual_close": 100 + actual_return, + "error": 0, + "abs_error": 0, + "pred_return_window": pred_return, + "actual_return_window": actual_return, + "direction_hit_window": int(pred_return * actual_return > 0), + "pred_path_consistency": 1.0 if pred_return > 0 else 0.4, + "pred_range_pct": 0.05, + "history_mean_amount": 1000 + symbol_idx * 100, + "history_volatility_pct": 0.05, + "mode": "kronos", + } + ) + window_id += 1 + csv_path = tmp_path / "rolling_pred.csv" + pd.DataFrame(rows).to_csv(csv_path, index=False, encoding="utf-8-sig") + + result = rolling_validate_filters( + csv_path, + top_k=2, + min_trades=1, + min_periods=1, + min_coverage=0.1, + train_periods=4, + test_periods=2, + step_periods=2, + ) + result = write_rolling_filter_report(result, tmp_path, "rolling_unit") + + assert result["summary"]["fold_count"] == 2 + assert "overfit_gap_pct" in result["summary"] + assert result["folds"][0]["test_trade_count"] >= 1 + assert "rolling_filter_validation.json" in result["artifact_paths"]["json"] diff --git a/tests/test_stom_1s_finetune_runner.py b/tests/test_stom_1s_finetune_runner.py new file mode 100644 index 000000000..1eb0ca9a5 --- /dev/null +++ b/tests/test_stom_1s_finetune_runner.py @@ -0,0 +1,329 @@ +import json +import os +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pytest + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +FINETUNE_DIR = PROJECT_ROOT / "finetune" +if str(FINETUNE_DIR) not in sys.path: + sys.path.insert(0, str(FINETUNE_DIR)) + +from run_stom_1s_finetune import build_run, execute_run, parse_args, sample_stage_budget # noqa: E402 +from model_source import resolve_model_source # noqa: E402 + + +def _skip_if_torch_cannot_initialize() -> None: + if os.name == "nt" and os.environ.get("KRONOS_RUN_TORCH_TESTS") != "1": + pytest.skip( + "torch-dependent dataset test is opt-in on Windows; set KRONOS_RUN_TORCH_TESTS=1 " + "in a verified PyTorch environment to run it" + ) + probe = subprocess.run( + [sys.executable, "-c", "import torch"], + capture_output=True, + text=True, + timeout=30, + ) + if probe.returncode != 0: + pytest.skip( + "torch is installed but cannot be initialized in this environment: " + f"{(probe.stderr or probe.stdout).strip()}" + ) + + +def test_resolve_model_source_falls_back_to_hf_identifier_for_missing_local_path(): + source = resolve_model_source( + "outputs/missing/tokenizer/checkpoints/best_model", + "NeoQuasar/Kronos-Tokenizer-base", + "Tokenizer", + ) + + assert source == "NeoQuasar/Kronos-Tokenizer-base" + + +def test_runner_dry_run_records_reproducible_env(tmp_path): + dataset_dir = tmp_path / "processed_datasets" + dataset_dir.mkdir() + (dataset_dir / "train_data.pkl").write_bytes(b"not loaded in dry-run") + (dataset_dir / "val_data.pkl").write_bytes(b"not loaded in dry-run") + + args = parse_args( + [ + "--horizon", + "30", + "--mode", + "smoke", + "--dataset-dir", + str(dataset_dir), + "--output-root", + str(tmp_path / "outputs"), + "--run-name", + "unit_smoke", + "--dry-run", + ] + ) + spec = build_run(30, args, "smoke") + result = execute_run(spec, dry_run=True) + + manifest = Path(result["manifest_path"]) + assert manifest.exists() + payload = json.loads(manifest.read_text(encoding="utf-8")) + assert payload["status"] == "dry_run" + assert Path(payload["progress_path"]).exists() + assert Path(payload["stdout_log"]).exists() + assert "dry-run only" in Path(payload["stdout_log"]).read_text(encoding="utf-8") + progress_payload = json.loads(Path(payload["progress_path"]).read_text(encoding="utf-8")) + assert progress_payload["status"] == "dry_run" + assert progress_payload["train_stage"] == "predictor" + assert payload["env_overrides"]["KRONOS_DATASET_PATH"] == str(dataset_dir.resolve()) + assert payload["env_overrides"]["KRONOS_PREDICT_WINDOW"] == "30" + assert payload["env_overrides"]["KRONOS_USE_COMET"] == "0" + assert payload["env_overrides"]["KRONOS_DDP_BACKEND"] == "gloo" + assert payload["env_overrides"]["USE_LIBUV"] == "0" + assert payload["env_overrides"]["PYTHONUNBUFFERED"] == "1" + assert payload["env_overrides"]["WORLD_SIZE"] == "1" + assert payload["env_overrides"]["KRONOS_DISABLE_DDP"] == "1" + assert payload["command"][-1].endswith("train_predictor.py") + + +def test_sample_stage_sets_staged_full_training_budget(tmp_path): + dataset_dir = tmp_path / "processed_datasets" + dataset_dir.mkdir() + (dataset_dir / "train_data.pkl").write_bytes(b"not loaded in dry-run") + (dataset_dir / "val_data.pkl").write_bytes(b"not loaded in dry-run") + + args = parse_args( + [ + "--horizon", + "60", + "--mode", + "full", + "--sample-stage", + "expand_200k", + "--dataset-dir", + str(dataset_dir), + "--output-root", + str(tmp_path / "outputs"), + "--dry-run", + ] + ) + spec = build_run(60, args, "full") + + assert spec["sample_stage"] == "expand_200k" + assert spec["run_name"] == "stom_1s_grid_pred60_expand_200k" + assert spec["target_train_samples"] == 200_000 + assert spec["target_val_samples"] == 40_000 + assert spec["env"]["KRONOS_N_TRAIN_ITER"] == "200000" + assert spec["env"]["KRONOS_N_VAL_ITER"] == "40000" + + +def test_runner_can_build_tokenizer_stage_and_both_stage_handoff(tmp_path): + dataset_dir = tmp_path / "processed_datasets" + dataset_dir.mkdir() + (dataset_dir / "train_data.pkl").write_bytes(b"not loaded in dry-run") + (dataset_dir / "val_data.pkl").write_bytes(b"not loaded in dry-run") + + args = parse_args( + [ + "--horizon", + "60", + "--mode", + "smoke", + "--train-stage", + "both", + "--dataset-dir", + str(dataset_dir), + "--output-root", + str(tmp_path / "outputs"), + "--run-name", + "official_smoke", + "--dataset-sample-mode", + "full_sequential", + "--dry-run", + ] + ) + + tokenizer_spec = build_run(60, args, "smoke", train_stage="tokenizer") + predictor_spec = build_run(60, args, "smoke", train_stage="predictor") + + assert tokenizer_spec["train_stage"] == "tokenizer" + assert tokenizer_spec["manifest_path"].endswith("tokenizer_run_manifest.json") + assert tokenizer_spec["command"][-1].endswith("train_tokenizer.py") + assert tokenizer_spec["env"]["KRONOS_DATASET_SAMPLE_MODE"] == "full_sequential" + assert tokenizer_spec["env"]["KRONOS_TOKENIZER_VAL_BATCH_SIZE"] == "1" + assert tokenizer_spec["env"]["KRONOS_TOKENIZER_SAVE_PRE_VAL_CHECKPOINT"] == "1" + assert predictor_spec["command"][-1].endswith("train_predictor.py") + assert "KRONOS_TOKENIZER_VAL_BATCH_SIZE" not in predictor_spec["env"] + assert predictor_spec["env"]["KRONOS_FINETUNED_TOKENIZER_PATH"].endswith( + "official_smoke\\finetune_tokenizer\\checkpoints\\best_model" + ) + assert predictor_spec["tokenizer_handoff_source"] == "best_model_expected" + + +def test_both_stage_can_use_predictor_efficiency_overrides(tmp_path): + dataset_dir = tmp_path / "processed_datasets" + dataset_dir.mkdir() + (dataset_dir / "train_data.pkl").write_bytes(b"not loaded in dry-run") + (dataset_dir / "val_data.pkl").write_bytes(b"not loaded in dry-run") + + args = parse_args( + [ + "--horizon", + "60", + "--mode", + "full", + "--train-stage", + "both", + "--dataset-dir", + str(dataset_dir), + "--output-root", + str(tmp_path / "outputs"), + "--run-name", + "efficiency_handoff", + "--batch-size", + "4", + "--num-workers", + "0", + "--predictor-batch-size", + "16", + "--predictor-num-workers", + "2", + "--dry-run", + ] + ) + + tokenizer_spec = build_run(60, args, "full", train_stage="tokenizer") + predictor_spec = build_run(60, args, "full", train_stage="predictor") + + assert tokenizer_spec["env"]["KRONOS_BATCH_SIZE"] == "4" + assert tokenizer_spec["env"]["KRONOS_TOKENIZER_VAL_BATCH_SIZE"] == "1" + assert tokenizer_spec["env"]["KRONOS_NUM_WORKERS"] == "0" + assert predictor_spec["env"]["KRONOS_BATCH_SIZE"] == "16" + assert predictor_spec["env"]["KRONOS_NUM_WORKERS"] == "2" + assert predictor_spec["env"]["KRONOS_FINETUNED_TOKENIZER_PATH"].endswith( + "efficiency_handoff\\finetune_tokenizer\\checkpoints\\best_model" + ) + + +def test_both_stage_predictor_handoff_falls_back_to_latest_train_model(tmp_path): + dataset_dir = tmp_path / "processed_datasets" + dataset_dir.mkdir() + (dataset_dir / "train_data.pkl").write_bytes(b"not loaded in dry-run") + (dataset_dir / "val_data.pkl").write_bytes(b"not loaded in dry-run") + latest = ( + tmp_path + / "outputs" + / "resume_handoff" + / "finetune_tokenizer" + / "checkpoints" + / "latest_train_model" + ) + latest.mkdir(parents=True) + (latest / "model.safetensors").write_bytes(b"weights") + + args = parse_args( + [ + "--horizon", + "60", + "--mode", + "full", + "--train-stage", + "both", + "--start-stage", + "predictor", + "--dataset-dir", + str(dataset_dir), + "--output-root", + str(tmp_path / "outputs"), + "--run-name", + "resume_handoff", + "--dry-run", + ] + ) + + predictor_spec = build_run(60, args, "full", train_stage="predictor") + + assert predictor_spec["stage_index"] == 2 + assert predictor_spec["stage_count"] == 2 + assert predictor_spec["tokenizer_handoff_source"] == "latest_train_model" + assert predictor_spec["env"]["KRONOS_FINETUNED_TOKENIZER_PATH"].endswith( + "resume_handoff\\finetune_tokenizer\\checkpoints\\latest_train_model" + ) + + +def test_tokenizer_validation_batch_size_can_be_overridden(tmp_path): + dataset_dir = tmp_path / "processed_datasets" + dataset_dir.mkdir() + (dataset_dir / "train_data.pkl").write_bytes(b"not loaded in dry-run") + (dataset_dir / "val_data.pkl").write_bytes(b"not loaded in dry-run") + + args = parse_args( + [ + "--horizon", + "60", + "--mode", + "full", + "--train-stage", + "tokenizer", + "--dataset-dir", + str(dataset_dir), + "--output-root", + str(tmp_path / "outputs"), + "--tokenizer-batch-size", + "4", + "--tokenizer-val-batch-size", + "2", + "--dry-run", + ] + ) + + tokenizer_spec = build_run(60, args, "full", train_stage="tokenizer") + + assert tokenizer_spec["env"]["KRONOS_BATCH_SIZE"] == "4" + assert tokenizer_spec["env"]["KRONOS_TOKENIZER_VAL_BATCH_SIZE"] == "2" + + +def test_full_window_sample_stage_uses_known_full_sample_pool(): + assert sample_stage_budget("full_window", 30) == {"train": 75_277_195, "val": 16_275_307} + assert sample_stage_budget("full_window", 60) == {"train": 73_718_875, "val": 15_938_107} + + +def test_full_sequential_dataset_uses_requested_index_order(tmp_path, monkeypatch): + _skip_if_torch_cannot_initialize() + from dataset import QlibDataset # noqa: E402 + + dataset_dir = tmp_path / "processed_datasets" + dataset_dir.mkdir() + frame = pd.DataFrame( + { + "open": [1, 2, 3, 4, 5, 6], + "high": [1, 2, 3, 4, 5, 6], + "low": [1, 2, 3, 4, 5, 6], + "close": [1, 2, 3, 4, 5, 6], + "vol": [10, 20, 30, 40, 50, 60], + "amt": [100, 200, 300, 400, 500, 600], + }, + index=pd.date_range("2026-01-02 09:00:00", periods=6, freq="s"), + ) + frame.index.name = "datetime" + payload = {"KR000001_20260102": frame} + pd.to_pickle(payload, dataset_dir / "train_data.pkl") + pd.to_pickle(payload, dataset_dir / "val_data.pkl") + + monkeypatch.setenv("KRONOS_DATASET_PATH", str(dataset_dir)) + monkeypatch.setenv("KRONOS_LOOKBACK_WINDOW", "2") + monkeypatch.setenv("KRONOS_PREDICT_WINDOW", "1") + monkeypatch.setenv("KRONOS_N_TRAIN_ITER", "3") + monkeypatch.setenv("KRONOS_DATASET_SAMPLE_MODE", "full_sequential") + + dataset = QlibDataset("train") + dataset.py_rng.randint = lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("random sampler used")) + first, _ = dataset[0] + + assert len(dataset) == 3 + assert first.shape[0] == 4 diff --git a/tests/test_stom_2025_preflight.py b/tests/test_stom_2025_preflight.py new file mode 100644 index 000000000..8c0f62360 --- /dev/null +++ b/tests/test_stom_2025_preflight.py @@ -0,0 +1,133 @@ +import json +import sqlite3 +import sys +from datetime import datetime, timedelta +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "finetune")) + +import preflight_stom_2025_full as preflight # noqa: E402 + + +def _create_minimal_stom_db(path: Path) -> None: + conn = sqlite3.connect(path) + try: + conn.execute( + ''' + CREATE TABLE "000001" ( + "timestamp" INTEGER, + "close" REAL, + "volume" REAL, + "amount" REAL + ) + ''' + ) + start = datetime(2025, 1, 3, 9, 0, 0) + for i in range(3): + ts = int((start + timedelta(seconds=i)).strftime("%Y%m%d%H%M%S")) + conn.execute( + 'INSERT INTO "000001" VALUES (?, ?, ?, ?)', + (ts, 1000 + i, 3, 3000 + i), + ) + conn.commit() + finally: + conn.close() + + +def test_preflight_builds_2025_export_and_training_commands(tmp_path, monkeypatch): + db_path = tmp_path / "stock_tick_back.db" + _create_minimal_stom_db(db_path) + scan_report = tmp_path / "scan.json" + scan_report.write_text( + json.dumps( + { + "split_by_session_70_15_15": { + "train": {"possible_samples_pred60": 123}, + "val": {"possible_samples_pred60": 45}, + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr( + preflight, + "_cuda_report", + lambda python_exe: {"python": python_exe, "cuda_available": True, "memory_total_gb": 16}, + ) + + args = preflight.parse_args( + [ + "--db", + str(db_path), + "--scan-report", + str(scan_report), + "--export-dir", + str(tmp_path / "export_2025"), + "--output-root", + str(tmp_path / "outputs"), + "--python-exe", + "python", + ] + ) + + report = preflight.build_preflight(args) + + assert report["blockers"] == [] + assert report["db"]["query_only"] == 1 + assert report["db"]["write_probe_blocked"] is True + assert "--session-start 20250101" in report["commands"]["export_2025_dataset"] + assert "--session-end 20251231" in report["commands"]["export_2025_dataset"] + assert "--n-train-iter 123" in report["commands"]["train_2025_full_small"] + assert "--n-val-iter 45" in report["commands"]["train_2025_full_small"] + + +def test_preflight_prefers_export_report_samples_after_dataset_exists(tmp_path, monkeypatch): + db_path = tmp_path / "stock_tick_back.db" + _create_minimal_stom_db(db_path) + export_dir = tmp_path / "export_2025" + processed = export_dir / "processed_datasets" + processed.mkdir(parents=True) + (processed / "train_data.pkl").write_bytes(b"placeholder") + (processed / "val_data.pkl").write_bytes(b"placeholder") + (export_dir / "stom_qlib_export_report.json").write_text( + json.dumps( + { + "split_counts": { + "train": {"rows": 1000, "groups": 2}, + "val": {"rows": 500, "groups": 1}, + } + } + ), + encoding="utf-8", + ) + monkeypatch.setattr( + preflight, + "_cuda_report", + lambda python_exe: {"python": python_exe, "cuda_available": True, "memory_total_gb": 16}, + ) + + args = preflight.parse_args( + [ + "--db", + str(db_path), + "--export-dir", + str(export_dir), + "--output-root", + str(tmp_path / "outputs"), + "--python-exe", + "python", + "--lookback-window", + "3", + "--horizon", + "2", + ] + ) + + report = preflight.build_preflight(args) + + assert report["target_samples"]["source"] == "export_report" + assert report["target_samples"]["train"] == 990 + assert report["target_samples"]["val"] == 495 + assert report["next_action"] == "run_training_2025_full_small" diff --git a/tests/test_stom_dashboard_helpers.py b/tests/test_stom_dashboard_helpers.py new file mode 100644 index 000000000..8a505bba0 --- /dev/null +++ b/tests/test_stom_dashboard_helpers.py @@ -0,0 +1,343 @@ +import json +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "webui")) + +import stom_dashboard # noqa: E402 + + +def test_dashboard_prediction_helpers_load_metrics_and_chart(tmp_path, monkeypatch): + pred_dir = tmp_path / "preds" + pred_dir.mkdir() + pred_file = pred_dir / "sample.csv" + pred_file.write_text( + "window_id,symbol,session,asof_timestamp,target_timestamp,horizon_step,pred_close,actual_close,error,abs_error,pred_return_window,actual_return_window,direction_hit_window\n" + "0,000001,20260102,2026-01-02T09:00:03,2026-01-02T09:00:04,1,103,101,2,2,2.0,0.6,1\n" + "0,000001,20260102,2026-01-02T09:00:03,2026-01-02T09:00:05,2,104,103,1,1,2.0,0.6,1\n", + encoding="utf-8", + ) + monkeypatch.setattr(stom_dashboard, "PREDICTION_DIRS", [pred_dir]) + + files = stom_dashboard.list_prediction_files() + df = stom_dashboard.load_prediction_frame("sample.csv") + metrics = stom_dashboard.prediction_metrics(df) + chart = json.loads(stom_dashboard.prediction_chart_json(df)) + topk = stom_dashboard.topk_rows(df) + recommendations = stom_dashboard.ranked_recommendations(df) + recommendation_summary = stom_dashboard.recommendation_summary(recommendations) + backtest_report = stom_dashboard.score_backtest_report(df) + visual = stom_dashboard.prediction_visual_payload(df) + export_payload = stom_dashboard.recommendation_export_payload(df, source_file="sample.csv") + export_csv = stom_dashboard.recommendation_export_csv(export_payload["records"]) + diagnostics = stom_dashboard.prediction_diagnostics(df) + scatter = json.loads(diagnostics["charts"]["return_scatter"]) + + assert files[0]["name"] == "sample.csv" + assert df["symbol"].iloc[0] == "000001" + assert metrics["rows"] == 2 + assert metrics["windows"] == 1 + assert chart["data"][0]["name"] == "실제 close" + assert topk[0]["symbol"] == "000001" + assert recommendations[0]["symbol"] == "000001" + assert 0 <= recommendations[0]["kronos_score"] <= 100 + assert recommendation_summary["count"] == 1 + assert backtest_report["window_count"] == 1 + assert visual["selected_window"]["symbol"] == "000001" + assert visual["window_series"][0]["actual_close"] == 101.0 + assert backtest_report["filters"][0]["label"] == "all_scored" + assert export_payload["metadata"]["adapter_version"] == "stom-kronos-score-v1" + assert export_payload["records"][0]["adapter_action"] == "BUY" + assert export_csv.splitlines()[0].startswith("rank,source_file,window_id,symbol") + assert diagnostics["overall"]["symbol_metric_count"] == 1 + assert diagnostics["symbol_summary"][0]["symbol"] == "000001" + assert scatter["data"][0]["name"] == "window" + + +def test_ranked_recommendations_prefers_positive_consistent_prediction(): + df = stom_dashboard.pd.DataFrame( + [ + { + "window_id": 0, + "symbol": "000001", + "session": "20260102", + "asof_timestamp": "2026-01-02T09:00:03", + "target_timestamp": "2026-01-02T09:00:04", + "actual_close_t0": 100, + "pred_close": 102, + "actual_close": 101, + "abs_error": 1, + "pred_return_window": 2.0, + "actual_return_window": 1.0, + "direction_hit_window": 1, + }, + { + "window_id": 1, + "symbol": "000002", + "session": "20260102", + "asof_timestamp": "2026-01-02T09:00:03", + "target_timestamp": "2026-01-02T09:00:04", + "actual_close_t0": 100, + "pred_close": 98, + "actual_close": 101, + "abs_error": 3, + "pred_return_window": -2.0, + "actual_return_window": 1.0, + "direction_hit_window": 0, + }, + ] + ) + df["target_timestamp"] = stom_dashboard.pd.to_datetime(df["target_timestamp"]) + + recommendations = stom_dashboard.ranked_recommendations(df, k=2) + report = stom_dashboard.score_backtest_report(df) + + assert [row["symbol"] for row in recommendations] == ["000001", "000002"] + assert recommendations[0]["signal"] == "BUY_CANDIDATE" + assert recommendations[0]["kronos_score"] > recommendations[1]["kronos_score"] + assert report["filters"][1]["label"] == "buy_candidate_score60" + assert report["segments"]["score_band"][0]["count"] >= 1 + + +def test_recommendation_export_filters_and_rejects_unknown_filter(): + df = stom_dashboard.pd.DataFrame( + [ + { + "window_id": 0, + "symbol": "000001", + "session": "20260102", + "asof_timestamp": "2026-01-02T09:00:03", + "target_timestamp": "2026-01-02T09:00:04", + "actual_close_t0": 100, + "pred_close": 102, + "actual_close": 101, + "abs_error": 1, + "pred_return_window": 2.0, + "actual_return_window": 1.0, + "direction_hit_window": 1, + }, + { + "window_id": 1, + "symbol": "000002", + "session": "20260102", + "asof_timestamp": "2026-01-02T09:00:03", + "target_timestamp": "2026-01-02T09:00:04", + "actual_close_t0": 100, + "pred_close": 98, + "actual_close": 101, + "abs_error": 3, + "pred_return_window": -2.0, + "actual_return_window": 1.0, + "direction_hit_window": 0, + }, + ] + ) + df["target_timestamp"] = stom_dashboard.pd.to_datetime(df["target_timestamp"]) + + payload = stom_dashboard.recommendation_export_payload( + df, + source_file="sample.csv", + selected_filter="buy_candidate_score60", + limit=10, + ) + + assert payload["metadata"]["record_count"] == 1 + assert payload["records"][0]["symbol"] == "000001" + assert "diagnostic_actual_return_pct" in payload["records"][0] + try: + stom_dashboard.recommendation_export_payload(df, selected_filter="unknown_filter") + except ValueError as exc: + assert "Unknown score filter" in str(exc) + else: + raise AssertionError("Expected unknown export filter to be rejected") + + +def test_score_backtest_report_tolerates_unknown_asof_timestamp(): + df = stom_dashboard.pd.DataFrame( + [ + { + "window_id": 0, + "symbol": "000001", + "session": "20260102", + "asof_timestamp": "not-a-time", + "target_timestamp": "2026-01-02T09:00:04", + "actual_close_t0": 100, + "pred_close": 102, + "actual_close": 101, + "abs_error": 1, + "pred_return_window": 2.0, + "actual_return_window": 1.0, + "direction_hit_window": 1, + } + ] + ) + df["target_timestamp"] = stom_dashboard.pd.to_datetime(df["target_timestamp"]) + + report = stom_dashboard.score_backtest_report(df) + + assert report["segments"]["asof_minute_bucket"][0]["label"] == "unknown" + early_filter = next(row for row in report["filters"] if row["label"] == "early_session_score60") + assert early_filter["count"] == 0 + + +def test_qlib_backtest_listing_ignores_filter_search_json(tmp_path, monkeypatch): + qlib_dir = tmp_path / "qlib" + qlib_dir.mkdir() + (qlib_dir / "supported.qlib_topk5.json").write_text( + json.dumps({"metrics": {"avg_net_return_pct": -0.1}, "curve": [], "trades": []}), + encoding="utf-8", + ) + (qlib_dir / "unsupported.filter_search.json").write_text( + json.dumps({"baseline_topk": {}, "best_filter": {}}), + encoding="utf-8", + ) + (qlib_dir / "bad.json").write_text("{", encoding="utf-8") + monkeypatch.setattr(stom_dashboard, "QLIB_BACKTEST_DIRS", [qlib_dir]) + + files = stom_dashboard.list_qlib_backtest_files() + + assert [file["name"] for file in files] == ["supported.qlib_topk5.json"] + + +def test_filter_report_listing_loads_search_and_rolling_artifacts(tmp_path, monkeypatch): + report_dir = tmp_path / "reports" + report_dir.mkdir() + search_path = report_dir / "sample.filter_search.json" + search_path.write_text( + json.dumps({"baseline_topk": {}, "best_filter": {}, "top_filters": []}), + encoding="utf-8", + ) + rolling_path = report_dir / "sample.rolling_filter_validation.json" + rolling_path.write_text( + json.dumps({"summary": {"avg_test_net_return_pct": -0.1}, "folds": []}), + encoding="utf-8", + ) + monkeypatch.setattr(stom_dashboard, "FILTER_REPORT_DIRS", [report_dir]) + + files = stom_dashboard.list_filter_report_files() + loaded = stom_dashboard.load_filter_report_artifact("sample.rolling_filter_validation.json") + + assert [file["artifact_type"] for file in files] == ["filter_search", "rolling_filter_validation"] + assert loaded["artifact_type"] == "rolling_filter_validation" + + +def test_horizon_comparison_loads_dashboard_summary(tmp_path, monkeypatch): + report_dir = tmp_path / "reports" + report_dir.mkdir() + (report_dir / "stom_1s_2025_full_small_horizon_comparison.json").write_text( + json.dumps( + { + "rows": [ + { + "horizon": 30, + "direction": 0.39, + "random_direction": 0.38, + "dir_edge": 0.01, + "topk_net": -0.25, + "rolling_net": -0.24, + "rolling_hit": 0.32, + "passes_gate": False, + }, + { + "horizon": 300, + "direction": 0.49, + "random_direction": 0.46, + "dir_edge": 0.03, + "topk_net": -0.11, + "rolling_net": -0.005, + "rolling_hit": 0.44, + "passes_gate": False, + }, + ] + } + ), + encoding="utf-8", + ) + monkeypatch.setattr(stom_dashboard, "QLIB_BACKTEST_DIRS", [report_dir]) + + payload = stom_dashboard.load_horizon_comparison() + + assert [row["horizon"] for row in payload["rows"]] == [30, 300] + assert payload["best_by_direction"]["horizon"] == 300 + assert payload["best_by_rolling_net"]["horizon"] == 300 + assert payload["decision"] == "hold_expand_200k" + + +def test_dashboard_rejects_path_traversal(tmp_path, monkeypatch): + pred_dir = tmp_path / "preds" + pred_dir.mkdir() + monkeypatch.setattr(stom_dashboard, "PREDICTION_DIRS", [pred_dir]) + + try: + stom_dashboard.load_prediction_frame("../secret.csv") + except ValueError as exc: + assert "Invalid file path" in str(exc) + else: + raise AssertionError("Expected path traversal to be rejected") + + +def test_flask_stom_routes_smoke(tmp_path, monkeypatch): + pred_dir = tmp_path / "preds" + pred_dir.mkdir() + pred_file = pred_dir / "sample.csv" + pred_file.write_text( + "window_id,symbol,session,asof_timestamp,target_timestamp,horizon_step,pred_close,actual_close,error,abs_error,pred_return_window,actual_return_window,direction_hit_window\n" + "0,000001,20260102,2026-01-02T09:00:03,2026-01-02T09:00:04,1,103,101,2,2,2.0,0.6,1\n", + encoding="utf-8", + ) + monkeypatch.setattr(stom_dashboard, "PREDICTION_DIRS", [pred_dir]) + + import app + + monkeypatch.setattr(app, "list_prediction_files", stom_dashboard.list_prediction_files) + monkeypatch.setattr(app, "load_prediction_frame", stom_dashboard.load_prediction_frame) + monkeypatch.setattr(app, "prediction_metrics", stom_dashboard.prediction_metrics) + monkeypatch.setattr(app, "prediction_chart_json", stom_dashboard.prediction_chart_json) + monkeypatch.setattr(app, "topk_rows", stom_dashboard.topk_rows) + monkeypatch.setattr(app, "ranked_recommendations", stom_dashboard.ranked_recommendations) + monkeypatch.setattr(app, "recommendation_summary", stom_dashboard.recommendation_summary) + monkeypatch.setattr(app, "score_backtest_report", stom_dashboard.score_backtest_report) + monkeypatch.setattr(app, "recommendation_export_payload", stom_dashboard.recommendation_export_payload) + monkeypatch.setattr(app, "recommendation_export_csv", stom_dashboard.recommendation_export_csv) + monkeypatch.setattr(app, "list_filter_report_files", stom_dashboard.list_filter_report_files) + monkeypatch.setattr(app, "load_filter_report_artifact", stom_dashboard.load_filter_report_artifact) + monkeypatch.setattr(app, "load_horizon_comparison", stom_dashboard.load_horizon_comparison) + monkeypatch.setattr(app, "prediction_diagnostics", stom_dashboard.prediction_diagnostics) + monkeypatch.setattr(app, "prediction_visual_payload", stom_dashboard.prediction_visual_payload) + + client = app.app.test_client() + page = client.get("/v1/stom") + assert page.status_code == 200 + assert "Kronos Score Export" in page.get_data(as_text=True) + assert "Model Diagnostics" in page.get_data(as_text=True) + assert client.get("/api/stom/prediction-files").status_code == 200 + assert client.get("/api/stom/qlib-backtests").status_code == 200 + assert client.get("/api/stom/filter-reports").status_code == 200 + assert client.get("/api/stom/horizon-comparison").status_code == 200 + assert client.get("/api/stom/prediction?file=sample.csv").status_code == 200 + assert client.get("/api/stom/prediction?file=sample.csv").get_json()["visual"]["selected_window"]["symbol"] == "000001" + rec = client.get("/api/stom/recommendations?file=sample.csv") + assert rec.status_code == 200 + assert rec.get_json()["summary"]["count"] == 1 + report = client.get("/api/stom/backtest-report?file=sample.csv") + assert report.status_code == 200 + assert report.get_json()["filters"][0]["count"] == 1 + diagnostics = client.get("/api/stom/diagnostics?file=sample.csv") + assert diagnostics.status_code == 200 + assert diagnostics.get_json()["overall"]["symbol_metric_count"] == 1 + export_json = client.get("/api/stom/recommendation-export?file=sample.csv&format=json") + assert export_json.status_code == 200 + assert export_json.get_json()["metadata"]["record_count"] == 1 + export_csv = client.get("/api/stom/recommendation-export?file=sample.csv&format=csv") + assert export_csv.status_code == 200 + assert export_csv.mimetype == "text/csv" + assert export_csv.get_data(as_text=True).splitlines()[0].startswith("rank,source_file") + + +def test_flask_stom_routes_work_when_imported_as_package(): + from webui.app import app as flask_app + + client = flask_app.test_client() + assert client.get("/api/stom/prediction-files").status_code == 200 diff --git a/tests/test_stom_filter_gate.py b/tests/test_stom_filter_gate.py new file mode 100644 index 000000000..310a5741e --- /dev/null +++ b/tests/test_stom_filter_gate.py @@ -0,0 +1,129 @@ +import json +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "finetune")) +sys.path.insert(0, str(REPO_ROOT / "webui")) + +from search_stom_1s_filters import FilterSpec, _selected_rows, build_cost_gate_report, write_cost_gate_report # noqa: E402 +import stom_dashboard # noqa: E402 +import pandas as pd + + +def _latest_row(window_id, asof_timestamp, pred_return): + return { + "window_id": window_id, + "asof_timestamp": asof_timestamp, + "pred_return_window": pred_return, + "pred_path_consistency": 1.0, + "history_mean_amount": 10, + "pred_range_pct": 0.1, + "history_volatility_pct": 0.1, + } + + +def test_selected_rows_keeps_top_k_per_asof_after_vectorized_filtering(): + latest = pd.DataFrame( + [ + _latest_row(1, "2026-01-02 09:05:00", 0.2), + _latest_row(2, "2026-01-02 09:05:00", 0.5), + _latest_row(3, "2026-01-02 09:10:00", 0.1), + _latest_row(4, "2026-01-02 09:10:00", 0.4), + ] + ) + latest["asof_timestamp"] = pd.to_datetime(latest["asof_timestamp"]) + + selected = _selected_rows(latest, FilterSpec(0.0, 0.0, None, 0.0, None), top_k=1) + + assert selected["window_id"].tolist() == [2, 4] + + +def test_cost_gate_report_blocks_target_cost_but_keeps_low_cost_scenario_visible(tmp_path, monkeypatch): + filter_report = tmp_path / "sample.filter_search.json" + filter_report.write_text( + json.dumps( + { + "cost_bps": 15, + "slippage_bps": 10, + "baseline_topk": { + "filter_name": "baseline", + "avg_gross_return_pct": 0.05, + "avg_net_return_pct": -0.20, + "period_count": 4, + "trade_count": 20, + "coverage": 1.0, + "direction_hit_rate": 0.45, + }, + "top_filters": [ + { + "filter_name": "ret>=0.05|cons>=0.8", + "avg_gross_return_pct": 0.12, + "avg_net_return_pct": -0.13, + "period_count": 4, + "trade_count": 12, + "coverage": 1.0, + "direction_hit_rate": 0.55, + } + ], + } + ), + encoding="utf-8", + ) + rolling_report = tmp_path / "sample.rolling_filter_validation.json" + rolling_report.write_text( + json.dumps( + { + "cost_bps": 15, + "slippage_bps": 10, + "summary": {"avg_test_net_return_pct": -0.175}, + "folds": [ + { + "fold": 1, + "selected_filter": "a", + "train_avg_net_return_pct": 0.05, + "test_avg_gross_return_pct": 0.30, + "test_avg_net_return_pct": 0.05, + "baseline_test_avg_net_return_pct": -0.10, + "test_trade_count": 60, + "test_direction_hit_rate": 0.6, + }, + { + "fold": 2, + "selected_filter": "b", + "train_avg_net_return_pct": 0.00, + "test_avg_gross_return_pct": 0.10, + "test_avg_net_return_pct": -0.15, + "baseline_test_avg_net_return_pct": -0.20, + "test_trade_count": 60, + "test_direction_hit_rate": 0.5, + }, + ], + } + ), + encoding="utf-8", + ) + + result = build_cost_gate_report( + filter_report, + rolling_report, + total_cost_bps_grid=[5, 25], + target_total_cost_bps=25, + min_total_test_trades=100, + ) + + assert result["decision"] == "hold_expand_200k" + by_cost = {row["total_cost_bps"]: row for row in result["rolling_cost_sensitivity"]} + assert by_cost[5.0]["passes_gate"] is True + assert by_cost[25.0]["passes_gate"] is False + assert by_cost[25.0]["avg_test_net_return_pct"] < 0 + assert result["filter_cost_sensitivity"][0]["filter_name"] == "ret>=0.05|cons>=0.8" + + written = write_cost_gate_report(result, tmp_path, "sample") + monkeypatch.setattr(stom_dashboard, "FILTER_REPORT_DIRS", [tmp_path]) + files = stom_dashboard.list_filter_report_files() + loaded = stom_dashboard.load_filter_report_artifact(Path(written["artifact_paths"]["json"]).name) + + assert any(file["artifact_type"] == "cost_sensitivity_gate" for file in files) + assert loaded["artifact_type"] == "cost_sensitivity_gate" diff --git a/tests/test_stom_prediction_eval.py b/tests/test_stom_prediction_eval.py new file mode 100644 index 000000000..39a92ff01 --- /dev/null +++ b/tests/test_stom_prediction_eval.py @@ -0,0 +1,87 @@ +import sqlite3 +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import pandas as pd + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "finetune_csv")) + +from stom_prediction_eval import evaluate_predictions, load_grouped_ohlcv # noqa: E402 +from stom_tick_dataset import export_stom_tick_db_to_csv # noqa: E402 + + +def _create_db(path: Path, rows: int = 12): + conn = sqlite3.connect(path) + try: + conn.execute( + ''' + CREATE TABLE "000001" ( + "index" INTEGER, + "현재가" REAL, + "시가" REAL, + "고가" REAL, + "저가" REAL, + "초당매수수량" REAL, + "초당매도수량" REAL, + "초당거래대금" REAL + ) + ''' + ) + start = datetime(2026, 1, 2, 9, 0, 0) + for i in range(rows): + ts = int((start + timedelta(seconds=i)).strftime("%Y%m%d%H%M%S")) + price = 1000 + i + conn.execute( + 'INSERT INTO "000001" VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + (ts, price, 1000, price, 1000, i + 1, i + 2, price * (i + 3)), + ) + conn.commit() + finally: + conn.close() + + +def test_baseline_prediction_eval_writes_csv_and_metrics(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + data_path = tmp_path / "grouped.csv" + output_path = tmp_path / "predictions.csv" + _create_db(db_path) + export_stom_tick_db_to_csv( + db_path, + data_path, + max_tables=0, + lookback_window=4, + predict_window=3, + price_mode="close_only", + ) + + metrics = evaluate_predictions( + data_path=data_path, + output_path=output_path, + lookback_window=4, + predict_window=3, + max_windows=2, + stride=2, + mode="baseline", + ) + out = pd.read_csv(output_path, dtype={"symbol": str, "session": str}) + + assert output_path.exists() + assert output_path.with_suffix(".metrics.json").exists() + assert metrics["windows"] == 2 + assert len(out) == 6 + assert set(["pred_close", "actual_close", "pred_return_window", "actual_return_window"]).issubset(out.columns) + assert out["symbol"].iloc[0] == "000001" + + +def test_load_grouped_ohlcv_preserves_symbol_string(tmp_path): + csv_path = tmp_path / "grouped.csv" + csv_path.write_text( + "symbol,session,timestamps,open,high,low,close,volume,amount\n" + "000001,20260102,2026-01-02 09:00:00,1,1,1,1,10,10\n", + encoding="utf-8", + ) + df = load_grouped_ohlcv(csv_path) + assert df["symbol"].iloc[0] == "000001" diff --git a/tests/test_stom_qlib_pipeline.py b/tests/test_stom_qlib_pipeline.py new file mode 100644 index 000000000..cdd3c0e69 --- /dev/null +++ b/tests/test_stom_qlib_pipeline.py @@ -0,0 +1,269 @@ +import json +import pickle +import sqlite3 +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import pandas as pd +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "finetune")) +sys.path.insert(0, str(REPO_ROOT / "webui")) + +from qlib_stom_pipeline import ( # noqa: E402 + StomQlibExportConfig, + check_qlib_environment, + export_stom_to_qlib, + run_dump_bin_from_report, + run_score_backtest, + smoke_qlib_provider, +) +import stom_dashboard # noqa: E402 + + +def _create_stom_db(path: Path, rows_per_session: int = 8): + conn = sqlite3.connect(path) + try: + for symbol, base_price in [("000001", 1000), ("000002", 2000)]: + conn.execute( + f''' + CREATE TABLE "{symbol}" ( + "index" INTEGER, + "현재가" REAL, + "시가" REAL, + "고가" REAL, + "저가" REAL, + "초당매수수량" REAL, + "초당매도수량" REAL, + "초당거래대금" REAL + ) + ''' + ) + for day in [ + datetime(2026, 1, 2, 9, 0, 0), + datetime(2026, 1, 3, 9, 0, 0), + datetime(2026, 1, 4, 9, 0, 0), + ]: + for i in range(rows_per_session): + ts = int((day + timedelta(seconds=i)).strftime("%Y%m%d%H%M%S")) + close = base_price + i + buy_qty = i + 1 + sell_qty = i + 2 + volume = buy_qty + sell_qty + conn.execute( + f'INSERT INTO "{symbol}" VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ( + ts, + close, + close - 1, + close + 1, + close - 2, + buy_qty, + sell_qty, + close * volume, + ), + ) + conn.commit() + finally: + conn.close() + + +def test_export_stom_to_qlib_pilot_outputs_dump_ready_csv_and_pickles(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + out_dir = tmp_path / "qlib_export" + _create_stom_db(db_path) + + report = export_stom_to_qlib( + StomQlibExportConfig( + db_path=str(db_path), + output_dir=str(out_dir), + lookback_window=3, + predict_window=2, + price_mode="close_only", + train_ratio=0.5, + val_ratio=0.25, + test_ratio=0.25, + ) + ) + + assert report["exported_group_count"] == 6 + assert report["split_strategy"] == "session" + assert report["split_counts"]["train"]["groups"] == 2 + assert set(report["split_sessions"]["train"]).isdisjoint(report["split_sessions"]["val"]) + assert set(report["split_sessions"]["train"]).isdisjoint(report["split_sessions"]["test"]) + assert (out_dir / "stom_qlib_export_report.json").exists() + dump_command = (out_dir / "meta" / "qlib_dump_bin_command.txt").read_text(encoding="utf-8") + assert dump_command.startswith("python scripts/dump_bin.py dump_all") + assert "--freq 1s" in dump_command + csv_files = sorted((out_dir / "qlib_csv").glob("*.csv")) + assert len(csv_files) == 6 + sample_csv = pd.read_csv(csv_files[0]) + assert {"symbol", "date", "open", "high", "low", "close", "volume", "amount", "money", "factor"}.issubset( + sample_csv.columns + ) + + with (out_dir / "processed_datasets" / "train_data.pkl").open("rb") as f: + train_data = pickle.load(f) + first_frame = next(iter(train_data.values())) + assert list(first_frame.columns) == ["open", "high", "low", "close", "vol", "amt"] + assert first_frame.index.name == "datetime" + + +def test_export_regularizes_1s_grid_and_keeps_sessions_out_of_multiple_splits(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + out_dir = tmp_path / "qlib_export_grid" + conn = sqlite3.connect(db_path) + try: + for symbol, base_price in [("000001", 1000), ("000002", 2000)]: + conn.execute( + f''' + CREATE TABLE "{symbol}" ( + "index" INTEGER, + "현재가" REAL, + "초당매수수량" REAL, + "초당매도수량" REAL, + "초당거래대금" REAL + ) + ''' + ) + for day in [ + datetime(2026, 1, 2, 9, 0, 0), + datetime(2026, 1, 3, 9, 0, 0), + datetime(2026, 1, 4, 9, 0, 0), + ]: + for offset in [0, 2, 5, 6]: + ts = int((day + timedelta(seconds=offset)).strftime("%Y%m%d%H%M%S")) + close = base_price + offset + conn.execute( + f'INSERT INTO "{symbol}" VALUES (?, ?, ?, ?, ?)', + (ts, close, 1, 2, close * 3), + ) + conn.commit() + finally: + conn.close() + + report = export_stom_to_qlib( + StomQlibExportConfig( + db_path=str(db_path), + output_dir=str(out_dir), + lookback_window=2, + predict_window=2, + horizon_seconds=2, + price_mode="close_only", + time_start="090000", + time_end="090006", + regularize_1s=True, + train_ratio=0.34, + val_ratio=0.33, + test_ratio=0.33, + ) + ) + + assert report["config"]["effective_predict_window"] == 2 + assert report["grid_summary"]["regularized_groups"] == 6 + assert report["grid_summary"]["inserted_rows"] == 18 + assert set(report["split_sessions"]["train"]).isdisjoint(report["split_sessions"]["val"]) + assert set(report["split_sessions"]["val"]).isdisjoint(report["split_sessions"]["test"]) + + sample_csv = pd.read_csv(sorted((out_dir / "qlib_csv").glob("*.csv"))[0]) + timestamps = pd.to_datetime(sample_csv["date"]) + assert timestamps.diff().dropna().dt.total_seconds().eq(1).all() + filled_row = sample_csv.loc[sample_csv["date"].str.endswith("09:00:01")].iloc[0] + assert filled_row["close"] == 1000 + assert filled_row["volume"] == 0 + assert filled_row["amount"] == 0 + + +def test_export_stom_to_qlib_can_limit_session_range(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + out_dir = tmp_path / "qlib_export_2026_session" + _create_stom_db(db_path) + + report = export_stom_to_qlib( + StomQlibExportConfig( + db_path=str(db_path), + output_dir=str(out_dir), + lookback_window=3, + predict_window=2, + price_mode="close_only", + session_start="20260103", + session_end="20260104", + train_ratio=0.5, + val_ratio=0.25, + test_ratio=0.25, + ) + ) + + exported_sessions = { + session + for sessions in report["split_sessions"].values() + for session in sessions + } + assert exported_sessions == {"20260103", "20260104"} + assert report["config"]["session_start"] == "20260103" + assert report["config"]["session_end"] == "20260104" + + +def test_score_backtest_and_dashboard_artifact_loader(tmp_path, monkeypatch): + pred_csv = tmp_path / "predictions.csv" + pred_csv.write_text( + "window_id,symbol,session,asof_timestamp,target_timestamp,horizon_step,actual_close_t0,pred_close,actual_close,error,abs_error,pred_return_window,actual_return_window,direction_hit_window,mode\n" + "0,000001,20260102,2026-01-02T09:05:00,2026-01-02T09:06:00,60,100,103,102,1,1,3.0,2.0,1,kronos\n" + "1,000002,20260102,2026-01-02T09:05:00,2026-01-02T09:06:00,60,100,101,99,2,2,1.0,-1.0,0,kronos\n" + "2,000003,20260102,2026-01-02T09:05:00,2026-01-02T09:06:00,60,100,104,103,1,1,4.0,3.0,1,kronos\n" + "3,000001,20260103,2026-01-03T09:05:00,2026-01-03T09:06:00,60,100,99,98,1,1,-1.0,-2.0,1,kronos\n", + encoding="utf-8", + ) + out_dir = tmp_path / "backtests" + + result = run_score_backtest(pred_csv, out_dir, top_k=2, cost_bps=10, slippage_bps=5) + + assert result["metrics"]["period_count"] == 2 + assert result["metrics"]["trade_count"] == 3 + assert result["metrics"]["cost_bps"] == 10 + assert Path(result["artifact_paths"]["json"]).exists() + + monkeypatch.setattr(stom_dashboard, "QLIB_BACKTEST_DIRS", [out_dir]) + files = stom_dashboard.list_qlib_backtest_files() + assert files[0]["name"].endswith(".json") + artifact = stom_dashboard.load_qlib_backtest_artifact(files[0]["name"]) + chart = json.loads(stom_dashboard.qlib_backtest_chart_json(artifact)) + assert artifact["metrics"]["mode"] == "qlib_style_topk" + assert chart["data"][0]["name"] == "Qlib Top-K equity" + + +def test_qlib_env_check_and_dump_bin_dry_run(tmp_path): + report_path = tmp_path / "stom_qlib_export_report.json" + csv_dir = tmp_path / "qlib_csv" + csv_dir.mkdir() + report_path.write_text( + json.dumps( + { + "qlib_csv_dir": str(csv_dir), + "output_dir": str(tmp_path / "export"), + "config": {"freq": "1s"}, + } + ), + encoding="utf-8", + ) + + env = check_qlib_environment() + assert "qlib_installed" in env + assert env["recommended_install_command"] == "python -m pip install pyqlib" + + result = run_dump_bin_from_report(report_path, qlib_dir=tmp_path / "qlib_bin", execute=False, freq="1min") + assert result["status"] == "dry_run" + assert "--data_path" in result["command"] + assert "--freq" in result["command"] + assert result["command"][-1] == "1min" + + inferred = run_dump_bin_from_report(report_path, qlib_dir=tmp_path / "qlib_bin", execute=False) + assert inferred["command"][-2:] == ["--freq", "1s"] + + +def test_provider_smoke_rejects_second_freq_before_importing_qlib(tmp_path): + with pytest.raises(ValueError, match="1s"): + smoke_qlib_provider(tmp_path, freq="1s") diff --git a/tests/test_stom_rl_accounting.py b/tests/test_stom_rl_accounting.py new file mode 100644 index 000000000..30894eb34 --- /dev/null +++ b/tests/test_stom_rl_accounting.py @@ -0,0 +1,34 @@ +import pytest + +from stom_rl.accounting import PortfolioAccount + + +def test_portfolio_account_buy_partial_sell_full_sell_invariants(): + account = PortfolioAccount(initial_cash=1_000.0, cost_bps=10.0) + + buy = account.buy(symbol="000001", price=100.0, quantity=5.0, timestamp="t0") + assert buy.cost == pytest.approx(0.5) + assert account.cash == pytest.approx(499.5) + account.assert_invariants({"000001": 110.0}) + assert account.nav({"000001": 110.0}) == pytest.approx(1_049.5) + + partial = account.sell(symbol="000001", price=120.0, quantity=2.0, timestamp="t1") + assert partial.cost == pytest.approx(0.24) + assert account.position("000001").quantity == pytest.approx(3.0) + assert account.position("000001").average_price == pytest.approx(100.0) + account.assert_invariants({"000001": 120.0}) + + account.sell(symbol="000001", price=90.0, timestamp="t2") + assert account.position("000001").quantity == pytest.approx(0.0) + account.assert_invariants({}) + + +def test_portfolio_account_rejects_overspend_and_oversell(): + account = PortfolioAccount(initial_cash=100.0, cost_bps=0.0) + + with pytest.raises(ValueError, match="cash"): + account.buy(symbol="000001", price=101.0, quantity=1.0) + + account.buy(symbol="000001", price=50.0, quantity=1.0) + with pytest.raises(ValueError, match="exceeds"): + account.sell(symbol="000001", price=50.0, quantity=2.0) diff --git a/tests/test_stom_rl_baselines.py b/tests/test_stom_rl_baselines.py new file mode 100644 index 000000000..85243ea64 --- /dev/null +++ b/tests/test_stom_rl_baselines.py @@ -0,0 +1,119 @@ +import csv +import json +from pathlib import Path + +import pandas as pd +import pytest + +from stom_rl.baselines import BaselineRunConfig, _parse_policies, run_baselines + + +def _write_baseline_fixture(tmp_path: Path, *, rows: int = 24) -> Path: + csv_dir = tmp_path / "qlib_csv" + csv_dir.mkdir(parents=True, exist_ok=True) + csv_path = csv_dir / "KR000001_20250103.csv" + base = pd.Timestamp("2025-01-03 09:00:00") + frame = pd.DataFrame( + { + "symbol": "KR000001", + "date": [(base + pd.Timedelta(seconds=i)).strftime("%Y-%m-%d %H:%M:%S") for i in range(rows)], + "open": [100.0 + i for i in range(rows)], + "high": [100.0 + i for i in range(rows)], + "low": [100.0 + i for i in range(rows)], + "close": [100.0 + i for i in range(rows)], + "volume": [10.0 + i for i in range(rows)], + "amount": [(100.0 + i) * (10.0 + i) for i in range(rows)], + "money": [(100.0 + i) * (10.0 + i) for i in range(rows)], + "factor": 1.0, + } + ) + frame.to_csv(csv_path, index=False, encoding="utf-8-sig") + + manifest = { + "mode": "stom_rl_episode_manifest", + "summary": {"episode_count": 1}, + "episodes": [ + { + "episode_id": "000001_20250103", + "symbol": "000001", + "instrument": "KR000001", + "session": "20250103", + "split": "test", + "time_start": "090000", + "time_end": "093000", + "lookback_window": 5, + "reward_horizon_seconds": 3, + "row_count": rows, + "source_csv": str(csv_path), + } + ], + } + manifest_path = tmp_path / "episode_manifest.json" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8-sig") + return manifest_path + + +def _summary_by_policy(payload): + return {item["policy"]: item for item in payload["summary"]["policies"]} + + +def test_baseline_runner_writes_summary_and_policy_artifacts(tmp_path): + manifest_path = _write_baseline_fixture(tmp_path) + output_dir = tmp_path / "baseline_run" + + payload = run_baselines( + BaselineRunConfig( + manifest_path=str(manifest_path), + output_dir=str(output_dir), + split="test", + policies=("no_trade", "buy_and_hold", "momentum"), + max_episodes=1, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + write_artifacts=True, + ) + ) + + summaries = _summary_by_policy(payload) + assert payload["summary"]["policy_count"] == 3 + assert summaries["no_trade"]["trade_count"] == 0 + assert summaries["no_trade"]["avg_episode_net_return_pct"] == pytest.approx(0.0) + assert summaries["buy_and_hold"]["trade_count"] == 1 + assert summaries["buy_and_hold"]["avg_episode_net_return_pct"] > 0 + assert summaries["momentum"]["trade_count"] >= 1 + + assert (output_dir / "baseline_summary.json").is_file() + assert (output_dir / "baseline_summary.csv").is_file() + assert (output_dir / "buy_and_hold" / "trades.csv").is_file() + assert (output_dir / "buy_and_hold" / "equity.csv").is_file() + with (output_dir / "buy_and_hold" / "trades.csv").open(encoding="utf-8-sig", newline="") as f: + trade_rows = list(csv.DictReader(f)) + assert trade_rows[0]["forced_exit"] == "True" + + +def test_random_baseline_is_deterministic_for_same_seed(tmp_path): + manifest_path = _write_baseline_fixture(tmp_path) + config = BaselineRunConfig( + manifest_path=str(manifest_path), + output_dir=str(tmp_path / "ignored"), + split="test", + policies=("random",), + max_episodes=1, + seed=77, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + write_artifacts=False, + ) + + first = run_baselines(config) + second = run_baselines(config) + + assert first["summary"]["policies"] == second["summary"]["policies"] + assert first["summary"]["ranking"] == second["summary"]["ranking"] + + +def test_parse_policies_rejects_unknown_policy(): + with pytest.raises(ValueError, match="Unknown policies"): + _parse_policies("no_trade,unknown_policy") diff --git a/tests/test_stom_rl_candidate_gen.py b/tests/test_stom_rl_candidate_gen.py new file mode 100644 index 000000000..da142cf16 --- /dev/null +++ b/tests/test_stom_rl_candidate_gen.py @@ -0,0 +1,144 @@ +"""Tests for Page 9 candidate generation (panel -> candidate, T+1 fill, top-K). + +These tests use a tiny in-memory panel fixture (no 29.7GB DB dependency) and the +real Page 8 rule JSONs. They verify the panel->candidate seam, the T+1 fill +contract on a multi-symbol panel, cross-symbol rank isolation, last-bar +unfillability, and the top-K distribution report. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd +import pytest + +from stom_rl.candidate_gen import ( + build_topk_report, + generate_candidates, + write_topk_report, +) +from stom_rl.condition_screener import ConditionRule, load_rules + +RULES_DIR = Path(__file__).resolve().parents[1] / "stom_rl" / "rules" + +# Schema the screener guarantees (Page 9 contract); feature_* columns follow. +_BASE_SCHEMA = ["timestamp", "symbol", "condition_id", "passed", "rank_score", "price", "fill_price", "fillable"] + + +def _panel() -> pd.DataFrame: + """Tiny long-format panel: 2 symbols x 3 seconds with the columns rules need. + + Symbol 000001 passes buy_widev1 (amount>=100, trade_strength>=100) at every + bar; 000002 fails (low amount / weak strength). close values are recognisable + so the T+1 fill (next bar close) is checkable. + """ + + return pd.DataFrame( + { + "timestamp": [ + "2025-07-09 09:00:00", + "2025-07-09 09:00:00", + "2025-07-09 09:00:01", + "2025-07-09 09:00:01", + "2025-07-09 09:00:02", + "2025-07-09 09:00:02", + ], + "symbol": ["000001", "000002", "000001", "000002", "000001", "000002"], + "close": [1000.0, 50.0, 1010.0, 51.0, 1025.0, 52.0], + "amount": [500.0, 10.0, 600.0, 12.0, 700.0, 11.0], + "trade_strength": [150.0, 80.0, 160.0, 70.0, 155.0, 90.0], + "buy_qty_1s": [20.0, 1.0, 22.0, 1.0, 25.0, 1.0], + "sell_qty_1s": [3.0, 5.0, 2.0, 6.0, 4.0, 7.0], + "net_buy_qty_1s": [17.0, -4.0, 20.0, -5.0, 21.0, -6.0], + "bid_ask_imbalance": [0.7, 0.3, 0.72, 0.28, 0.75, 0.31], + } + ) + + +def test_panel_to_candidate_schema_and_determinism(): + rules = load_rules(RULES_DIR / "buy_widev1.json") + first = generate_candidates(_panel(), rules) + second = generate_candidates(_panel(), rules) + + # Schema match: base columns present and in order, plus feature_* columns. + assert list(first.columns)[: len(_BASE_SCHEMA)] == _BASE_SCHEMA + assert any(col.startswith("feature_") for col in first.columns) + # Only 000001 passes the buy_widev1 demand filter. + assert set(first["symbol"]) == {"000001"} + # Determinism: identical output on a fresh screen of the same panel. + pd.testing.assert_frame_equal(first, second) + + +def test_fill_price_equals_next_bar_close_on_panel(): + rules = load_rules(RULES_DIR / "buy_widev1.json") + candidates = generate_candidates(_panel(), rules).sort_values("timestamp").reset_index(drop=True) + + # 000001 closes: 1000 (T0) -> 1010 (T1) -> 1025 (T2). + t0 = candidates[candidates["timestamp"] == "2025-07-09T09:00:00"].iloc[0] + assert t0["price"] == 1000.0 + assert t0["fill_price"] == 1010.0 # next bar + t1 = candidates[candidates["timestamp"] == "2025-07-09T09:00:01"].iloc[0] + assert t1["price"] == 1010.0 + assert t1["fill_price"] == 1025.0 + # Last bar of 000001 has no T+1 -> NaN / unfillable. + t2 = candidates[candidates["timestamp"] == "2025-07-09T09:00:02"].iloc[0] + assert pd.isna(t2["fill_price"]) + assert bool(t2["fillable"]) is False + + +def test_cross_symbol_does_not_change_fill_price(): + """Removing 000002 from the panel must not change 000001's fill_price.""" + + rules = load_rules(RULES_DIR / "buy_widev1.json") + full = generate_candidates(_panel(), rules) + only_one = generate_candidates(_panel()[_panel()["symbol"] == "000001"], rules) + + full_1 = full[full["symbol"] == "000001"].sort_values("timestamp")["fill_price"].reset_index(drop=True) + solo_1 = only_one.sort_values("timestamp")["fill_price"].reset_index(drop=True) + pd.testing.assert_series_equal(full_1, solo_1, check_names=False) + + +@pytest.mark.parametrize("rule_name", ("buy_widev1", "buy_widev2", "buy_demand_pressure")) +def test_real_rules_run_on_panel(rule_name: str): + rules = load_rules(RULES_DIR / f"{rule_name}.json") + candidates = generate_candidates(_panel(), rules) + # All 3 demand rules select 000001 only on this fixture (000002 always fails). + assert set(candidates["symbol"]) == {"000001"} + assert candidates["condition_id"].unique().tolist() == [rule_name] + + +def test_topk_report_counts_and_distribution(tmp_path: Path): + # A rule that lets both symbols pass so a timestamp has >1 candidate. + rule = ConditionRule( + condition_id="any_buy", + expression="buy_qty_1s > 0", + rank_expression="trade_strength", + ) + candidates = generate_candidates(_panel(), [rule]) + report = build_topk_report(candidates, top_k=2) + + # 3 decision timestamps, each with 2 passing symbols. + assert len(report) == 3 + first = report.iloc[0] + assert first["passing_symbols"] == 2 + # Top symbol by trade_strength at T0 is 000001 (150 > 80). + assert first["top_2_symbols"][0] == "000001" + assert first["rank_score_max"] >= first["rank_score_min"] + + # JSON round-trip writes a readable report. + out = tmp_path / "topk.json" + write_topk_report(out, report) + loaded = json.loads(out.read_text(encoding="utf-8-sig")) + assert len(loaded) == 3 + assert loaded[0]["passing_symbols"] == 2 + + +def test_empty_panel_yields_empty_candidates_and_report(): + rules = load_rules(RULES_DIR / "buy_widev1.json") + empty = pd.DataFrame(columns=["timestamp", "symbol", "close", "amount", "trade_strength"]) + candidates = generate_candidates(empty, rules) + assert candidates.empty + report = build_topk_report(candidates, top_k=3) + assert report.empty diff --git a/tests/test_stom_rl_condition_screener.py b/tests/test_stom_rl_condition_screener.py new file mode 100644 index 000000000..72fb74ba0 --- /dev/null +++ b/tests/test_stom_rl_condition_screener.py @@ -0,0 +1,301 @@ +import json +from pathlib import Path + +import pandas as pd +import pytest + +from stom_rl.condition_screener import ( + ConditionRule, + SafeExpression, + load_rules, + screen_frame, + write_candidates, +) + +RULES_DIR = Path(__file__).resolve().parents[1] / "stom_rl" / "rules" +REAL_RULE_FILES = ("buy_widev1", "buy_widev2", "buy_demand_pressure") + + +def _demand_frame() -> pd.DataFrame: + """Tiny in-memory fixture (no DB dependency) covering pass and fail rows.""" + + return pd.DataFrame( + { + "timestamp": [ + "2025-01-03 09:00:00", + "2025-01-03 09:00:00", + "2025-01-03 09:00:01", + ], + "symbol": ["000001", "000002", "000001"], + "close": [100.0, 200.0, 101.0], + "amount": [150, 50, 200], + "trade_strength": [120, 90, 130], + "buy_qty_1s": [10, 1, 8], + "sell_qty_1s": [2, 5, 3], + "net_buy_qty_1s": [8, -4, 5], + "bid_ask_imbalance": [0.6, 0.4, 0.7], + } + ) + + +def test_safe_expression_rejects_forbidden_tokens_and_unknowns(): + with pytest.raises(ValueError, match="Forbidden"): + SafeExpression("__import__('os')", allowed_names=["close"]) + with pytest.raises(ValueError, match="Unknown"): + SafeExpression("missing > 0", allowed_names=["close"]) + + +def test_condition_screener_emits_deterministic_candidate_schema(tmp_path: Path): + frame = pd.DataFrame( + { + "timestamp": ["2025-01-03 09:00:00", "2025-01-03 09:00:00", "2025-01-03 09:00:01"], + "symbol": ["000001", "000002", "000001"], + "close": [100.0, 200.0, 101.0], + "rank_score": [0.5, 0.2, 0.7], + "buy_qty_1s": [10, 0, 5], + "sell_qty_1s": [2, 5, 1], + } + ) + candidates = screen_frame( + frame, + [ConditionRule(condition_id="net_buy", expression="buy_qty_1s > sell_qty_1s", rank_expression="rank_score")], + ) + + assert list(candidates[["timestamp", "symbol", "condition_id", "passed", "rank_score", "price"]].columns) + assert candidates["symbol"].tolist() == ["000001", "000001"] + assert candidates["timestamp"].is_monotonic_increasing + output = tmp_path / "candidates.jsonl" + write_candidates(output, candidates) + assert output.read_text(encoding="utf-8-sig").count("\n") == 2 + + +def test_buy_screener_rejects_sell_only_variables(): + frame = pd.DataFrame({"timestamp": ["2025-01-03"], "symbol": ["000001"], "close": [100], "holding_qty": [1]}) + with pytest.raises(ValueError, match="sell-only"): + screen_frame(frame, [ConditionRule(condition_id="bad", expression="holding_qty > 0")]) + + +@pytest.mark.parametrize("rule_name", REAL_RULE_FILES) +def test_real_buy_rule_json_loads(rule_name: str): + rules = load_rules(RULES_DIR / f"{rule_name}.json") + assert len(rules) == 1 + rule = rules[0] + assert rule.condition_id == rule_name + assert rule.side == "buy" + assert rule.expression # non-empty whitelisted expression + + +@pytest.mark.parametrize( + "rule_name, expected_rank", + [ + ("buy_widev1", [120.0, 130.0]), + ("buy_widev2", [8.0, 5.0]), + ("buy_demand_pressure", [72.0, 91.0]), + ], +) +def test_real_buy_rule_screens_deterministically(rule_name: str, expected_rank: list[float]): + rules = load_rules(RULES_DIR / f"{rule_name}.json") + candidates = screen_frame(_demand_frame(), rules, strategy_side="buy") + # 000002 fails every filter (low amount, weak trade strength, net selling, bid-light book). + assert candidates["symbol"].tolist() == ["000001", "000001"] + assert candidates["condition_id"].tolist() == [rule_name, rule_name] + assert candidates["timestamp"].is_monotonic_increasing + assert candidates["rank_score"].tolist() == expected_rank + # Determinism: a second screen of the same frame yields identical output. + again = screen_frame(_demand_frame(), rules, strategy_side="buy") + assert again["rank_score"].tolist() == candidates["rank_score"].tolist() + + +def test_real_buy_rules_use_only_whitelisted_canonical_features(): + canonical = { + "open", "high", "low", "close", "volume", "amount", "trade_strength", + "buy_qty_1s", "sell_qty_1s", "net_buy_qty_1s", "bid_ask_imbalance", + "spread_ticks", "amount_delta", "turnover_rate", "rank_score", + } + for rule_name in REAL_RULE_FILES: + rules = load_rules(RULES_DIR / f"{rule_name}.json") + for rule in rules: + expr = SafeExpression(rule.expression, allowed_names=canonical) + # Every referenced name maps to a canonical feature (no hallucinated vars). + assert expr.referenced_names <= canonical + ranker = SafeExpression(rule.rank_expression, allowed_names=canonical) + assert ranker.referenced_names <= canonical + + +def test_screener_rejects_forbidden_tokens_in_rule_expression(): + forbidden_rule = ConditionRule(condition_id="evil", expression="__import__('os') and close > 0") + with pytest.raises(ValueError, match="Forbidden"): + screen_frame(_demand_frame(), [forbidden_rule], strategy_side="buy") + + +def test_screener_rejects_unknown_variable_in_rule_expression(): + unknown_rule = ConditionRule(condition_id="hallucinated", expression="시가총액 > 100") + with pytest.raises(ValueError, match="Unknown"): + screen_frame(_demand_frame(), [unknown_rule], strategy_side="buy") + + +def test_screener_rejects_sell_only_variable_in_buy_rule(): + frame = _demand_frame().assign(position_weight=0.0) + sell_var_rule = ConditionRule(condition_id="leak", expression="position_weight > 0 and close > 0") + with pytest.raises(ValueError, match="sell-only"): + screen_frame(frame, [sell_var_rule], strategy_side="buy") + + +def test_real_buy_rule_json_has_no_forbidden_or_sell_only_tokens(): + sell_only_korean = ("수익률", "보유시간", "매수가", "보유수량", "수익금", "최고수익률") + for rule_name in REAL_RULE_FILES: + text = (RULES_DIR / f"{rule_name}.json").read_text(encoding="utf-8-sig") + payload = json.loads(text) + for rule in payload["rules"]: + assert rule["side"] == "buy" + lowered = rule["expression"].lower() + assert "import" not in lowered and "eval" not in lowered and "__" not in lowered + assert "open(" not in lowered and "compile" not in lowered + for token in sell_only_korean: + assert token not in rule["expression"] + + +# --------------------------------------------------------------------------- +# Page 9 — T+1 fill contract (P0 leakage gate) +# --------------------------------------------------------------------------- +def _two_bar_frame() -> pd.DataFrame: + """Two symbols, each observed at three consecutive seconds (always pass).""" + + return pd.DataFrame( + { + "timestamp": [ + "2025-01-03 09:00:00", + "2025-01-03 09:00:00", + "2025-01-03 09:00:01", + "2025-01-03 09:00:01", + "2025-01-03 09:00:02", + "2025-01-03 09:00:02", + ], + "symbol": ["000001", "000002", "000001", "000002", "000001", "000002"], + "close": [100.0, 200.0, 101.0, 202.0, 103.0, 205.0], + "buy_qty_1s": [10, 9, 8, 7, 6, 5], + "sell_qty_1s": [1, 1, 1, 1, 1, 1], + } + ) + + +_PASS_ALL_RULE = ConditionRule( + condition_id="pass_all", + expression="buy_qty_1s > sell_qty_1s", + rank_expression="rank_score", +) + + +def test_fill_price_is_next_bar_per_symbol_and_after_decision(): + """fill_price[T] == that symbol's price at T+1, and fill ts > decision ts.""" + + candidates = screen_frame(_two_bar_frame(), [_PASS_ALL_RULE]) + # Decision price is always the close at T; fill_price is the next bar's close. + sym1 = candidates[candidates["symbol"] == "000001"].sort_values("timestamp") + # T=09:00:00 -> price 100, fill 101 (the 09:00:01 close). + first = sym1.iloc[0] + assert first["price"] == 100.0 + assert first["fill_price"] == 101.0 + assert first["fillable"] is True or first["fillable"] == True # noqa: E712 + # T=09:00:01 -> price 101, fill 103. + second = sym1.iloc[1] + assert second["price"] == 101.0 + assert second["fill_price"] == 103.0 + # Symbol 000002 is independent: its fills come from 000002's own next bars. + sym2 = candidates[candidates["symbol"] == "000002"].sort_values("timestamp") + assert sym2.iloc[0]["price"] == 200.0 + assert sym2.iloc[0]["fill_price"] == 202.0 + # The fill timestamp is strictly later than the decision timestamp. + for _, row in sym1.iloc[:-1].iterrows(): + assert row["fill_price"] != row["price"] + + +def test_last_bar_per_symbol_is_unfillable_nan(): + """The last bar per symbol has no T+1 -> fill_price NaN, fillable False.""" + + candidates = screen_frame(_two_bar_frame(), [_PASS_ALL_RULE]) + last_ts = "2025-01-03T09:00:02" + last_rows = candidates[candidates["timestamp"] == last_ts] + assert len(last_rows) == 2 # both symbols' last bar + assert last_rows["fill_price"].isna().all() + assert (~last_rows["fillable"]).all() + + +def test_drop_unfillable_removes_last_bar_candidates(): + kept = screen_frame(_two_bar_frame(), [_PASS_ALL_RULE], drop_unfillable=True) + # 3 bars/symbol x 2 symbols = 6 rows; last bar of each dropped -> 4 rows. + assert len(kept) == 4 + assert kept["fillable"].all() + assert kept["fill_price"].notna().all() + + +# --------------------------------------------------------------------------- +# Page 9 — per-symbol default rank_score (P1 fix) +# --------------------------------------------------------------------------- +def test_default_rank_score_is_per_symbol_no_cross_contamination(): + """Symbol A's rows never influence symbol B's default rank_score. + + Default rank_score is pct_change of price within each symbol. Symbol B's + first row must be 0.0 (no prior bar in B), NOT a pct_change off symbol A's + last close — which is the cross-symbol contamination bug this guards. + """ + + frame = pd.DataFrame( + { + "timestamp": [ + "2025-01-03 09:00:00", + "2025-01-03 09:00:01", + "2025-01-03 09:00:00", + "2025-01-03 09:00:01", + ], + "symbol": ["000001", "000001", "000002", "000002"], + "close": [100.0, 110.0, 5000.0, 5050.0], + "buy_qty_1s": [10, 10, 10, 10], + "sell_qty_1s": [1, 1, 1, 1], + } + ) + # No rank_score column and no rank_expression -> default per-symbol path. + rule = ConditionRule(condition_id="all", expression="buy_qty_1s > sell_qty_1s", rank_expression="rank_score") + candidates = screen_frame(frame, [rule]) + + rank = { + (row["symbol"], row["timestamp"]): row["rank_score"] + for _, row in candidates.iterrows() + } + # First bar of each symbol -> 0.0 (no prior within-symbol bar). + assert rank[("000001", "2025-01-03T09:00:00")] == 0.0 + assert rank[("000002", "2025-01-03T09:00:00")] == 0.0 + # Second bars use within-symbol pct_change only. + assert rank[("000001", "2025-01-03T09:00:01")] == pytest.approx(0.10) # 100 -> 110 + assert rank[("000002", "2025-01-03T09:00:01")] == pytest.approx(0.01) # 5000 -> 5050 + # If contamination existed, 000002's first row would be 5000/100-1 = 49.0. + assert rank[("000002", "2025-01-03T09:00:00")] != pytest.approx(49.0) + + +def test_symbol_order_does_not_affect_per_symbol_rank(): + """Re-ordering input rows must not change per-symbol rank_score values.""" + + base = pd.DataFrame( + { + "timestamp": ["2025-01-03 09:00:00", "2025-01-03 09:00:01"], + "symbol": ["000002", "000002"], + "close": [5000.0, 5050.0], + "buy_qty_1s": [10, 10], + "sell_qty_1s": [1, 1], + } + ) + other = pd.DataFrame( + { + "timestamp": ["2025-01-03 09:00:00", "2025-01-03 09:00:01"], + "symbol": ["000001", "000001"], + "close": [100.0, 110.0], + "buy_qty_1s": [10, 10], + "sell_qty_1s": [1, 1], + } + ) + rule = ConditionRule(condition_id="all", expression="buy_qty_1s > sell_qty_1s", rank_expression="rank_score") + a = screen_frame(pd.concat([base, other], ignore_index=True), [rule]) + b = screen_frame(pd.concat([other, base], ignore_index=True), [rule]) + a_map = {(r["symbol"], r["timestamp"]): r["rank_score"] for _, r in a.iterrows()} + b_map = {(r["symbol"], r["timestamp"]): r["rank_score"] for _, r in b.iterrows()} + assert a_map == b_map diff --git a/tests/test_stom_rl_contextual_bandit.py b/tests/test_stom_rl_contextual_bandit.py new file mode 100644 index 000000000..dca5c2017 --- /dev/null +++ b/tests/test_stom_rl_contextual_bandit.py @@ -0,0 +1,130 @@ +import json +from pathlib import Path + +import pandas as pd + +from stom_rl.contextual_bandit import ( + ContextualBanditConfig, + _raw_features, + load_model, + run_contextual_bandit, +) + + +def _write_bandit_fixture(tmp_path: Path, *, rows: int = 28) -> Path: + csv_dir = tmp_path / "qlib_csv" + csv_dir.mkdir(parents=True, exist_ok=True) + episodes = [] + specs = [ + ("train", "20250103", 100.0, 1.0), + ("train", "20250106", 101.0, 1.0), + ("test", "20250107", 102.0, 1.0), + ] + for split, session, price_base, slope in specs: + csv_path = csv_dir / f"KR000001_{session}.csv" + base = pd.Timestamp(f"{session[:4]}-{session[4:6]}-{session[6:]} 09:00:00") + close = [price_base + slope * i for i in range(rows)] + frame = pd.DataFrame( + { + "symbol": "KR000001", + "date": [(base + pd.Timedelta(seconds=i)).strftime("%Y-%m-%d %H:%M:%S") for i in range(rows)], + "open": close, + "high": [value + 0.1 for value in close], + "low": [value - 0.1 for value in close], + "close": close, + "volume": [10.0 + i for i in range(rows)], + "amount": [close[i] * (10.0 + i) for i in range(rows)], + "money": [close[i] * (10.0 + i) for i in range(rows)], + "factor": 1.0, + } + ) + frame.to_csv(csv_path, index=False, encoding="utf-8-sig") + episodes.append( + { + "episode_id": f"000001_{session}", + "symbol": "000001", + "instrument": "KR000001", + "session": session, + "split": split, + "time_start": "090000", + "time_end": "093000", + "lookback_window": 5, + "reward_horizon_seconds": 3, + "row_count": rows, + "source_csv": str(csv_path), + } + ) + manifest = { + "mode": "stom_rl_episode_manifest", + "summary": {"episode_count": len(episodes)}, + "episodes": episodes, + } + manifest_path = tmp_path / "episode_manifest.json" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8-sig") + return manifest_path + + +def test_contextual_bandit_trains_saves_loads_and_evaluates(tmp_path): + manifest_path = _write_bandit_fixture(tmp_path) + output_dir = tmp_path / "bandit_run" + + payload = run_contextual_bandit( + ContextualBanditConfig( + manifest_path=str(manifest_path), + output_dir=str(output_dir), + train_split="train", + eval_split="test", + max_train_episodes=2, + max_eval_episodes=1, + train_sample_stride=1, + eval_sample_stride=1, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + ridge_alpha=0.1, + write_artifacts=True, + ) + ) + + summary = payload["eval_summary"] + assert summary["policy"] == "contextual_bandit" + assert summary["episode_count"] == 1 + assert summary["trade_count"] >= 1 + assert summary["avg_episode_net_return_pct"] > 0 + assert summary["passes_cost_gate"] is True + + assert (output_dir / "model.json").is_file() + assert (output_dir / "eval_summary.json").is_file() + assert (output_dir / "actions.csv").is_file() + assert (output_dir / "trades.csv").is_file() + model = load_model(output_dir / "model.json") + + test_csv = Path(json.loads(manifest_path.read_text(encoding="utf-8-sig"))["episodes"][2]["source_csv"]) + frame = pd.read_csv(test_csv) + score = model.predict_score(_raw_features(frame, 5, 5)) + assert score > 0 + + +def test_contextual_bandit_can_run_without_writing_artifacts(tmp_path): + manifest_path = _write_bandit_fixture(tmp_path) + output_dir = tmp_path / "no_write_run" + + payload = run_contextual_bandit( + ContextualBanditConfig( + manifest_path=str(manifest_path), + output_dir=str(output_dir), + train_split="train", + eval_split="test", + max_train_episodes=1, + max_eval_episodes=1, + train_sample_stride=2, + eval_sample_stride=2, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + write_artifacts=False, + ) + ) + + assert payload["model"]["train_summary"]["train_sample_count"] > 0 + assert output_dir.exists() is False diff --git a/tests/test_stom_rl_cost_gate.py b/tests/test_stom_rl_cost_gate.py new file mode 100644 index 000000000..5eb58a5de --- /dev/null +++ b/tests/test_stom_rl_cost_gate.py @@ -0,0 +1,131 @@ +import csv +import json +from pathlib import Path + +import pandas as pd + +from stom_rl.cost_gate import CostGateConfig, run_cost_gate + + +def _write_cost_gate_fixture(tmp_path: Path, *, rows: int = 24) -> Path: + csv_dir = tmp_path / "qlib_csv" + csv_dir.mkdir(parents=True, exist_ok=True) + episodes = [] + for day_offset, session in enumerate(["20250103", "20250106"]): + csv_path = csv_dir / f"KR000001_{session}.csv" + base = pd.Timestamp(f"{session[:4]}-{session[4:6]}-{session[6:]} 09:00:00") + price_base = 100.0 + day_offset + frame = pd.DataFrame( + { + "symbol": "KR000001", + "date": [(base + pd.Timedelta(seconds=i)).strftime("%Y-%m-%d %H:%M:%S") for i in range(rows)], + "open": [price_base + i for i in range(rows)], + "high": [price_base + i for i in range(rows)], + "low": [price_base + i for i in range(rows)], + "close": [price_base + i for i in range(rows)], + "volume": [10.0 + i for i in range(rows)], + "amount": [(price_base + i) * (10.0 + i) for i in range(rows)], + "money": [(price_base + i) * (10.0 + i) for i in range(rows)], + "factor": 1.0, + } + ) + frame.to_csv(csv_path, index=False, encoding="utf-8-sig") + episodes.append( + { + "episode_id": f"000001_{session}", + "symbol": "000001", + "instrument": "KR000001", + "session": session, + "split": "test", + "time_start": "090000", + "time_end": "093000", + "lookback_window": 5, + "reward_horizon_seconds": 3, + "row_count": rows, + "source_csv": str(csv_path), + } + ) + + manifest = { + "mode": "stom_rl_episode_manifest", + "summary": {"episode_count": len(episodes)}, + "episodes": episodes, + } + manifest_path = tmp_path / "episode_manifest.json" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8-sig") + return manifest_path + + +def test_cost_gate_writes_scenario_rolling_and_gate_artifacts(tmp_path): + manifest_path = _write_cost_gate_fixture(tmp_path) + output_dir = tmp_path / "cost_gate" + + payload = run_cost_gate( + CostGateConfig( + manifest_path=str(manifest_path), + output_dir=str(output_dir), + split="test", + policies=("no_trade", "buy_and_hold", "momentum"), + cost_bps_values=(0.0, 25.0), + slippage_bps_values=(0.0,), + target_cost_bps=25.0, + max_episodes=2, + lookback_window=5, + reward_horizon_seconds=3, + max_drawdown_pct=50.0, + max_trades_per_episode=10.0, + rolling_sessions_per_fold=1, + rolling_max_folds=2, + rolling_max_episodes_per_fold=1, + write_policy_artifacts=False, + ) + ) + + assert payload["summary"]["scenario_count"] == 2 + assert payload["summary"]["policy_count"] == 3 + assert payload["summary"]["passing_policy_count"] >= 1 + assert "buy_and_hold" in payload["summary"]["passing_policies"] + assert len(payload["scenario_rows"]) == 6 + assert len(payload["rolling_rows"]) == 6 + + report_path = output_dir / "cost_gate_report.json" + scenario_csv = output_dir / "scenario_summary.csv" + rolling_csv = output_dir / "rolling_folds.csv" + gate_csv = output_dir / "gate_summary.csv" + assert report_path.is_file() + assert scenario_csv.is_file() + assert rolling_csv.is_file() + assert gate_csv.is_file() + + with gate_csv.open(encoding="utf-8-sig", newline="") as f: + gate_rows = {row["policy"]: row for row in csv.DictReader(f)} + assert gate_rows["no_trade"]["passes_cost_gate"] == "False" + assert gate_rows["buy_and_hold"]["passes_cost_gate"] == "True" + + +def test_cost_gate_can_limit_to_specific_sessions(tmp_path): + manifest_path = _write_cost_gate_fixture(tmp_path) + output_dir = tmp_path / "cost_gate_session_filter" + + payload = run_cost_gate( + CostGateConfig( + manifest_path=str(manifest_path), + output_dir=str(output_dir), + split="test", + policies=("buy_and_hold",), + cost_bps_values=(0.0,), + slippage_bps_values=(0.0,), + target_cost_bps=0.0, + sessions=("20250106",), + max_episodes=0, + lookback_window=5, + reward_horizon_seconds=3, + rolling_sessions_per_fold=1, + rolling_max_folds=0, + rolling_max_episodes_per_fold=0, + ) + ) + + assert payload["scenario_rows"][0]["episode_count"] == 1 + assert len(payload["rolling_rows"]) == 1 + assert payload["rolling_rows"][0]["sessions"] == "20250106" diff --git a/tests/test_stom_rl_daily_ohlcv_dataset.py b/tests/test_stom_rl_daily_ohlcv_dataset.py new file mode 100644 index 000000000..aab542a0e --- /dev/null +++ b/tests/test_stom_rl_daily_ohlcv_dataset.py @@ -0,0 +1,289 @@ +import csv +import json +import math +import sqlite3 +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from stom_rl.daily_ohlcv_db import EXPECTED_COLUMNS # noqa: E402 +from stom_rl.daily_ohlcv_dataset import ( # noqa: E402 + DATASET_BLOCKED_USES_WITH_UPSTREAM_BLOCKERS, + DATASET_REQUIRED_EVIDENCE, + DEFAULT_FEATURE_COLUMNS, + DEFAULT_LABEL_COLUMNS, + assign_chronological_splits, + build_daily_ohlcv_dataset, + validate_no_feature_leakage, + write_dataset_artifacts, +) + + +def _create_daily_db(path: Path) -> Path: + conn = sqlite3.connect(path) + cols = ", ".join([f'"{col}" REAL' for col in EXPECTED_COLUMNS if col != "date"]) + for table in ("A000250", "A005930", "A069500"): + conn.execute(f'CREATE TABLE "{table}" ("date" TEXT, {cols})') + + rows_000250 = [] + rows_005930 = [] + rows_etf = [] + for index in range(12): + day = index + 1 + date = f"2024-01-{day:02d}" + # A000250 includes one split-like jump at 2024-01-08 that must be blocked. + close_000250 = 100.0 + index + open_000250 = close_000250 - 0.5 + if day == 8: + open_000250 = 500.0 + close_000250 = 510.0 + rows_000250.append( + ( + date, + open_000250, + max(open_000250, close_000250) + 1.0, + min(open_000250, close_000250) - 1.0, + close_000250, + 1000.0 + index * 10, + 10, + 0, + 0, + 1.5 + index, + 100.0 + index, + 100.0 + index, + ) + ) + close_005930 = 200.0 + index * 2 + rows_005930.append( + ( + date, + close_005930 - 1.0, + close_005930 + 2.0, + close_005930 - 2.0, + close_005930, + 2000.0 + index * 20, + 10, + 0, + 0, + 2.0 + index, + 200.0 + index, + 200.0 + index, + ) + ) + rows_etf.append( + ( + date, + 50.0, + 51.0, + 49.0, + 50.0, + 500.0, + 10, + 0, + 0, + 0, + 0, + 0, + ) + ) + conn.executemany('INSERT INTO "A000250" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', rows_000250) + conn.executemany('INSERT INTO "A005930" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', rows_005930) + conn.executemany('INSERT INTO "A069500" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', rows_etf) + conn.commit() + conn.close() + return path + + +def _create_universe_manifest(path: Path) -> Path: + manifest = { + "schema_version": 1, + "manifest_sha": "unit-universe-sha", + "review_status": "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW", + "verdict": "WATCH_HEURISTIC_UNIVERSE", + "symbols": [ + { + "table": "A000250", + "code": "000250", + "name": "삼천당제약", + "market": "KOSDAQ", + "instrument_type": "common_equity", + "include": True, + "review_status": "heuristic_watch", + "classification_source": "stockinfo_name_market_heuristic", + "classification_confidence": 0.85, + }, + { + "table": "A005930", + "code": "005930", + "name": "삼성전자", + "market": "KOSPI", + "instrument_type": "common_equity", + "include": True, + "review_status": "heuristic_watch", + "classification_source": "stockinfo_name_market_heuristic", + "classification_confidence": 0.85, + }, + { + "table": "A069500", + "code": "069500", + "name": "KODEX 200", + "market": "KOSPI", + "instrument_type": "fund_or_etf", + "include": False, + "exclusion_reason": "ETF_ETN_FUND_NAME_PREFIX", + "review_status": "excluded_by_default", + }, + ], + } + path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + return path + + +def test_validate_no_feature_leakage_rejects_future_label_columns(): + with pytest.raises(ValueError): + validate_no_feature_leakage(["return_1d", "future_return_1d"], DEFAULT_LABEL_COLUMNS) + report = validate_no_feature_leakage(DEFAULT_FEATURE_COLUMNS, DEFAULT_LABEL_COLUMNS) + assert report["status"] == "PASS" + assert report["forbidden_feature_columns"] == [] + + +def test_assign_chronological_splits_blocks_purge_embargo_dates(): + assignments = assign_chronological_splits([f"2024-01-{idx:02d}" for idx in range(1, 11)], purge_days=1, embargo_days=1) + assert list(assignments.values()).count("blocked_purge_embargo") == 2 + train_dates = [date for date, split in assignments.items() if split == "train"] + val_dates = [date for date, split in assignments.items() if split == "val"] + test_dates = [date for date, split in assignments.items() if split == "test"] + assert max(train_dates) < min(val_dates) < min(test_dates) + + +def test_build_daily_dataset_preserves_codes_splits_and_blocks_material_windows(tmp_path: Path): + db_path = _create_daily_db(tmp_path / "daily.db") + universe_path = _create_universe_manifest(tmp_path / "universe.json") + dataset = build_daily_ohlcv_dataset( + daily_db_path=db_path, + universe_manifest_path=universe_path, + horizon_days=1, + train_fraction=0.5, + val_fraction=0.25, + purge_days=1, + embargo_days=1, + ) + manifest = dataset["manifest"] + assert manifest["price_basis"] == "unknown" + assert manifest["universe_verdict"] == "WATCH_HEURISTIC_UNIVERSE" + assert manifest["price_basis_status"] == "UNKNOWN_CONFIRMED" + assert manifest["decision_grade_return_status"] == "BLOCKED_UNTIL_PRICE_BASIS_VERIFIED" + assert manifest["universe_manifest_path"] == str(universe_path) + assert manifest["universe_manifest_sha"] == "unit-universe-sha" + assert manifest["universe_file_sha256"] + assert manifest["upstream_gate_blockers"] == [ + "D0_PRICE_BASIS_NOT_VERIFIED", + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED", + ] + assert manifest["decision_grade_status"] == "BLOCKED_BY_UPSTREAM_D0_D1_GUARDRAILS" + assert manifest["model_readiness"] == "DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS" + assert manifest["dataset_required_evidence"] == list(DATASET_REQUIRED_EVIDENCE) + assert manifest["dataset_blocked_uses"] == list(DATASET_BLOCKED_USES_WITH_UPSTREAM_BLOCKERS) + assert manifest["row_counts"]["feature_rows"] == 24 + assert manifest["row_counts"]["blocked_windows"] >= 1 + assert manifest["leakage_status"] == "PASS" + assert manifest["normalization_policy"] == "fit_train_only_apply_to_val_test_later" + assert manifest["split_chronology_status"] == "PASS" + codes = {row["code"] for row in dataset["feature_panel"]} + assert "000250" in codes + assert "005930" in codes + assert "069500" not in codes + assert all(not key.startswith("future_") for row in dataset["rl_candidate_panel"] for key in row) + assert all("label_join_key" in row for row in dataset["rl_candidate_panel"]) + blocked = [row for row in dataset["split_assignments"] if row["split"] == "blocked_material_unknown_adjustment"] + assert {row["date"] for row in blocked} & {"2024-01-07", "2024-01-08"} + assert all(row["eligible_for_training"] is False for row in blocked) + assert any(row["split"] == "train" for row in dataset["split_assignments"]) + assert any(row["split"] == "val" for row in dataset["split_assignments"]) + assert any(row["split"] == "test" for row in dataset["split_assignments"]) + blocked_h2 = build_daily_ohlcv_dataset( + daily_db_path=db_path, + universe_manifest_path=universe_path, + horizon_days=2, + train_fraction=0.5, + val_fraction=0.25, + purge_days=1, + embargo_days=1, + ) + blocked_h2_dates = { + row["date"] + for row in blocked_h2["split_assignments"] + if row["table"] == "A000250" and row["split"] == "blocked_material_unknown_adjustment" + } + assert {"2024-01-06", "2024-01-07", "2024-01-08"}.issubset(blocked_h2_dates) + + +def test_normalization_stats_are_fit_on_train_split_only(tmp_path: Path): + db_path = _create_daily_db(tmp_path / "daily.db") + universe_path = _create_universe_manifest(tmp_path / "universe.json") + dataset = build_daily_ohlcv_dataset( + daily_db_path=db_path, + universe_manifest_path=universe_path, + horizon_days=1, + train_fraction=0.5, + val_fraction=0.25, + purge_days=1, + embargo_days=1, + ) + train_values = [ + float(row["return_1d"]) + for row in dataset["feature_panel"] + if row["split"] == "train" and row["eligible_for_training"] and row["return_1d"] is not None + ] + expected_mean = sum(train_values) / len(train_values) + stats = dataset["normalization_stats"]["features"]["return_1d"] + assert stats["fit_split"] == "train" + assert stats["count"] == len(train_values) + assert math.isclose(stats["mean"], expected_mean) + + +def test_write_dataset_artifacts_stays_under_generated_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + db_path = _create_daily_db(tmp_path / "daily.db") + universe_path = _create_universe_manifest(tmp_path / "universe.json") + dataset = build_daily_ohlcv_dataset( + daily_db_path=db_path, + universe_manifest_path=universe_path, + horizon_days=1, + train_fraction=0.5, + val_fraction=0.25, + purge_days=1, + embargo_days=1, + ) + import stom_rl.daily_ohlcv_dataset as daily_dataset + + safe_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_dataset" + monkeypatch.setattr(daily_dataset, "DEFAULT_DATASET_ROOT", safe_root) + written = daily_dataset.write_dataset_artifacts(dataset, run_id="dataset_unit") + assert Path(written["dataset_manifest_path"]).exists() + assert Path(written["feature_panel_path"]).exists() + assert Path(written["label_panel_path"]).exists() + assert Path(written["rl_candidate_panel_path"]).exists() + assert Path(written["split_assignments_path"]).exists() + assert Path(written["normalization_stats_path"]).exists() + assert Path(written["leakage_report_path"]).exists() + assert Path(written["blocked_windows_path"]).exists() + manifest = json.loads(Path(written["dataset_manifest_path"]).read_text(encoding="utf-8")) + assert manifest["run_id"] == "dataset_unit" + assert manifest["universe_manifest_path"] == str(universe_path) + assert manifest["universe_manifest_sha"] == "unit-universe-sha" + assert manifest["universe_file_sha256"] + assert manifest["cost_assumption_round_trip_bp"] == 23 + with Path(written["feature_panel_path"]).open(encoding="utf-8", newline="") as handle: + feature_rows = list(csv.DictReader(handle)) + assert feature_rows[0]["code"] in {"000250", "005930"} + with pytest.raises(FileExistsError): + daily_dataset.write_dataset_artifacts(dataset, run_id="dataset_unit") + with pytest.raises(ValueError): + daily_dataset.write_dataset_artifacts(dataset, artifact_root=tmp_path / "elsewhere", run_id="bad") + with pytest.raises(ValueError): + daily_dataset.write_dataset_artifacts(dataset, run_id="..") diff --git a/tests/test_stom_rl_daily_ohlcv_db.py b/tests/test_stom_rl_daily_ohlcv_db.py new file mode 100644 index 000000000..680e895f1 --- /dev/null +++ b/tests/test_stom_rl_daily_ohlcv_db.py @@ -0,0 +1,144 @@ +import sqlite3 +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from stom_rl.daily_ohlcv_db import ( # noqa: E402 + EXPECTED_COLUMNS, + connect_readonly, + resolve_daily_table, + summarize_daily_db, + summarize_symbol, + validate_daily_table_name, + write_db_summary_artifacts, +) + + +def _create_daily_db(path: Path) -> Path: + conn = sqlite3.connect(path) + cols = ", ".join([f'"{col}" REAL' for col in EXPECTED_COLUMNS if col != "date"]) + for table in ("A000250", "A035720", "Q500001"): + conn.execute(f'CREATE TABLE "{table}" ("date" TEXT, {cols})') + conn.executemany( + 'INSERT INTO "A000250" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + ("2024-01-02", 100.0, 110.0, 90.0, 100.0, 1000.0, 10, 0, 0, 0, 0, 0), + ("2024-01-03", 102.0, 112.0, 95.0, 105.0, 1200.0, 10, 0, 0, 0, 0, 0), + ("2024-01-04", 500.0, 520.0, 490.0, 510.0, 2000.0, 10, 0, 0, 0, 0, 0), + ], + ) + conn.executemany( + 'INSERT INTO "A035720" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [ + ("2024-01-02", 10.0, 11.0, 9.0, 10.0, 100.0, 10, 0, 0, 0, 0, 0), + ("2024-01-03", 0.0, 11.0, 9.0, 10.0, 100.0, 10, 0, 0, 0, 0, 0), + ("2024-01-04", 10.0, 8.0, 9.0, 10.0, 100.0, 10, 0, 0, 0, 0, 0), + ], + ) + conn.execute( + 'INSERT INTO "Q500001" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ("2024-01-04", 10.0, 11.0, 9.0, 10.0, 100.0, 10, 0, 0, 0, 0, 0), + ) + conn.commit() + conn.close() + return path + + +def test_resolve_daily_table_preserves_leading_zero_code(): + resolved = resolve_daily_table("000250") + assert resolved.table == "A000250" + assert resolved.code == "000250" + assert resolve_daily_table("Q500001").prefix == "Q" + + +@pytest.mark.parametrize("bad", ["../A000250", "A000250;DROP", "A00025", "AA000250", "000250.csv"]) +def test_validate_daily_table_name_rejects_unsafe_values(bad: str): + with pytest.raises(ValueError): + validate_daily_table_name(bad) + + +def test_connect_readonly_blocks_write_attempt(tmp_path: Path): + db_path = _create_daily_db(tmp_path / "daily.db") + with connect_readonly(db_path) as conn: + assert conn.execute('SELECT COUNT(*) FROM "A000250"').fetchone()[0] == 3 + with pytest.raises(sqlite3.OperationalError): + conn.execute('INSERT INTO "A000250" (date) VALUES ("2099-01-01")') + + +def test_summarize_symbol_reports_price_basis_and_quality(tmp_path: Path): + db_path = _create_daily_db(tmp_path / "daily.db") + payload = summarize_symbol("000250", db_path=db_path, sample_limit=2) + assert payload["table"] == "A000250" + assert payload["code"] == "000250" + assert payload["price_basis"] == "unknown" + assert payload["price_basis_status"] == "UNKNOWN_CONFIRMED" + assert payload["decision_grade_return_status"] == "BLOCKED_UNTIL_PRICE_BASIS_VERIFIED" + assert payload["price_basis_audit"]["status"] == "UNKNOWN_CONFIRMED" + assert payload["price_basis_audit"]["component_status"]["split_adjustment"] == "not_declared_no_split_factor_or_corporate_action_table" + assert payload["price_basis_audit"]["blocked_uses"] == [ + "decision_grade_return_labels", + "model_build_or_candidate_promotion", + "paper_forward_or_live_readiness_claims", + ] + assert payload["price_basis_user_guidance"][0]["section"] == "D0 summary" + assert payload["schema_matches_expected"] is True + assert payload["quality"]["split_like_discontinuity_count"] == 1 + assert payload["quality"]["material_unknown_adjustment_windows"][0]["date"] == "2024-01-04" + assert len(payload["sample_rows_desc"]) == 2 + + +def test_summarize_daily_db_contract_and_bounded_tables(tmp_path: Path): + db_path = _create_daily_db(tmp_path / "daily.db") + summary = summarize_daily_db(db_path, table_limit=2, quality_table_limit=3) + assert summary["read_only"] is True + assert summary["query_only"] is True + assert summary["table_count"] == 3 + assert summary["prefix_counts"] == {"A": 2, "Q": 1} + assert summary["total_rows"] == 7 + assert summary["first_date"] == "2024-01-02" + assert summary["latest_date"] == "2024-01-04" + assert summary["tables_at_latest_date"] == 3 + assert summary["price_basis"] == "unknown" + assert summary["decision_grade_status"] == "WATCH_PRICE_BASIS_UNKNOWN_CONFIRMED" + assert summary["price_basis_status"] == "UNKNOWN_CONFIRMED" + assert summary["decision_grade_return_status"] == "BLOCKED_UNTIL_PRICE_BASIS_VERIFIED" + assert summary["price_basis_audit"]["quality_scan_complete"] is True + assert summary["price_basis_audit"]["split_like_table_count"] == 1 + assert summary["price_basis_audit"]["split_like_window_sample_count"] == 1 + assert "official_or_vendor_field_declaring_adjusted_or_raw_close" in summary["price_basis_required_evidence"] + assert "model_build_or_candidate_promotion" in summary["price_basis_blocked_uses"] + assert summary["price_basis_user_guidance"][2]["section"] == "D4-D9 promotion" + assert summary["table_summaries_returned"] == 2 + assert summary["quality_scan_scope"] == "all_tables" + assert summary["quality_scan_complete"] is True + assert summary["material_unknown_adjustment_windows"][0]["table"] == "A000250" + assert summary["material_unknown_adjustment_windows"][0]["date"] == "2024-01-04" + assert "open_to_previous_close_ratio" in summary["material_unknown_adjustment_windows"][0] + flags = {(row["table"], row["flag"]) for row in summary["quality_flags"]} + assert ("A000250", "split_like_discontinuity_count") in flags + assert ("A035720", "nonpositive_ohlc_rows") in flags + assert ("A035720", "ohlc_inconsistency_rows") in flags + + +def test_write_db_summary_artifacts_stays_under_generated_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + db_path = _create_daily_db(tmp_path / "daily.db") + summary = summarize_daily_db(db_path, table_limit=1, quality_table_limit=1) + import stom_rl.daily_ohlcv_db as daily_db + + safe_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_db_summary" + monkeypatch.setattr(daily_db, "DEFAULT_ARTIFACT_ROOT", safe_root) + written = daily_db.write_db_summary_artifacts(summary, run_id="unit_run") + assert Path(written["db_summary_path"]).exists() + assert Path(written["table_summaries_path"]).exists() + assert Path(written["quality_flags_path"]).exists() + assert Path(written["price_basis_audit_path"]).exists() + assert Path(written["price_basis_windows_path"]).exists() + with pytest.raises(ValueError): + daily_db.write_db_summary_artifacts(summary, artifact_root=tmp_path / "elsewhere", run_id="bad") + with pytest.raises(ValueError): + daily_db.write_db_summary_artifacts(summary, run_id="..") diff --git a/tests/test_stom_rl_daily_ohlcv_universe.py b/tests/test_stom_rl_daily_ohlcv_universe.py new file mode 100644 index 000000000..a0bbdaa4a --- /dev/null +++ b/tests/test_stom_rl_daily_ohlcv_universe.py @@ -0,0 +1,264 @@ +import sqlite3 +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from stom_rl.daily_ohlcv_db import EXPECTED_COLUMNS # noqa: E402 +from stom_rl.daily_ohlcv_universe import ( # noqa: E402 + UNIVERSE_BLOCKED_USES_WHEN_VERIFIED, + UNIVERSE_BLOCKED_USES_WHEN_WATCH, + UNIVERSE_REQUIRED_EVIDENCE, + build_universe_manifest, + classify_daily_table, + load_official_metadata_csv, + load_stockinfo, + write_universe_artifacts, +) + + +def _create_daily_db(path: Path, tables: list[str]) -> Path: + conn = sqlite3.connect(path) + cols = ", ".join([f'"{col}" REAL' for col in EXPECTED_COLUMNS if col != "date"]) + for table in tables: + conn.execute(f'CREATE TABLE "{table}" ("date" TEXT, {cols})') + conn.execute( + f'INSERT INTO "{table}" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + ("2024-01-02", 10.0, 11.0, 9.0, 10.0, 100.0, 10, 0, 0, 0, 0, 0), + ) + conn.commit() + conn.close() + return path + + +def _create_stockinfo_db(path: Path) -> Path: + conn = sqlite3.connect(path) + conn.execute('CREATE TABLE stockinfo ("index" TEXT, "종목명" TEXT, "코스닥" INTEGER)') + rows = [ + ("000250", "삼천당제약", 1), + ("005930", "삼성전자", 0), + ("069500", "KODEX 200", 0), + ("580001", "미래에셋 레버리지 원유선물혼합 ETN", 0), + ("123456", "테스트스팩1호", 1), + ("654321", "테스트리츠", 0), + ("005935", "삼성전자우", 0), + ("195940", "HK이노엔", 1), + ("111111", "시장없음테스트", None), + ] + conn.executemany('INSERT INTO stockinfo VALUES (?, ?, ?)', rows) + conn.commit() + conn.close() + return path +def _create_official_metadata_csv(path: Path) -> Path: + path.write_text( + "\n".join( + [ + "code,name,market,instrument_type,source", + "000250,삼천당제약,KOSDAQ,common_equity,unit_krx_csv", + "005930,삼성전자,KOSPI,common_equity,unit_krx_csv", + "069500,KODEX 200,KOSPI,etf,unit_krx_csv", + "005935,삼성전자우,KOSPI,preferred_stock,unit_krx_csv", + "123456,테스트스팩1호,KOSDAQ,spac,unit_krx_csv", + ] + ), + encoding="utf-8", + ) + return path + + + + +def test_load_stockinfo_preserves_codes_and_names(tmp_path: Path): + stockinfo_path = _create_stockinfo_db(tmp_path / "stockinfo.db") + records = load_stockinfo(stockinfo_path) + assert records["000250"].name == "삼천당제약" + assert records["000250"].kosdaq == 1 + assert "5930" not in records + +def test_load_official_metadata_csv_preserves_codes_and_required_contract(tmp_path: Path): + path = _create_official_metadata_csv(tmp_path / "krx.csv") + records = load_official_metadata_csv(path) + assert records["000250"].market == "KOSDAQ" + assert records["005935"].instrument_type == "preferred_stock" + assert "250" not in records + duplicate_path = tmp_path / "duplicate_krx.csv" + duplicate_path.write_text( + "code,name,market,instrument_type\n000250,삼천당제약,KOSDAQ,common_equity\n000250,중복,KOSDAQ,common_equity\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="duplicate code"): + load_official_metadata_csv(duplicate_path) + short_code_path = tmp_path / "short_code_krx.csv" + short_code_path.write_text( + "code,name,market,instrument_type\n250,삼천당제약,KOSDAQ,common_equity\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="6-character string"): + load_official_metadata_csv(short_code_path) + blank_name_path = tmp_path / "blank_name_krx.csv" + blank_name_path.write_text( + "code,name,market,instrument_type\n000250,,KOSDAQ,common_equity\n", + encoding="utf-8", + ) + with pytest.raises(ValueError, match="name is required"): + load_official_metadata_csv(blank_name_path) + + + +def test_classify_daily_table_includes_common_kospi_kosdaq_and_preserves_metadata(tmp_path: Path): + stockinfo_path = _create_stockinfo_db(tmp_path / "stockinfo.db") + stockinfo = load_stockinfo(stockinfo_path) + kosdaq = classify_daily_table("A000250", stockinfo) + kospi = classify_daily_table("A005930", stockinfo) + hk_common = classify_daily_table("A195940", stockinfo) + assert kosdaq["include"] is True + assert kosdaq["code"] == "000250" + assert kosdaq["market"] == "KOSDAQ" + assert kosdaq["instrument_type"] == "common_equity" + assert kosdaq["review_status"] == "heuristic_watch" + assert kosdaq["classification_source"] == "stockinfo_name_market_heuristic" + assert isinstance(kosdaq["metadata_sha"], str) and len(kosdaq["metadata_sha"]) == 64 + assert kosdaq["official_metadata_status"] == "not_available" + assert kospi["include"] is True + assert kospi["market"] == "KOSPI" + assert hk_common["include"] is True + + +@pytest.mark.parametrize( + ("table", "expected_reason"), + [ + ("A069500", "ETF_ETN_FUND_NAME_PREFIX"), + ("A580001", "ETF_ETN_FUND_NAME_TOKEN"), + ("Q580001", "Q_PRODUCT_TABLE"), + ("A123456", "SPAC_EXCLUDED"), + ("A654321", "REIT_EXCLUDED"), + ("A005935", "PREFERRED_SHARE_EXCLUDED"), + ("A999999", "METADATA_UNMATCHED"), + ("A0017J0", "ALPHANUMERIC_CODE_UNREVIEWED"), + ("A111111", "UNKNOWN_MARKET_METADATA"), + ], +) +def test_classify_daily_table_excludes_products_and_uncertain_symbols(tmp_path: Path, table: str, expected_reason: str): + stockinfo_path = _create_stockinfo_db(tmp_path / "stockinfo.db") + stockinfo = load_stockinfo(stockinfo_path) + payload = classify_daily_table(table, stockinfo) + assert payload["include"] is False + assert payload["exclusion_reason"] == expected_reason + assert payload["review_status"] in {"excluded_by_default", "quarantined_unmatched", "quarantined_unknown_market"} + assert payload["classification_source"] + assert "metadata_sha" in payload + + +def test_build_universe_manifest_contract_counts_and_watch_verdict(tmp_path: Path): + daily_path = _create_daily_db( + tmp_path / "daily.db", + ["A000250", "A005930", "A069500", "Q580001", "A005935", "A999999", "A0017J0", "A111111"], + ) + stockinfo_path = _create_stockinfo_db(tmp_path / "stockinfo.db") + manifest = build_universe_manifest(daily_path, stockinfo_path) + assert manifest["verdict"] == "WATCH_HEURISTIC_UNIVERSE" + assert manifest["table_count"] == 8 + assert manifest["stockinfo_matched_table_count"] == 6 + assert manifest["stockinfo_unmatched_table_count"] == 2 + assert manifest["include_count"] == 2 + assert manifest["exclude_count"] == 6 + assert manifest["unmatched_count"] == 2 + assert manifest["metadata_unmatched_count"] == 1 + assert manifest["q_product_count"] == 1 + assert set(manifest["required_fields"]) == { + "classification_source", + "classification_confidence", + "exclusion_reason", + "metadata_sha", + "review_status", + "official_metadata_status", + "official_metadata_coverage_status", + "universe_certification_status", + } + assert manifest["official_metadata_status"] == "MISSING" + assert manifest["official_metadata"]["review_status"] == "WATCH_OFFICIAL_METADATA_REQUIRED" + assert manifest["official_metadata_coverage_status"] == "MISSING" + assert manifest["universe_certification_status"] == "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW" + assert manifest["universe_required_evidence"] == list(UNIVERSE_REQUIRED_EVIDENCE) + assert manifest["universe_blocked_uses"] == list(UNIVERSE_BLOCKED_USES_WHEN_WATCH) + assert "model_build_or_candidate_promotion" in manifest["universe_blocked_uses"] + assert manifest["official_metadata_unmatched_table_count"] == 8 + assert manifest["quarantine_artifact_count"] == 3 + by_code = {row["code"]: row for row in manifest["symbols"]} + assert by_code["000250"]["include"] is True + assert by_code["069500"]["include"] is False + assert by_code["999999"]["exclusion_reason"] == "METADATA_UNMATCHED" + assert by_code["111111"]["exclusion_reason"] == "UNKNOWN_MARKET_METADATA" + assert len(manifest["manifest_sha"]) == 64 + +def test_build_universe_manifest_can_use_official_metadata_without_overclaim(tmp_path: Path): + daily_path = _create_daily_db( + tmp_path / "daily.db", + ["A000250", "A005930", "A069500", "A005935", "A123456", "A999999"], + ) + stockinfo_path = _create_stockinfo_db(tmp_path / "stockinfo.db") + official_path = _create_official_metadata_csv(tmp_path / "krx.csv") + manifest = build_universe_manifest(daily_path, stockinfo_path, official_metadata_path=official_path) + assert manifest["verdict"] == "WATCH_HEURISTIC_UNIVERSE" + assert manifest["official_metadata_status"] == "LOADED" + assert manifest["official_metadata_matched_table_count"] == 5 + assert manifest["official_metadata_coverage_status"] == "PARTIAL" + assert manifest["universe_certification_status"] == "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW" + assert manifest["universe_blocked_uses"] == list(UNIVERSE_BLOCKED_USES_WHEN_WATCH) + assert manifest["official_metadata_unmatched_table_count"] == 1 + by_code = {row["code"]: row for row in manifest["symbols"]} + assert by_code["000250"]["include"] is True + assert by_code["000250"]["classification_source"] == "official_metadata_csv" + assert by_code["000250"]["review_status"] == "official_metadata_reviewed" + assert by_code["069500"]["include"] is False + assert by_code["069500"]["exclusion_reason"] == "OFFICIAL_ETF_ETN_FUND_EXCLUDED" + assert by_code["005935"]["exclusion_reason"] == "OFFICIAL_PREFERRED_SHARE_EXCLUDED" + assert by_code["123456"]["exclusion_reason"] == "OFFICIAL_SPAC_EXCLUDED" + assert by_code["999999"]["official_metadata_status"] == "not_used" + assert by_code["999999"]["exclusion_reason"] == "METADATA_UNMATCHED" + +def test_build_universe_manifest_can_clear_only_with_complete_official_metadata(tmp_path: Path): + daily_path = _create_daily_db( + tmp_path / "daily.db", + ["A000250", "A005930", "A069500", "A005935", "A123456"], + ) + stockinfo_path = _create_stockinfo_db(tmp_path / "stockinfo.db") + official_path = _create_official_metadata_csv(tmp_path / "krx.csv") + manifest = build_universe_manifest(daily_path, stockinfo_path, official_metadata_path=official_path) + + assert manifest["verdict"] == "OFFICIAL_OR_MANUAL_REVIEWED" + assert manifest["review_status"] == "OFFICIAL_OR_MANUAL_REVIEWED" + assert manifest["official_metadata_status"] == "OFFICIAL_VERIFIED" + assert manifest["official_metadata_coverage_status"] == "COMPLETE" + assert manifest["official_metadata"]["certification_status"] == "OFFICIAL_OR_MANUAL_REVIEWED" + assert manifest["universe_certification_status"] == "OFFICIAL_OR_MANUAL_REVIEWED" + assert manifest["universe_blocked_uses"] == list(UNIVERSE_BLOCKED_USES_WHEN_VERIFIED) + assert "model_build_or_candidate_promotion" not in manifest["universe_blocked_uses"] + assert manifest["official_metadata_unmatched_table_count"] == 0 + + + +def test_write_universe_artifacts_rejects_escape_and_writes_csvs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + daily_path = _create_daily_db(tmp_path / "daily.db", ["A000250", "A069500", "A999999"]) + stockinfo_path = _create_stockinfo_db(tmp_path / "stockinfo.db") + manifest = build_universe_manifest(daily_path, stockinfo_path) + import stom_rl.daily_ohlcv_universe as universe + + safe_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_universe" + monkeypatch.setattr(universe, "DEFAULT_UNIVERSE_ROOT", safe_root) + written = universe.write_universe_artifacts(manifest, run_id="universe_unit") + assert Path(written["universe_path"]).exists() + assert Path(written["symbols_path"]).read_text(encoding="utf-8").startswith("classification_confidence") + assert Path(written["exclusions_path"]).exists() + assert Path(written["official_metadata_audit_path"]).exists() + assert Path(written["quarantine_path"]).exists() + quarantine_text = Path(written["quarantine_path"]).read_text(encoding="utf-8") + assert "ALPHANUMERIC_CODE_UNREVIEWED" in quarantine_text or "METADATA_UNMATCHED" in quarantine_text + with pytest.raises(ValueError): + universe.write_universe_artifacts(manifest, artifact_root=tmp_path / "elsewhere", run_id="bad") + with pytest.raises(ValueError): + universe.write_universe_artifacts(manifest, run_id="..") diff --git a/tests/test_stom_rl_daily_portfolio_env.py b/tests/test_stom_rl_daily_portfolio_env.py new file mode 100644 index 000000000..898a445fc --- /dev/null +++ b/tests/test_stom_rl_daily_portfolio_env.py @@ -0,0 +1,346 @@ +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] + +import pytest +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from stom_rl.daily_portfolio_env import ( # noqa: E402 + FILL_ASSUMPTION, + DailyPortfolioEnv, + build_env_inspection, + OBSERVATION_MODE_ACTION_INDUCTION_V2, + build_observation_manifest, + candidates_by_date, + environment_contract, + write_env_inspection_artifacts, + validate_observation_manifest, +) + + +def _candidate_rows(): + return [ + {"date": "2024-01-01", "code": "20", "split": "train", "score_supervised_linear_ranker": 0.9, "future_return_1d": 0.01}, + {"date": "2024-01-01", "code": "000030", "split": "train", "score_supervised_linear_ranker": 0.5, "future_return_1d": -0.01}, + {"date": "2024-01-02", "code": "000020", "split": "train", "score_supervised_linear_ranker": 0.8, "future_return_1d": 0.02}, + {"date": "2024-01-02", "code": "000040", "split": "train", "score_supervised_linear_ranker": 0.7, "future_return_1d": 0.00}, + {"date": "2024-01-03", "code": "000020", "split": "train", "score_supervised_linear_ranker": 0.3, "future_return_1d": -0.01}, + ] + + +def test_candidates_preserve_leading_zero_codes_and_limit(): + grouped = candidates_by_date(_candidate_rows(), score_column="score_supervised_linear_ranker", candidate_limit=1) + assert grouped["2024-01-01"][0].code == "000020" + assert len(grouped["2024-01-01"]) == 1 + + +def test_action_masks_invalid_action_and_reward_breakdown(): + grouped = candidates_by_date(_candidate_rows(), score_column="score_supervised_linear_ranker", candidate_limit=2) + env = DailyPortfolioEnv(grouped, max_positions=2) + assert env.action_mask() == [True, True, False, False, False] + assert env.action_mask_details()["hold"]["reason"] == "always_valid_no_trade_or_hold" + assert env.action_mask_details()["add"]["reason"] == "blocked_no_position" + + _state, reward, done, info = env.step(1) + assert done is False + assert info["action"] == "buy" + assert info["requested_action"] == "buy" + assert info["executed_action"] == "buy" + assert info["positions"] == ["000020"] + assert info["turnover"] > 0 + assert info["cost"] > 0 + assert "exposure_penalty" in info + assert "drawdown_penalty" in info + assert "churn_penalty" in info + assert info["fill_assumption"] == FILL_ASSUMPTION + assert info["reward_components"]["turnover_cost"] == info["cost"] + assert info["net_return_after_cost"] == pytest.approx(info["gross_return"] - info["cost"]) + assert info["reward_components"]["net_return_after_cost"] == info["net_return_after_cost"] + assert info["action_mask"]["buy"] is True + assert reward == info["reward"] + + assert env.action_mask() == [True, False, True, True, False] + _state, _reward, _done, invalid_info = env.step(4) + assert invalid_info["invalid_action"] is True + assert invalid_info["action"] == "hold" + assert invalid_info["requested_action"] == "reduce" + assert invalid_info["executed_action"] == "hold" + assert invalid_info["invalid_action_reason"] == "blocked_requires_multiple_positions" + assert invalid_info["action_mask_reasons"]["reduce"] == "blocked_requires_multiple_positions" + assert invalid_info["invalid_action_penalty"] > 0 + assert env.invalid_actions == 1 + env.reset() + assert env.state() == (0, 1) + assert env.positions == [] + assert env.invalid_actions == 0 + assert env.current_drawdown == 0.0 + +def test_no_trade_hold_is_explicit_zero_cost_control(): + grouped = candidates_by_date(_candidate_rows(), score_column="score_supervised_linear_ranker", candidate_limit=2) + env = DailyPortfolioEnv(grouped, max_positions=2) + + _state, reward, _done, info = env.step(0) + + assert info["requested_action"] == "hold" + assert info["executed_action"] == "hold" + assert info["no_trade_action"] is True + assert info["positions"] == [] + assert info["gross_return"] == 0.0 + assert info["cost"] == 0.0 + assert info["net_return_after_cost"] == 0.0 + assert info["reward_components"]["no_trade_hold_reward"] == 0.0 + assert reward == pytest.approx( + -info["exposure_penalty"] + - info["concentration_penalty"] + - info["invalid_action_penalty"] + - info["churn_penalty"] + - info["drawdown_penalty"] + + info["no_trade_hold_reward"] + ) + +def test_unknown_action_is_reported_and_penalized(): + grouped = candidates_by_date(_candidate_rows(), score_column="score_supervised_linear_ranker", candidate_limit=2) + env = DailyPortfolioEnv(grouped, max_positions=2) + + _state, _reward, _done, info = env.step(99) + + assert info["requested_action"] == "unknown" + assert info["executed_action"] == "hold" + assert info["invalid_action"] is True + assert info["invalid_action_reason"] == "unknown_action" + assert info["invalid_action_penalty"] > 0 + assert env.invalid_actions == 1 + +def test_reward_formula_and_drawdown_penalty_are_explicit(): + rows = [ + {"date": "2024-03-01", "code": "000020", "split": "train", "score_supervised_linear_ranker": 1.0, "future_return_1d": 0.10}, + {"date": "2024-03-02", "code": "000020", "split": "train", "score_supervised_linear_ranker": 1.0, "future_return_1d": -0.20}, + ] + grouped = candidates_by_date(rows, score_column="score_supervised_linear_ranker", candidate_limit=1) + env = DailyPortfolioEnv(grouped, max_positions=1, drawdown_penalty=0.5, churn_penalty=0.001) + _state, _reward, _done, first = env.step(1) + components = first["reward_components"] + assert first["net_return_after_cost"] == pytest.approx( + components["daily_nav_return"] - components["turnover_cost"] + ) + assert first["reward"] == pytest.approx( + components["net_return_after_cost"] + - components["exposure_penalty"] + - components["concentration_penalty"] + - components["invalid_action_penalty"] + - components["churn_penalty"] + - components["drawdown_penalty"] + + components["no_trade_hold_reward"] + ) + _state, _reward, _done, second = env.step(0) + second_components = second["reward_components"] + assert second_components["drawdown_penalty"] > 0 + assert second["current_drawdown"] < 0 + assert second["reward"] == pytest.approx( + second_components["net_return_after_cost"] + - second_components["exposure_penalty"] + - second_components["concentration_penalty"] + - second_components["invalid_action_penalty"] + - second_components["churn_penalty"] + - second_components["drawdown_penalty"] + + second_components["no_trade_hold_reward"] + ) + + +def test_state_and_mask_do_not_expose_future_return_labels(): + base_rows = [ + {"date": "2024-02-01", "code": "000020", "split": "train", "score_supervised_linear_ranker": 1.0, "future_return_1d": 0.50}, + {"date": "2024-02-02", "code": "000020", "split": "train", "score_supervised_linear_ranker": 1.0, "future_return_1d": -0.50}, + ] + grouped = candidates_by_date(base_rows, score_column="score_supervised_linear_ranker", candidate_limit=1) + env = DailyPortfolioEnv(grouped, max_positions=1) + assert env.state() == (0, 1) + assert env.action_mask() == [True, True, False, False, False] + _state, _reward, _done, info = env.step(1) + assert info["gross_return"] == 0.50 + assert environment_contract()["state"]["lookahead_policy"].startswith("state uses current candidate scores") + + missing_label_rows = [ + {"date": "2024-02-01", "code": "000999", "split": "train", "score_supervised_linear_ranker": 2.0}, + {"date": "2024-02-01", "code": "000020", "split": "train", "score_supervised_linear_ranker": 1.0, "future_return_1d": 0.50}, + ] + grouped_missing = candidates_by_date(missing_label_rows, score_column="score_supervised_linear_ranker", candidate_limit=2) + assert grouped_missing["2024-02-01"][0].code == "000999" + env_missing = DailyPortfolioEnv(grouped_missing, max_positions=2) + assert env_missing.state() == (0, 1) + assert env_missing.action_mask() == [True, True, False, False, False] + _state, _reward, _done, missing_info = env_missing.step(1) + assert missing_info["positions"] == ["000999"] + assert missing_info["missing_reward_label_count"] == 1 + assert missing_info["gross_return"] == 0.0 +def test_action_induction_v2_state_uses_causal_buckets_without_current_label_leakage(): + base_rows = [ + {"date": "2024-02-01", "code": "000020", "split": "train", "score_supervised_linear_ranker": 0.010, "future_return_1d": 0.02}, + {"date": "2024-02-01", "code": "000030", "split": "train", "score_supervised_linear_ranker": 0.004, "future_return_1d": -0.01}, + {"date": "2024-02-02", "code": "000020", "split": "train", "score_supervised_linear_ranker": 0.012, "future_return_1d": 0.50}, + {"date": "2024-02-02", "code": "000030", "split": "train", "score_supervised_linear_ranker": 0.002, "future_return_1d": -0.50}, + ] + changed_current_label_rows = [ + {**row, "future_return_1d": (-0.50 if row["code"] == "000020" else 0.50)} + if row["date"] == "2024-02-02" + else dict(row) + for row in base_rows + ] + + grouped = candidates_by_date(base_rows, score_column="score_supervised_linear_ranker", candidate_limit=2) + changed_grouped = candidates_by_date(changed_current_label_rows, score_column="score_supervised_linear_ranker", candidate_limit=2) + env = DailyPortfolioEnv(grouped, max_positions=2, observation_mode=OBSERVATION_MODE_ACTION_INDUCTION_V2) + changed_env = DailyPortfolioEnv(changed_grouped, max_positions=2, observation_mode=OBSERVATION_MODE_ACTION_INDUCTION_V2) + env.step(0) + changed_env.step(0) + + assert env.state() == changed_env.state() + details = env.state_details() + assert len(env.state()) == 6 + assert details["score_margin_bucket"] > 0 + assert details["candidate_count_bucket"] == 2 + assert details["d3_confidence_bucket"] > 0 + + manifest = build_observation_manifest( + max_positions=2, + score_column="score_supervised_linear_ranker", + candidate_limit=2, + observation_mode=OBSERVATION_MODE_ACTION_INDUCTION_V2, + action_prior_mode="entry_bias_v1", + action_prior_strength=0.0005, + ) + report = validate_observation_manifest(manifest) + assert report["status"] == "PASS" + assert manifest["action_induction_v2"]["enabled"] is True + assert "future_return_1d" not in report["observation_fields"] + assert {"score_margin_bucket", "candidate_count_bucket", "recent_score_volatility_bucket", "d3_confidence_bucket"} <= set( + report["observation_fields"] + ) + + +def test_observation_manifest_covers_required_d4_state_gate(): + manifest = build_observation_manifest(max_positions=3, score_column="score_supervised_linear_ranker", candidate_limit=2) + report = validate_observation_manifest(manifest) + + assert report["status"] == "PASS" + assert manifest["gate"] == "D4_OBSERVATION_STATE_MANIFEST" + assert manifest["model_build_allowed"] is False + assert manifest["go_summary_allowed"] is False + assert manifest["reward_action_telemetry_sufficient_for_d4"] is False + assert manifest["cash_exposure"]["exposure_formula"] == "position_count / max_positions" + assert manifest["holdings_identity"]["leading_zero_policy"].startswith("zfill(6)") + assert manifest["candidate_rank_score_features"]["score_column"] == "score_supervised_linear_ranker" + assert manifest["horizon_alignment"]["reward_label"] == "future_return_1d" + assert manifest["frozen_d3_comparison"]["required"] is True + assert {"no_trade_cash", "shuffle_control", "supervised_linear_ranker"} <= set( + manifest["frozen_d3_comparison"]["required_baselines"] + ) + assert "reason_fields" in manifest["action_mask_semantics"] + assert "future_return_1d" not in report["observation_fields"] + + missing_section = dict(manifest) + missing_section.pop("cash_exposure") + assert validate_observation_manifest(missing_section)["status"] == "FAIL" + + leaky_manifest = { + **manifest, + "observation_fields": [*manifest["observation_fields"], {"name": "future_return_1d"}], + } + leaky_report = validate_observation_manifest(leaky_manifest) + assert leaky_report["status"] == "FAIL" + assert leaky_report["future_label_fields"] == ["future_return_1d"] + + failing_check_manifest = { + **manifest, + "leakage_checks": [ + {**check, "status": "FAIL"} if check["check"] == "future_label_availability_not_candidate_filter" else check + for check in manifest["leakage_checks"] + ], + } + failing_check_report = validate_observation_manifest(failing_check_manifest) + assert failing_check_report["status"] == "FAIL" + assert failing_check_report["failing_leakage_checks"] == ["future_label_availability_not_candidate_filter"] + + missing_check_manifest = { + **manifest, + "leakage_checks": [ + check for check in manifest["leakage_checks"] if check["check"] != "future_label_availability_not_candidate_filter" + ], + } + missing_check_report = validate_observation_manifest(missing_check_manifest) + assert missing_check_report["status"] == "FAIL" + assert missing_check_report["missing_leakage_checks"] == ["future_label_availability_not_candidate_filter"] + + duplicate_check_manifest = { + **manifest, + "leakage_checks": [ + *manifest["leakage_checks"], + {"check": "future_label_availability_not_candidate_filter", "status": "FAIL"}, + ], + } + duplicate_check_report = validate_observation_manifest(duplicate_check_manifest) + assert duplicate_check_report["status"] == "FAIL" + assert duplicate_check_report["duplicate_leakage_checks"] == ["future_label_availability_not_candidate_filter"] + assert duplicate_check_report["failing_leakage_checks"] == ["future_label_availability_not_candidate_filter"] + + +def test_env_inspection_artifacts_document_contract_and_reject_escape(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_portfolio_env as portfolio_env + + grouped = candidates_by_date(_candidate_rows(), score_column="score_supervised_linear_ranker", candidate_limit=2) + inspection = build_env_inspection(grouped, max_positions=2, scripted_actions=[1, 2, 4, 3]) + manifest = inspection["env_manifest"] + assert manifest["status"] == "RESEARCH_ONLY" + assert manifest["fill_assumption"] == FILL_ASSUMPTION + assert manifest["cost_round_trip_bp"] == 23 + assert manifest["model_build_allowed"] is False + assert "drawdown_penalty" in manifest["reward_components"] + assert "net_return_after_cost" in manifest["reward_components"] + assert "no_trade_hold_reward" in manifest["reward_components"] + assert manifest["observation_manifest_validation"]["status"] == "PASS" + assert manifest["observation_manifest"]["reward_action_telemetry_sufficient_for_d4"] is False + assert inspection["reward_breakdown"] + assert inspection["action_masks"][0]["mask_buy"] is True + assert "mask_reason_hold" in inspection["action_masks"][0] + assert inspection["action_masks"][0]["mask_reason_hold"] == "always_valid_no_trade_or_hold" + assert {row["code"] for row in inspection["positions"]} <= {"000020", "000030", "000040"} + assert inspection["state_observations"] + assert inspection["state_observations"][0]["cash_fraction"] == 1.0 + assert inspection["state_observations"][0]["exposure_fraction"] == 0.0 + assert inspection["state_observations"][0]["future_label_exposed"] is False + assert "top_candidate_reward_label_available" in inspection["state_observations"][0] + + root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio_env" + monkeypatch.setattr(portfolio_env, "DEFAULT_ENV_INSPECTION_ROOT", root) + written = write_env_inspection_artifacts(inspection, run_id="env_unit", artifact_root=root) + assert Path(written["env_manifest_path"]).exists() + assert Path(written["observation_manifest_path"]).exists() + assert Path(written["reward_breakdown_path"]).exists() + assert Path(written["action_masks_path"]).exists() + assert Path(written["positions_path"]).exists() + assert Path(written["state_observations_path"]).exists() + on_disk = Path(written["env_manifest_path"]).read_text(encoding="utf-8") + assert "no live/broker/orders" in on_disk + assert "net_return_after_cost" in on_disk + observation_on_disk = Path(written["observation_manifest_path"]).read_text(encoding="utf-8") + assert "reward_action_telemetry_sufficient_for_d4" in observation_on_disk + assert "future_return_1d" in observation_on_disk + with pytest.raises(FileExistsError): + write_env_inspection_artifacts(inspection, run_id="env_unit", artifact_root=root) + with pytest.raises(ValueError): + write_env_inspection_artifacts(inspection, run_id="../escape", artifact_root=root) + with pytest.raises(ValueError): + write_env_inspection_artifacts(inspection, run_id="bad", artifact_root=tmp_path / "elsewhere") + +def test_sell_clears_positions_and_reduce_requires_multiple_positions(): + grouped = candidates_by_date(_candidate_rows(), score_column="score_supervised_linear_ranker", candidate_limit=2) + env = DailyPortfolioEnv(grouped, max_positions=2) + env.step(1) + env.step(2) + assert len(env.positions) == 2 + assert env.action_mask()[4] is True + _state, _reward, _done, info = env.step(3) + assert info["action"] == "sell" + assert info["positions"] == [] diff --git a/tests/test_stom_rl_daily_prediction.py b/tests/test_stom_rl_daily_prediction.py new file mode 100644 index 000000000..92f36f637 --- /dev/null +++ b/tests/test_stom_rl_daily_prediction.py @@ -0,0 +1,265 @@ +import csv +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from stom_rl.daily_prediction import BASELINE_STRATEGIES, D3_BLOCKED_USES_WHEN_WATCH, D3_REQUIRED_EVIDENCE, run_daily_prediction # noqa: E402 + +FEATURE_COLUMNS = [ + "return_1d", + "return_5d", + "volatility_5d", + "volume_ratio_5d", + "hl_range", + "gap_from_prev_close", + "foreign_holding_ratio", + "institutional_net_buy", +] +LABEL_COLUMNS = ["future_return_1d", "future_direction_1d", "future_rank_pct_1d"] + + +def _write_csv(path: Path, rows: list[dict[str, object]]) -> None: + fields = sorted({key for row in rows for key in row.keys()}) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def _create_dataset_run(root: Path) -> Path: + run_dir = root / "dataset_unit" + run_dir.mkdir(parents=True) + manifest = { + "schema_version": 1, + "manifest_sha": "dataset-sha-unit", + "artifact_scope": "UNIT", + "price_basis": "unknown", + "price_basis_evidence": "unit unknown", + "price_basis_status": "UNKNOWN_CONFIRMED", + "decision_grade_return_status": "BLOCKED_UNTIL_PRICE_BASIS_VERIFIED", + "universe_verdict": "WATCH_HEURISTIC_UNIVERSE", + "universe_review_status": "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW", + "official_metadata_status": "MISSING", + "official_metadata_coverage_status": "MISSING", + "universe_certification_status": "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW", + "feature_columns": FEATURE_COLUMNS, + "label_columns": LABEL_COLUMNS, + "model_readiness": "DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS", + "decision_grade_status": "BLOCKED_BY_UPSTREAM_D0_D1_GUARDRAILS", + "upstream_gate_blockers": [ + "D0_PRICE_BASIS_NOT_VERIFIED", + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED", + ], + } + (run_dir / "dataset_manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + feature_rows = [] + label_rows = [] + split_rows = [] + dates = [f"2024-01-{idx:02d}" for idx in range(1, 10)] + split_by_date = {date: ("train" if idx < 5 else "val" if idx < 7 else "test") for idx, date in enumerate(dates)} + for idx, date in enumerate(dates): + for offset, code in enumerate(["000001", "000002", "000003"]): + momentum = (offset - 1) * 0.02 + idx * 0.001 + future = momentum * 0.5 + table = f"A{code}" + feature_rows.append( + { + "date": date, + "table": table, + "code": code, + "return_1d": momentum / 2, + "return_5d": momentum, + "volatility_5d": 0.01 + offset * 0.005, + "volume_ratio_5d": 1.0 + offset * 0.1, + "hl_range": 0.02, + "gap_from_prev_close": momentum / 3, + "foreign_holding_ratio": 2.0 + offset, + "institutional_net_buy": 100.0 * offset, + } + ) + label_rows.append( + { + "date": date, + "table": table, + "code": code, + "future_return_1d": future, + "future_direction_1d": int(future > 0), + "future_rank_pct_1d": offset / 2, + } + ) + split_rows.append( + { + "date": date, + "table": table, + "code": code, + "split": split_by_date[date], + "eligible_for_training": True, + "block_reason": "", + } + ) + _write_csv(run_dir / "feature_panel.csv", feature_rows) + _write_csv(run_dir / "label_panel.csv", label_rows) + _write_csv(run_dir / "split_assignments.csv", split_rows) + return run_dir + + +def test_run_daily_prediction_builds_baselines_and_watch_verdict(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_prediction as prediction + + dataset_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_dataset" + run_dir = _create_dataset_run(dataset_root) + monkeypatch.setattr(prediction, "DEFAULT_DATASET_ROOT", dataset_root) + result = run_daily_prediction(dataset_run_dir=run_dir, top_k=2) + manifest = result["manifest"] + assert manifest["fit_split"] == "train" + assert manifest["no_oos_retuning"] is True + assert manifest["price_basis"] == "unknown" + assert manifest["universe_verdict"] == "WATCH_HEURISTIC_UNIVERSE" + assert manifest["cost_assumption_round_trip_bp"] == 23 + assert manifest["status"] == "WATCH" + assert manifest["readiness_status"] == "D3_WATCH_RESEARCH_ONLY" + assert manifest["model_build_allowed"] is False + assert manifest["go_summary_allowed"] is False + assert manifest["verdict"]["status"] == "WATCH" + assert manifest["verdict"]["readiness_status"] == "D3_WATCH_RESEARCH_ONLY" + assert manifest["verdict"]["go_summary_allowed"] is False + assert manifest["verdict"]["model_build_allowed"] is False + assert manifest["dataset_run_id"] == run_dir.name + assert manifest["dataset_upstream_gate_blockers"] == [ + "D0_PRICE_BASIS_NOT_VERIFIED", + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED", + ] + assert manifest["d3_gate_blockers"] == [ + "D0_PRICE_BASIS_NOT_VERIFIED", + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED", + "D5_WALK_FORWARD_NOT_PASS", + "D3_BASELINE_WATCH_RESEARCH_ONLY", + ] + assert manifest["d3_required_evidence"] == list(D3_REQUIRED_EVIDENCE) + assert manifest["d3_blocked_uses"] == list(D3_BLOCKED_USES_WHEN_WATCH) + assert manifest["baseline_freeze_contract"]["deterministic_shuffle_method"] == "sha256(date:code)_ascending" + assert manifest["baseline_freeze_contract"]["frozen_dataset_manifest_sha"] == "dataset-sha-unit" + strategies = {row["strategy"] for row in result["baseline_metrics"]} + assert set(BASELINE_STRATEGIES).issubset(strategies) + assert "shuffle_control" in strategies + assert manifest["models"]["supervised_linear_ranker"]["train_row_count"] == 15 + assert manifest["models"]["supervised_linear_ranker"]["training_policy"] == "fit_train_split_only_no_oos_retuning" + cash = next(row for row in result["baseline_metrics"] if row["strategy"] == "no_trade_cash") + assert cash["total_net_return"] == 0.0 + market_proxy = next(row for row in result["baseline_metrics"] if row["strategy"] == "market_proxy") + assert market_proxy["mean_turnover"] == 1.0 + shuffle = next(row for row in result["baseline_metrics"] if row["strategy"] == "shuffle_control") + assert shuffle["strategy_family"] == "control" + assert shuffle["is_shuffle_control"] is True + assert shuffle["positions"] > 0 + assert all("delta_vs_shuffle_control_total_net_return" in row for row in result["baseline_metrics"]) + assert all("delta_vs_best_rule_baseline_total_net_return" in row for row in result["baseline_metrics"]) + summary = manifest["baseline_delta_summary"] + metrics_by_strategy = {row["strategy"]: row for row in result["baseline_metrics"]} + for row in result["baseline_metrics"]: + assert row["delta_vs_shuffle_control_total_net_return"] == pytest.approx( + row["total_net_return"] - shuffle["total_net_return"] + ) + assert row["delta_vs_best_rule_baseline_total_net_return"] == pytest.approx( + row["total_net_return"] - metrics_by_strategy[summary["best_rule_baseline_strategy"]]["total_net_return"] + ) + best_rule = max( + (row for row in result["baseline_metrics"] if row["strategy_family"] == "rule_baseline"), + key=lambda row: row["total_net_return"], + ) + best_supervised = max( + (row for row in result["baseline_metrics"] if row["strategy_family"] == "supervised"), + key=lambda row: row["total_net_return"], + ) + assert result["baseline_delta_summary"] == summary + assert summary["shuffle_control_strategy"] == "shuffle_control" + assert summary["best_rule_baseline_strategy"] in strategies + assert summary["best_supervised_strategy"] in {"supervised_linear_ranker", "supervised_direction_classifier"} + assert summary["model_build_allowed"] is False + assert summary["go_summary_allowed"] is False + assert summary["readiness_status"] == "D3_WATCH_RESEARCH_ONLY" + assert summary["d3_gate_blockers"] == manifest["d3_gate_blockers"] + assert summary["deterministic_shuffle_method"] == "sha256(date:code)_ascending" + assert summary["best_rule_baseline_strategy"] == best_rule["strategy"] + assert summary["best_supervised_strategy"] == best_supervised["strategy"] + assert summary["best_supervised_delta_vs_best_rule_baseline"] == pytest.approx(best_supervised["total_net_return"] - best_rule["total_net_return"]) + assert summary["best_supervised_delta_vs_shuffle_control"] == pytest.approx(best_supervised["total_net_return"] - shuffle["total_net_return"]) + repeat = run_daily_prediction(dataset_run_dir=run_dir, top_k=2) + shuffle_positions = [(row["date"], row["rank"], row["code"]) for row in result["topk_positions"] if row["strategy"] == "shuffle_control"] + repeat_shuffle_positions = [(row["date"], row["rank"], row["code"]) for row in repeat["topk_positions"] if row["strategy"] == "shuffle_control"] + assert repeat_shuffle_positions == shuffle_positions + assert result["topk_positions"] + assert {row["code"] for row in result["topk_positions"]} <= {"000001", "000002", "000003"} + assert all(row["split"] in {"val", "test"} for row in result["topk_positions"]) + assert result["calibration"] + +def test_run_daily_prediction_treats_unverified_price_basis_as_blocked(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_prediction as prediction + + dataset_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_dataset" + run_dir = _create_dataset_run(dataset_root) + manifest_path = run_dir / "dataset_manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest.update( + { + "price_basis": "raw", + "price_basis_status": "UNVERIFIED", + "decision_grade_return_status": "READY_FOR_DECISION_GRADE_RETURNS", + "universe_verdict": "OFFICIAL_OR_MANUAL_REVIEWED", + "universe_review_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "official_metadata_status": "OFFICIAL_VERIFIED", + "official_metadata_coverage_status": "COMPLETE", + "universe_certification_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "upstream_gate_blockers": [], + } + ) + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + monkeypatch.setattr(prediction, "DEFAULT_DATASET_ROOT", dataset_root) + + result = run_daily_prediction(dataset_run_dir=run_dir, top_k=2) + + assert result["manifest"]["price_basis"] == "raw" + assert "D0_PRICE_BASIS_NOT_VERIFIED" in result["manifest"]["d3_gate_blockers"] + assert "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED" not in result["manifest"]["d3_gate_blockers"] + + +def test_write_prediction_artifacts_rejects_escape_and_duplicate_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_prediction as prediction + + dataset_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_dataset" + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + run_dir = _create_dataset_run(dataset_root) + monkeypatch.setattr(prediction, "DEFAULT_DATASET_ROOT", dataset_root) + monkeypatch.setattr(prediction, "DEFAULT_PREDICTION_ROOT", prediction_root) + result = run_daily_prediction(dataset_run_dir=run_dir, top_k=2) + written = prediction.write_prediction_artifacts(result, run_id="prediction_unit") + assert Path(written["prediction_manifest_path"]).exists() + assert Path(written["baseline_metrics_path"]).exists() + assert Path(written["model_metrics_path"]).exists() + assert Path(written["baseline_delta_summary_path"]).exists() + assert Path(written["topk_positions_path"]).exists() + assert Path(written["predictions_path"]).exists() + manifest = json.loads(Path(written["prediction_manifest_path"]).read_text(encoding="utf-8")) + assert manifest["run_id"] == "prediction_unit" + assert manifest["status"] == "WATCH" + assert manifest["readiness_status"] == "D3_WATCH_RESEARCH_ONLY" + assert manifest["model_build_allowed"] is False + assert manifest["go_summary_allowed"] is False + assert manifest["artifact_hashes"]["baseline_metrics"] + assert written["prediction_manifest_sha256"] + assert written["artifact_hashes"]["prediction_manifest"] == written["prediction_manifest_sha256"] + assert manifest["verdict"]["status"] == "WATCH" + assert manifest["verdict"]["readiness_status"] == "D3_WATCH_RESEARCH_ONLY" + assert manifest["baseline_delta_summary"]["shuffle_control_strategy"] == "shuffle_control" + with pytest.raises(FileExistsError): + prediction.write_prediction_artifacts(result, run_id="prediction_unit") + with pytest.raises(ValueError): + prediction.write_prediction_artifacts(result, artifact_root=tmp_path / "elsewhere", run_id="bad") + with pytest.raises(ValueError): + prediction.write_prediction_artifacts(result, run_id="..") diff --git a/tests/test_stom_rl_daily_ranker.py b/tests/test_stom_rl_daily_ranker.py new file mode 100644 index 000000000..2ebadefa0 --- /dev/null +++ b/tests/test_stom_rl_daily_ranker.py @@ -0,0 +1,34 @@ +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from stom_rl.daily_ranker import fit_direction_classifier, fit_linear_ranker, score_direction_probability, score_row # noqa: E402 + + +def _rows(): + return [ + {"split": "train", "eligible_for_training": True, "return_5d": -0.02, "volatility_5d": 0.01, "future_return_1d": -0.01, "future_direction_1d": 0}, + {"split": "train", "eligible_for_training": True, "return_5d": 0.01, "volatility_5d": 0.02, "future_return_1d": 0.02, "future_direction_1d": 1}, + {"split": "train", "eligible_for_training": True, "return_5d": 0.03, "volatility_5d": 0.02, "future_return_1d": 0.04, "future_direction_1d": 1}, + {"split": "test", "eligible_for_training": True, "return_5d": 99.0, "volatility_5d": 0.02, "future_return_1d": 999.0, "future_direction_1d": 1}, + ] + + +def test_linear_ranker_fits_train_split_only_and_scores_order(): + model = fit_linear_ranker(_rows(), feature_columns=["return_5d", "volatility_5d"], target_column="future_return_1d") + assert model.train_row_count == 3 + assert model.target_column == "future_return_1d" + low = score_row(model, {"return_5d": -0.02, "volatility_5d": 0.01}) + high = score_row(model, {"return_5d": 0.04, "volatility_5d": 0.02}) + assert high > low + assert model.to_dict()["training_policy"] == "fit_train_split_only_no_oos_retuning" + + +def test_direction_classifier_probability_is_bounded(): + model = fit_direction_classifier(_rows(), feature_columns=["return_5d", "volatility_5d"]) + probability = score_direction_probability(model, {"return_5d": 0.02, "volatility_5d": 0.01}) + assert 0.0 < probability < 1.0 + assert model.model_kind == "supervised_direction_classifier" diff --git a/tests/test_stom_rl_daily_registry.py b/tests/test_stom_rl_daily_registry.py new file mode 100644 index 000000000..534d4142e --- /dev/null +++ b/tests/test_stom_rl_daily_registry.py @@ -0,0 +1,444 @@ +import csv +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from stom_rl import daily_registry # noqa: E402 + + +def _write_json(path: Path, payload: dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _write_csv(path: Path, rows: list[dict[str, object]], fieldnames: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fieldnames) + writer.writeheader() + for row in rows: + writer.writerow(row) + + +def _synthetic_source_runs(tmp_path: Path) -> tuple[Path, Path]: + portfolio = tmp_path / "portfolio" / "portfolio_000250" + walk_forward = tmp_path / "walk_forward" / "walk_forward_000250" + _write_json( + portfolio / "rl_manifest.json", + { + "schema_version": 1, + "run_id": "portfolio_000250", + "prediction_manifest_sha": "pred-sha-000250", + "price_basis": "unknown", + "universe_review_status": "WATCH_HEURISTIC_UNIVERSE", + "score_column": "score", + "cost_assumption_round_trip_bp": 23, + "guardrail": "RESEARCH_ONLY no live/broker/orders", + }, + ) + _write_json( + portfolio / "verdict.json", + { + "status": "RESEARCH_ONLY", + "implementation_unlocked": False, + "go_summary_allowed": False, + "gate_dependency": "D3_WATCH_D5_NOT_RUN", + }, + ) + _write_json( + portfolio / "baseline_comparison.json", + { + "policy_strategy": "tabular_q_constrained_daily_portfolio_rl", + "delta_vs_best_d3_total_net_return": -0.62, + "cost_round_trip_bp": 23, + }, + ) + _write_json( + portfolio / "policy_evaluation_manifest.json", + { + "policy_baseline_comparison_rows": 6, + "model_build_allowed": False, + "no_live_broker_order_readiness": True, + }, + ) + _write_csv( + portfolio / "policy_nav.csv", + [ + {"split": "val", "date": "2026-01-02", "policy_nav": 1.0, "policy_reward": 0.0, "policy_turnover": 0.0, "policy_concentration": 0.0, "policy_current_drawdown": 0.0}, + {"split": "test", "date": "2026-01-03", "policy_nav": 0.98, "policy_reward": -0.02, "policy_turnover": 0.0, "policy_concentration": 0.0, "policy_current_drawdown": -0.02}, + ], + ["split", "date", "policy_nav", "policy_reward", "policy_turnover", "policy_concentration", "policy_current_drawdown"], + ) + _write_json( + walk_forward / "walk_forward_manifest.json", + { + "schema_version": 1, + "run_id": "walk_forward_000250", + "no_oos_retuning": True, + "guardrail": "NO-GO no live/broker/orders", + }, + ) + _write_json( + walk_forward / "gate_verdict.json", + { + "status": "NO-GO", + "model_build_allowed": False, + "go_summary_allowed": False, + "selected_strategy": "equal_weight_topk_momentum", + "price_basis": "unknown", + "universe_review_status": "WATCH_HEURISTIC_UNIVERSE", + "cost_round_trip_bp": 23, + "reasons": ["RL_POLICY_UNDERPERFORMS_D3_BASELINE"], + }, + ) + _write_csv( + walk_forward / "fold_metrics.csv", + [{"fold_id": "F01", "strategy": "equal_weight_topk_momentum", "total_net_return": 0.01}], + ["fold_id", "strategy", "total_net_return"], + ) + return portfolio, walk_forward +def _write_clean_pass_gates(portfolio: Path, walk_forward: Path) -> None: + _write_json( + portfolio / "rl_manifest.json", + { + "schema_version": 1, + "run_id": "portfolio_000250", + "prediction_manifest_sha": "pred-sha-000250", + "price_basis": "adjusted", + "price_basis_status": "ADJUSTED_VERIFIED", + "decision_grade_return_status": "READY", + "verdict": "OFFICIAL_OR_MANUAL_REVIEWED", + "universe_review_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "official_metadata_status": "OFFICIAL_VERIFIED", + "official_metadata_coverage_status": "COMPLETE", + "universe_certification_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "score_column": "score", + "cost_assumption_round_trip_bp": 23, + "guardrail": "RESEARCH_ONLY no live/broker/orders", + }, + ) + _write_json( + portfolio / "verdict.json", + { + "status": "PASS", + "implementation_unlocked": True, + "go_summary_allowed": True, + "gate_dependency": "D5_PASS_SYNTHETIC", + }, + ) + _write_json( + walk_forward / "gate_verdict.json", + { + "status": "PASS", + "model_build_allowed": True, + "go_summary_allowed": True, + "selected_strategy": "optimistic_daily_portfolio_rl", + "price_basis": "adjusted", + "price_basis_status": "ADJUSTED_VERIFIED", + "decision_grade_return_status": "READY", + "verdict": "OFFICIAL_OR_MANUAL_REVIEWED", + "universe_review_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "official_metadata_status": "OFFICIAL_VERIFIED", + "official_metadata_coverage_status": "COMPLETE", + "universe_certification_status": "OFFICIAL_OR_MANUAL_REVIEWED", + "cost_round_trip_bp": 23, + "reasons": [], + }, + ) + + + + +def test_daily_registry_builds_blocked_research_only_payload(tmp_path: Path): + portfolio, walk_forward = _synthetic_source_runs(tmp_path) + + result = daily_registry.build_daily_registry(portfolio_run_dir=portfolio, walk_forward_run_dir=walk_forward) + + manifest = result["manifest"] + candidate = result["candidate_registry"]["candidates"][0] + assert manifest["status"] == "RESEARCH_ONLY_BLOCKED" + assert manifest["model_build_allowed"] is False + assert manifest["live_broker_order_allowed"] is False + assert "no live/broker/orders" in manifest["guardrail"] + assert manifest["no_live_broker_order_readiness"] is True + assert candidate["candidate_id"] == "portfolio_000250" + assert candidate["promotion_status"] == "BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER" + assert candidate["model_build_allowed"] is False + assert candidate["paper_forward_allowed"] is False + assert candidate["live_broker_order_allowed"] is False + assert candidate["no_live_broker_order_readiness"] is True + assert candidate["cost_round_trip_bp"] == 23 + assert "PRICE_BASIS_UNKNOWN" in candidate["reasons"] + assert "UNIVERSE_WATCH_HEURISTIC" in candidate["reasons"] + assert len(candidate["config_hash"]) == 64 + assert len(candidate["data_hash"]) == 64 + assert len(candidate["code_hash"]) == 64 + assert "webui/daily_ohlcv_dashboard.py" in candidate["source_hashes"] + assert "webui/app.py" in candidate["source_hashes"] + assert "webui/v2_src/src/lib/dailyOhlcvApi.ts" in candidate["source_hashes"] + assert "webui/v2_src/src/tabs/DailyOhlcvTab.svelte" in candidate["source_hashes"] + assert "webui/v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte" in candidate["source_hashes"] + assert "webui/v2_src/src/tabs/dailyOhlcv/DailyVisualLabCard.svelte" in candidate["source_hashes"] + assert result["paper_selected"][0]["selection_status"] == "BLOCKED_BY_D5_NO_GO" + assert result["paper_selected"][0]["paper_only_selected"] is False + assert result["realized_returns"][1]["realized_return"] == pytest.approx(-0.02) + assert result["drawdown"][1]["paper_forward_drawdown"] == pytest.approx(-0.02) + drift_status = {row["metric"]: row["status"] for row in result["drift"]} + assert drift_status["price_basis"] == "BLOCKED" + assert drift_status["d5_gate_status"] == "BLOCKED" + assert result["decision_log"][2]["event"] == "live_broker_order_blocked" + assert drift_status["effective_model_gate"] == "BLOCKED" + + + +def test_daily_registry_effective_gate_blocks_optimistic_d5_with_research_blockers(tmp_path: Path): + portfolio, walk_forward = _synthetic_source_runs(tmp_path) + _write_json( + portfolio / "verdict.json", + { + "status": "PASS", + "implementation_unlocked": True, + "go_summary_allowed": True, + "gate_dependency": "D5_PASS_SYNTHETIC", + }, + ) + _write_json( + portfolio / "baseline_comparison.json", + { + "policy_strategy": "optimistic_daily_portfolio_rl", + "delta_vs_best_d3_total_net_return": 0.01, + "cost_round_trip_bp": 23, + }, + ) + _write_json( + walk_forward / "gate_verdict.json", + { + "status": "PASS", + "model_build_allowed": True, + "go_summary_allowed": True, + "selected_strategy": "optimistic_daily_portfolio_rl", + "price_basis": "unknown", + "universe_review_status": "WATCH_HEURISTIC_UNIVERSE", + "cost_round_trip_bp": 23, + "reasons": [], + }, + ) + + result = daily_registry.build_daily_registry(portfolio_run_dir=portfolio, walk_forward_run_dir=walk_forward) + + manifest = result["manifest"] + candidate = result["candidate_registry"]["candidates"][0] + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert candidate["model_build_allowed"] is False + assert candidate["paper_forward_allowed"] is False + assert candidate["promotion_status"] == "BLOCKED_RESEARCH_ONLY_NO_LIVE_BROKER_ORDER" + assert candidate["effective_gate_blockers"] == [ + "D0_PRICE_BASIS_NOT_VERIFIED", + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED", + ] + assert result["paper_selected"][0]["selection_status"] == "BLOCKED_BY_EFFECTIVE_RESEARCH_GATE" + assert "D0_PRICE_BASIS_NOT_VERIFIED" in result["paper_selected"][0]["reason"] + drift_status = {row["metric"]: row["status"] for row in result["drift"]} + assert drift_status["effective_model_gate"] == "BLOCKED" + + + +def test_daily_registry_blocks_noncanonical_d0_d1_even_when_other_gates_pass(tmp_path: Path): + portfolio, walk_forward = _synthetic_source_runs(tmp_path) + _write_clean_pass_gates(portfolio, walk_forward) + _write_json( + portfolio / "rl_manifest.json", + { + "schema_version": 1, + "run_id": "portfolio_000250", + "prediction_manifest_sha": "pred-sha-000250", + "price_basis": "adjusted_verified", + "price_basis_status": "READY", + "decision_grade_return_status": "READY", + "verdict": "OFFICIAL_COMMON_EQUITY_REVIEWED", + "universe_review_status": "OFFICIAL_COMMON_EQUITY_REVIEWED", + "official_metadata_status": "OFFICIAL_VERIFIED", + "official_metadata_coverage_status": "COMPLETE", + "universe_certification_status": "OFFICIAL_COMMON_EQUITY_REVIEWED", + "score_column": "score", + "cost_assumption_round_trip_bp": 23, + "guardrail": "RESEARCH_ONLY no live/broker/orders no profit claim", + }, + ) + _write_json( + portfolio / "baseline_comparison.json", + { + "policy_strategy": "optimistic_daily_portfolio_rl", + "delta_vs_best_d3_total_net_return": 0.01, + "cost_round_trip_bp": 23, + }, + ) + + result = daily_registry.build_daily_registry(portfolio_run_dir=portfolio, walk_forward_run_dir=walk_forward) + + candidate = result["candidate_registry"]["candidates"][0] + assert candidate["effective_gate_blockers"] == [ + "D0_PRICE_BASIS_NOT_VERIFIED", + "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED", + ] + assert candidate["model_build_allowed"] is False + assert "PRICE_BASIS_NOT_VERIFIED" in candidate["reasons"] + assert "UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED" in candidate["reasons"] + drift_status = {row["metric"]: row["status"] for row in result["drift"]} + assert drift_status["price_basis"] == "BLOCKED" + assert drift_status["universe_review_status"] == "WATCH" + +def test_daily_registry_blocks_missing_d3_baseline_evidence(tmp_path: Path): + portfolio, walk_forward = _synthetic_source_runs(tmp_path) + _write_clean_pass_gates(portfolio, walk_forward) + (portfolio / "baseline_comparison.json").unlink() + + result = daily_registry.build_daily_registry(portfolio_run_dir=portfolio, walk_forward_run_dir=walk_forward) + + manifest = result["manifest"] + candidate = result["candidate_registry"]["candidates"][0] + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert candidate["effective_gate_blockers"] == ["D3_BASELINE_EVIDENCE_MISSING"] + assert "D3_BASELINE_EVIDENCE_MISSING" in candidate["reasons"] + assert result["paper_selected"][0]["selection_status"] == "BLOCKED_BY_EFFECTIVE_RESEARCH_GATE" + drift = {row["metric"]: row["value"] for row in result["drift"]} + assert "D3_BASELINE_EVIDENCE_MISSING" in drift["effective_model_gate"] + + +def test_daily_registry_blocks_missing_policy_nav_evidence(tmp_path: Path): + portfolio, walk_forward = _synthetic_source_runs(tmp_path) + _write_clean_pass_gates(portfolio, walk_forward) + _write_json( + portfolio / "baseline_comparison.json", + { + "policy_strategy": "optimistic_daily_portfolio_rl", + "delta_vs_best_d3_total_net_return": 0.01, + "cost_round_trip_bp": 23, + }, + ) + (portfolio / "policy_nav.csv").unlink() + + result = daily_registry.build_daily_registry(portfolio_run_dir=portfolio, walk_forward_run_dir=walk_forward) + + manifest = result["manifest"] + candidate = result["candidate_registry"]["candidates"][0] + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert candidate["effective_gate_blockers"] == ["D9_POLICY_NAV_EVIDENCE_MISSING"] + assert result["realized_returns"][0]["evidence_status"] == "BLOCKED_MISSING_POLICY_NAV" + assert result["realized_returns"][0]["numeric_error"] == "POLICY_NAV_CSV_MISSING" + assert result["drawdown"][0]["evidence_status"] == "BLOCKED_MISSING_POLICY_NAV" + assert result["paper_selected"][0]["selection_status"] == "BLOCKED_BY_EFFECTIVE_RESEARCH_GATE" + + +def test_daily_registry_blocks_empty_policy_nav_evidence(tmp_path: Path): + portfolio, walk_forward = _synthetic_source_runs(tmp_path) + _write_clean_pass_gates(portfolio, walk_forward) + _write_json( + portfolio / "baseline_comparison.json", + { + "policy_strategy": "optimistic_daily_portfolio_rl", + "delta_vs_best_d3_total_net_return": 0.01, + "cost_round_trip_bp": 23, + }, + ) + _write_csv( + portfolio / "policy_nav.csv", + [], + ["split", "date", "policy_nav", "policy_reward", "policy_turnover", "policy_concentration", "policy_current_drawdown"], + ) + + result = daily_registry.build_daily_registry(portfolio_run_dir=portfolio, walk_forward_run_dir=walk_forward) + + manifest = result["manifest"] + candidate = result["candidate_registry"]["candidates"][0] + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert candidate["effective_gate_blockers"] == ["D9_POLICY_NAV_EVIDENCE_MISSING"] + assert result["realized_returns"][0]["evidence_status"] == "BLOCKED_MISSING_POLICY_NAV" + assert result["realized_returns"][0]["numeric_error"] == "POLICY_NAV_CSV_EMPTY" + assert result["drawdown"][0]["evidence_status"] == "BLOCKED_MISSING_POLICY_NAV" + assert result["drawdown"][0]["numeric_error"] == "POLICY_NAV_CSV_EMPTY" + assert result["paper_selected"][0]["selection_status"] == "BLOCKED_BY_EFFECTIVE_RESEARCH_GATE" + + +def test_daily_registry_default_root_stays_under_webui_rl_runs(): + expected = REPO_ROOT / "webui" / "rl_runs" / "daily_ohlcv_registry" + assert daily_registry.DEFAULT_DAILY_REGISTRY_ROOT.resolve() == expected.resolve() + + +def test_daily_registry_marks_invalid_policy_nav_numeric_evidence(tmp_path: Path): + portfolio, walk_forward = _synthetic_source_runs(tmp_path) + _write_clean_pass_gates(portfolio, walk_forward) + _write_json( + portfolio / "baseline_comparison.json", + { + "policy_strategy": "optimistic_daily_portfolio_rl", + "delta_vs_best_d3_total_net_return": 0.01, + "cost_round_trip_bp": 23, + }, + ) + _write_csv( + portfolio / "policy_nav.csv", + [ + {"split": "test", "date": "2026-01-02", "policy_nav": "not-a-number", "policy_reward": "nan", "policy_turnover": 0, "policy_concentration": 0, "policy_current_drawdown": "bad"}, + ], + ["split", "date", "policy_nav", "policy_reward", "policy_turnover", "policy_concentration", "policy_current_drawdown"], + ) + + result = daily_registry.build_daily_registry(portfolio_run_dir=portfolio, walk_forward_run_dir=walk_forward) + manifest = result["manifest"] + candidate = result["candidate_registry"]["candidates"][0] + + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert candidate["effective_gate_blockers"] == ["D9_POLICY_NAV_NUMERIC_EVIDENCE_INVALID"] + realized = result["realized_returns"][0] + drawdown = result["drawdown"][0] + assert realized["paper_nav"] is None + assert realized["realized_return"] is None + assert realized["policy_reward"] is None + assert realized["current_drawdown"] is None + assert realized["evidence_status"] == "BLOCKED_NUMERIC_EVIDENCE" + assert "INVALID_POLICY_NAV" in realized["numeric_error"] + assert "INVALID_POLICY_REWARD" in realized["numeric_error"] + assert "INVALID_POLICY_CURRENT_DRAWDOWN" in realized["numeric_error"] + assert drawdown["paper_forward_drawdown"] is None + assert drawdown["computed_drawdown"] is None + assert drawdown["evidence_status"] == "BLOCKED_NUMERIC_EVIDENCE" + + +def test_daily_registry_writer_creates_expected_artifacts_and_rejects_unsafe_paths(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + portfolio, walk_forward = _synthetic_source_runs(tmp_path) + result = daily_registry.build_daily_registry(portfolio_run_dir=portfolio, walk_forward_run_dir=walk_forward) + registry_root = tmp_path / "registry_root" + monkeypatch.setattr(daily_registry, "DEFAULT_DAILY_REGISTRY_ROOT", registry_root) + + receipt = daily_registry.write_registry_artifacts(result, run_id="registry_000250", overwrite=False) + out_dir = Path(receipt["artifact_dir"]) + + assert (out_dir / "registry_manifest.json").exists() + assert (out_dir / "candidate_registry.json").exists() + assert (out_dir / "paper_selected.csv").exists() + assert (out_dir / "realized_returns.csv").exists() + assert (out_dir / "drift.csv").exists() + assert (out_dir / "drawdown.csv").exists() + assert (out_dir / "decision_log.jsonl").exists() + manifest = json.loads((out_dir / "registry_manifest.json").read_text(encoding="utf-8")) + assert manifest["run_id"] == "registry_000250" + assert manifest["row_counts"]["decision_log_rows"] == 3 + + with pytest.raises(FileExistsError): + daily_registry.write_registry_artifacts(result, run_id="registry_000250", overwrite=False) + with pytest.raises(ValueError): + daily_registry.write_registry_artifacts(result, run_id="../bad", overwrite=True) + with pytest.raises(ValueError): + daily_registry.write_registry_artifacts(result, run_id="registry_outside", artifact_root=tmp_path / "outside", overwrite=True) diff --git a/tests/test_stom_rl_daily_rl_gate.py b/tests/test_stom_rl_daily_rl_gate.py new file mode 100644 index 000000000..3690ed46a --- /dev/null +++ b/tests/test_stom_rl_daily_rl_gate.py @@ -0,0 +1,471 @@ +import csv +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from stom_rl.daily_rl_train import ( # noqa: E402 + build_action_prior_values, + build_action_filter_decision, + build_action_distribution, + build_no_trade_opportunity_summary, + run_daily_rl, + write_rl_artifacts, +) + + +def _write_csv(path: Path, rows: list[dict[str, object]]) -> None: + fields = sorted({key for row in rows for key in row.keys()}) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def _create_prediction_run(root: Path, *, verdict_status: str = "WATCH") -> Path: + run_dir = root / "prediction_unit" + run_dir.mkdir(parents=True) + manifest = { + "schema_version": 1, + "manifest_sha": "prediction-sha-unit", + "price_basis": "unknown", + "price_basis_evidence": "unit unknown", + "universe_review_status": "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW", + "verdict": {"status": verdict_status, "go_summary_allowed": False}, + } + verdict = { + "schema_version": 1, + "status": verdict_status, + "go_summary_allowed": False, + "reasons": ["UNIT_TEST"], + } + baseline_metrics = { + "metrics": [ + {"strategy": "no_trade_cash", "total_net_return": 0.0, "max_drawdown": 0.0, "mean_turnover": 0.0}, + {"strategy": "equal_weight_topk_momentum", "total_net_return": 0.01, "max_drawdown": -0.01, "mean_turnover": 0.5}, + {"strategy": "supervised_linear_ranker", "total_net_return": 0.02, "max_drawdown": -0.02, "mean_turnover": 0.4}, + ] + } + rows = [] + split_by_index = {0: "train", 1: "train", 2: "train", 3: "val", 4: "val", 5: "test", 6: "test"} + for idx in range(7): + date = f"2024-01-{idx + 1:02d}" + split = split_by_index[idx] + for offset, code in enumerate(["000020", "000030", "000040"]): + score = 0.5 - offset * 0.1 + idx * 0.01 + future = (0.01 if offset == 0 else -0.002) + idx * 0.0005 + rows.append( + { + "date": date, + "table": f"A{code}", + "code": code, + "split": split, + "future_return_1d": future, + "score_supervised_linear_ranker": score, + "score_equal_weight_topk_momentum": score, + } + ) + (run_dir / "prediction_manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + (run_dir / "baseline_metrics.json").write_text(json.dumps(baseline_metrics), encoding="utf-8") + (run_dir / "verdict.json").write_text(json.dumps(verdict), encoding="utf-8") + _write_csv(run_dir / "predictions.csv", rows) + return run_dir + + +def test_action_distribution_preserves_invalid_and_no_trade_reasons(): + rows = [ + { + "split": "unit", + "action": "hold", + "requested_action": "reduce", + "executed_action": "hold", + "invalid_action": True, + "invalid_action_reason": "blocked_requires_multiple_positions", + "no_trade_action": False, + }, + { + "split": "unit", + "action": "hold", + "requested_action": "hold", + "executed_action": "hold", + "invalid_action": False, + "invalid_action_reason": "", + "no_trade_action": True, + }, + ] + + distribution = build_action_distribution(rows) + + invalid = next(row for row in distribution if row["invalid_action"]) + no_trade = next(row for row in distribution if row["no_trade_action"]) + assert invalid["requested_action"] == "reduce" + assert invalid["executed_action"] == "hold" + assert invalid["invalid_action_reason"] == "blocked_requires_multiple_positions" + assert invalid["action_rate"] == pytest.approx(0.5) + assert no_trade["requested_action"] == "hold" + assert no_trade["executed_action"] == "hold" + assert no_trade["action_rate"] == pytest.approx(0.5) + +def test_no_trade_opportunity_summary_is_post_policy_diagnostic_only(): + rows = [ + { + "split": "unit", + "no_trade_action": True, + "top_candidate_score": 0.8, + "top_candidate_net_after_entry_cost": 0.012, + "diagnostic_future_label_exposed": True, + "future_label_used_for_training_state": False, + }, + { + "split": "unit", + "no_trade_action": True, + "top_candidate_score": 0.3, + "top_candidate_net_after_entry_cost": -0.004, + "diagnostic_future_label_exposed": True, + "future_label_used_for_training_state": False, + }, + { + "split": "unit", + "no_trade_action": False, + "top_candidate_score": 0.9, + "top_candidate_net_after_entry_cost": 0.02, + "diagnostic_future_label_exposed": True, + "future_label_used_for_training_state": False, + }, + ] + + summary = build_no_trade_opportunity_summary(rows) + unit = summary["by_split"][0] + + assert summary["status"] == "POST_POLICY_DIAGNOSTIC_ONLY" + assert summary["training_state_uses_future_label"] is False + assert summary["diagnostic_uses_future_label_after_policy"] is True + assert unit["no_trade_rows"] == 2 + assert unit["missed_positive_no_trade_count"] == 1 + assert unit["risk_avoided_no_trade_count"] == 1 + assert unit["total_missed_positive_net_after_entry_cost"] == pytest.approx(0.012) + assert unit["total_risk_avoided_net_after_entry_cost"] == pytest.approx(-0.004) +def test_action_induction_v2_emits_richer_state_and_action_prior(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_rl_train as rl_train + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + run_dir = _create_prediction_run(prediction_root, verdict_status="WATCH") + monkeypatch.setattr(rl_train, "DEFAULT_PREDICTION_ROOT", prediction_root) + + priors = build_action_prior_values(mode="entry_bias_v1", strength=0.0005) + assert priors[1] == pytest.approx(0.0005) + assert priors[2] == pytest.approx(0.0005) + assert priors[0] == 0.0 + + result = run_daily_rl( + prediction_run_dir=run_dir, + episodes=2, + candidate_limit=2, + max_positions=2, + seed=3, + observation_mode="action_induction_v2", + action_prior_mode="entry_bias_v1", + action_prior_strength=0.0005, + ) + + manifest = result["manifest"] + observation_manifest = result["observation_manifest"] + first_state = result["state_observations"][0] + action_counts = {row["action"]: row["count"] for row in result["action_distribution"]} + + assert manifest["policy_type"] == "tabular_q_action_prior_v2" + assert manifest["observation_mode"] == "action_induction_v2" + assert observation_manifest["action_induction_v2"]["enabled"] is True + assert {"score_margin_bucket", "candidate_count_bucket", "recent_score_volatility_bucket", "d3_confidence_bucket"} <= { + field["name"] for field in observation_manifest["observation_fields"] + } + assert first_state["future_label_exposed"] is False + assert first_state["observation_state_key"].count("|") == 5 + assert first_state["action_prior_buy"] == pytest.approx(0.0005) + assert first_state["policy_score_buy"] == pytest.approx(first_state["policy_value_buy"] + first_state["action_prior_buy"]) + assert action_counts.get("buy", 0) > 0 + + +def test_trade_quality_filter_blocks_entry_without_future_label(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_rl_train as rl_train + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + run_dir = _create_prediction_run(prediction_root, verdict_status="WATCH") + monkeypatch.setattr(rl_train, "DEFAULT_PREDICTION_ROOT", prediction_root) + + decision = build_action_filter_decision( + mode="confidence_abstain_v1", + state_details={ + "d3_confidence_bucket": 1, + "score_margin_bucket": 4, + "recent_score_volatility_bucket": 0, + }, + mask=[True, True, False, False, False], + ) + assert decision["future_label_exposed"] is False + assert decision["filtered_mask"] == [True, False, False, False, False] + assert decision["blocked_entry_actions"] == ["buy"] + assert decision["reasons_by_action"]["buy"] == "blocked_confidence_bucket_below_threshold" + + with pytest.raises(ValueError, match="action_induction_v2 state fields"): + build_action_filter_decision( + mode="margin_abstain_v1", + state_details={"top_score_bucket": 1}, + mask=[True, True, False, False, False], + ) + + result = run_daily_rl( + prediction_run_dir=run_dir, + episodes=2, + candidate_limit=2, + max_positions=2, + seed=3, + observation_mode="action_induction_v2", + action_prior_mode="entry_bias_v1", + action_prior_strength=0.0005, + action_filter_mode="confidence_margin_joint_v1", + ) + + manifest = result["manifest"] + observation_manifest = result["observation_manifest"] + first_abstention = result["abstention_reasons"][0] + assert manifest["policy_type"] == "tabular_q_trade_quality_filter_v1" + assert manifest["action_filter_mode"] == "confidence_margin_joint_v1" + assert observation_manifest["trade_quality_filter"]["enabled"] is True + assert observation_manifest["trade_quality_filter"]["telemetry_artifact"] == "abstention_reasons.csv" + assert first_abstention["future_label_exposed"] is False + assert {"filter_reason_buy", "filtered_action_mask_hold_buy_add_sell_reduce"} <= set(first_abstention) + assert result["state_observations"][0]["action_filter_mode"] == "confidence_margin_joint_v1" + + +def test_run_daily_rl_emits_research_only_gate_and_required_metrics(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_rl_train as rl_train + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + portfolio_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio" + run_dir = _create_prediction_run(prediction_root, verdict_status="WATCH") + monkeypatch.setattr(rl_train, "DEFAULT_PREDICTION_ROOT", prediction_root) + monkeypatch.setattr(rl_train, "DEFAULT_PORTFOLIO_ROOT", portfolio_root) + + result = run_daily_rl(prediction_run_dir=run_dir, episodes=3, candidate_limit=2, max_positions=2, seed=3) + manifest = result["manifest"] + verdict = result["verdict"] + assert manifest["cost_assumption_round_trip_bp"] == 23 + assert "slippage" in manifest["slippage_assumption"] + assert manifest["action_space"][1] == "buy" + assert manifest["go_summary_allowed"] is False + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert manifest["live_broker_order_allowed"] is False + assert manifest["readiness_status"] == "D4_RESEARCH_ONLY_DIAGNOSTICS" + assert len(manifest["prediction_manifest_sha"]) == 64 + assert manifest["prediction_artifact_hashes"]["prediction_manifest"] == manifest["prediction_manifest_sha"] + assert set(manifest["prediction_artifact_hashes"]) == {"prediction_manifest", "predictions", "baseline_metrics", "verdict"} + assert all(len(value) == 64 for value in manifest["prediction_artifact_hashes"].values()) + assert manifest["prediction_artifact_hash_mismatches"] == [] + assert set(manifest["source_hashes"]) >= { + "stom_rl/daily_rl_train.py", + "stom_rl/daily_portfolio_env.py", + "stom_rl/daily_prediction.py", + } + assert all(len(value) == 64 for value in manifest["source_hashes"].values()) + assert manifest["state_contract_status"] == "PASS" + assert manifest["observation_manifest_validation"]["status"] == "PASS" + assert manifest["observation_manifest"]["reward_action_telemetry_sufficient_for_d4"] is False + assert manifest["observation_manifest"]["frozen_d3_comparison"]["required"] is True + assert verdict["status"] == "RESEARCH_ONLY" + assert verdict["ui_badge"] == "RESEARCH_ONLY" + assert verdict["gate_dependency"] == "D3_WATCH_D5_NOT_RUN" + assert verdict["go_summary_allowed"] is False + assert verdict["model_build_allowed"] is False + assert verdict["readiness_status"] == "D4_RESEARCH_ONLY_DIAGNOSTICS" + assert verdict["paper_forward_allowed"] is False + assert verdict["live_broker_order_allowed"] is False + eval_metric = next(row for row in result["policy_metrics"]["metrics"] if row["split"] == "val+test") + assert "invalid_action_rate" in eval_metric + assert eval_metric["invalid_action_rate"] == 0.0 + assert result["reward_breakdown"] + assert "slippage_cost" in result["reward_breakdown"][0] + assert {"requested_action", "executed_action", "invalid_action_reason", "no_trade_action"} <= set(result["reward_breakdown"][0]) + assert "net_return_after_cost" in result["reward_breakdown"][0] + assert "no_trade_hold_reward" in result["reward_breakdown"][0] + assert result["baseline_comparison"]["best_d3_strategy"] == "supervised_linear_ranker" + assert "slippage" in result["baseline_comparison"]["slippage_assumption"] + assert result["learning_curve"] + assert result["learning_curve"][0]["rolling_mean_reward"] == pytest.approx(result["episode_metrics"][0]["total_reward"]) + assert result["action_distribution"] + assert result["turnover"] + assert result["drawdown"] + assert result["reward_component_summary"]["by_split"] + assert "net_return_after_cost" in result["reward_component_summary"]["component_keys"] + assert "no_trade_hold_reward" in result["reward_component_summary"]["component_keys"] + assert result["reward_action_ablations"] + assert result["reward_action_ablation_summary"]["ablation_count"] == len(result["reward_action_ablations"]) + assert "without_turnover_cost" in result["reward_action_ablation_summary"]["ablation_names"] + assert result["no_trade_opportunity_diagnostics"] + assert result["no_trade_opportunity_summary"]["status"] == "POST_POLICY_DIAGNOSTIC_ONLY" + assert result["no_trade_opportunity_summary"]["training_state_uses_future_label"] is False + assert result["no_trade_opportunity_summary"]["diagnostic_uses_future_label_after_policy"] is True + assert result["source_hashes"] == manifest["source_hashes"] + assert manifest["telemetry"]["status"] == "READY_RESEARCH_ONLY" + assert "learning_curve.csv" in manifest["telemetry"]["canonical_artifacts"] + assert "observation_manifest.json" in manifest["telemetry"]["canonical_artifacts"] + assert "state_observations.csv" in manifest["telemetry"]["canonical_artifacts"] + assert "policy_baseline_comparison.csv" in manifest["telemetry"]["canonical_artifacts"] + assert "policy_nav.csv" in manifest["telemetry"]["canonical_artifacts"] + assert "reward_action_ablations.csv" in manifest["telemetry"]["canonical_artifacts"] + assert "reward_action_ablation_summary.json" in manifest["telemetry"]["canonical_artifacts"] + assert "no_trade_opportunity_diagnostics.csv" in manifest["telemetry"]["canonical_artifacts"] + assert "no_trade_opportunity_summary.json" in manifest["telemetry"]["canonical_artifacts"] + assert "abstention_reasons.csv" in manifest["telemetry"]["canonical_artifacts"] + assert "source_hashes.json" in manifest["telemetry"]["canonical_artifacts"] + assert {row["baseline_strategy"] for row in result["policy_baseline_comparison"]} == { + "no_trade_cash", + "shuffle_control", + "equal_weight_topk_momentum", + "vol_adjusted_momentum", + "supervised_linear_ranker", + "supervised_direction_classifier", + } + assert all(row["cost_round_trip_bp"] == 23 for row in result["policy_baseline_comparison"]) + assert result["policy_nav"] + assert result["state_observations"] + assert result["state_observations"][0]["future_label_exposed"] is False + assert result["no_trade_opportunity_diagnostics"][0]["diagnostic_future_label_exposed"] is True + assert result["no_trade_opportunity_diagnostics"][0]["future_label_used_for_training_state"] is False + + written = write_rl_artifacts(result, run_id="portfolio_unit") + assert Path(written["rl_manifest_path"]).exists() + assert Path(written["observation_manifest_path"]).exists() + assert Path(written["state_observations_path"]).exists() + assert Path(written["policy_metrics_path"]).exists() + assert Path(written["episode_metrics_path"]).exists() + assert Path(written["positions_path"]).exists() + assert Path(written["invalid_actions_path"]).exists() + assert Path(written["reward_breakdown_path"]).exists() + assert Path(written["baseline_comparison_path"]).exists() + assert Path(written["verdict_path"]).exists() + assert Path(written["training_manifest_path"]).exists() + assert Path(written["learning_curve_path"]).exists() + assert Path(written["action_distribution_path"]).exists() + assert Path(written["turnover_path"]).exists() + assert Path(written["drawdown_path"]).exists() + assert Path(written["reward_component_summary_path"]).exists() + assert Path(written["policy_baseline_comparison_path"]).exists() + assert Path(written["policy_nav_path"]).exists() + assert Path(written["policy_evaluation_manifest_path"]).exists() + assert Path(written["reward_action_ablations_path"]).exists() + assert Path(written["reward_action_ablation_summary_path"]).exists() + assert Path(written["no_trade_opportunity_diagnostics_path"]).exists() + assert Path(written["no_trade_opportunity_summary_path"]).exists() + assert Path(written["abstention_reasons_path"]).exists() + assert Path(written["source_hashes_path"]).exists() + manifest_on_disk = json.loads(Path(written["rl_manifest_path"]).read_text(encoding="utf-8")) + assert manifest_on_disk["run_id"] == "portfolio_unit" + assert manifest_on_disk["verdict"]["go_summary_allowed"] is False + assert manifest_on_disk["model_build_allowed"] is False + assert manifest_on_disk["readiness_status"] == "D4_RESEARCH_ONLY_DIAGNOSTICS" + assert manifest_on_disk["artifact_hashes"]["policy_metrics"] + training_manifest = json.loads(Path(written["training_manifest_path"]).read_text(encoding="utf-8")) + observation_manifest = json.loads(Path(written["observation_manifest_path"]).read_text(encoding="utf-8")) + assert observation_manifest["gate"] == "D4_OBSERVATION_STATE_MANIFEST" + assert observation_manifest["reward_action_telemetry_sufficient_for_d4"] is False + assert "shuffle_control" in observation_manifest["frozen_d3_comparison"]["required_baselines"] + state_rows = list(csv.DictReader(Path(written["state_observations_path"]).open("r", encoding="utf-8", newline=""))) + assert {"cash_fraction", "exposure_fraction", "future_label_exposed"} <= set(state_rows[0]) + assert {"action_mask_hold_buy_add_sell_reduce", "mask_reason_hold", "mask_reason_buy"} <= set(state_rows[0]) + assert training_manifest["telemetry"]["training_status"] == "TABULAR_Q_TELEMETRY_RECORDED" + assert training_manifest["artifact_hashes"]["policy_metrics"] + learning_curve = list(csv.DictReader(Path(written["learning_curve_path"]).open("r", encoding="utf-8", newline=""))) + assert {"episode", "rolling_mean_reward", "best_total_reward"} <= set(learning_curve[0]) + action_distribution = list(csv.DictReader(Path(written["action_distribution_path"]).open("r", encoding="utf-8", newline=""))) + assert {"split", "action", "action_rate"} <= set(action_distribution[0]) + assert {"requested_action", "executed_action", "invalid_action_reason", "no_trade_action"} <= set(action_distribution[0]) + reward_rows = list(csv.DictReader(Path(written["reward_breakdown_path"]).open("r", encoding="utf-8", newline=""))) + assert {"net_return_after_cost", "no_trade_hold_reward", "mask_reason_reduce"} <= set(reward_rows[0]) + ablation_rows = list(csv.DictReader(Path(written["reward_action_ablations_path"]).open("r", encoding="utf-8", newline=""))) + assert {"ablation", "delta_vs_recorded_reward", "cost_round_trip_bp"} <= set(ablation_rows[0]) + assert {row["ablation"] for row in ablation_rows} >= {"recorded_reward", "without_turnover_cost"} + opportunity_rows = list(csv.DictReader(Path(written["no_trade_opportunity_diagnostics_path"]).open("r", encoding="utf-8", newline=""))) + assert {"diagnostic", "top_candidate_net_after_entry_cost", "future_label_used_for_training_state"} <= set(opportunity_rows[0]) + abstention_rows = list(csv.DictReader(Path(written["abstention_reasons_path"]).open("r", encoding="utf-8", newline=""))) + assert {"action_filter_mode", "future_label_exposed", "filter_reason_buy"} <= set(abstention_rows[0]) + assert abstention_rows[0]["future_label_exposed"] == "False" + opportunity_summary = json.loads(Path(written["no_trade_opportunity_summary_path"]).read_text(encoding="utf-8")) + assert opportunity_summary["guardrail"].startswith("No-trade opportunity diagnostics") + assert opportunity_summary["training_state_uses_future_label"] is False + policy_rows = list(csv.DictReader(Path(written["policy_baseline_comparison_path"]).open("r", encoding="utf-8", newline=""))) + assert {"baseline_strategy", "policy_nav", "baseline_nav", "baseline_delta_total_net_return"} <= set(policy_rows[0]) + policy_manifest = json.loads(Path(written["policy_evaluation_manifest_path"]).read_text(encoding="utf-8")) + assert policy_manifest["readiness_status"] == "D4_RESEARCH_ONLY_DIAGNOSTICS" + assert policy_manifest["paper_forward_allowed"] is False + assert policy_manifest["live_broker_order_allowed"] is False + assert policy_manifest["reward_action_ablation_rows"] == len(result["reward_action_ablations"]) + assert policy_manifest["no_trade_opportunity_diagnostic_rows"] == len(result["no_trade_opportunity_diagnostics"]) + assert policy_manifest["abstention_reason_rows"] == len(result["abstention_reasons"]) + assert policy_manifest["source_hashes"] == result["source_hashes"] + source_hashes = json.loads(Path(written["source_hashes_path"]).read_text(encoding="utf-8")) + assert source_hashes["source_hashes"] == result["source_hashes"] + assert policy_manifest["required_frozen_baselines"] == [ + "no_trade_cash", + "shuffle_control", + "equal_weight_topk_momentum", + "vol_adjusted_momentum", + "supervised_linear_ranker", + "supervised_direction_classifier", + ] + + with pytest.raises(FileExistsError): + write_rl_artifacts(result, run_id="portfolio_unit") + with pytest.raises(ValueError): + write_rl_artifacts(result, artifact_root=tmp_path / "elsewhere", run_id="bad") + with pytest.raises(ValueError): + write_rl_artifacts(result, run_id="../escape") + + +def test_run_daily_rl_records_declared_prediction_hash_mismatches(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_rl_train as rl_train + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + run_dir = _create_prediction_run(prediction_root, verdict_status="WATCH") + manifest_path = run_dir / "prediction_manifest.json" + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["artifact_hashes"] = { + "predictions": "0" * 64, + "baseline_metrics": "1" * 64, + "verdict": "2" * 64, + } + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + monkeypatch.setattr(rl_train, "DEFAULT_PREDICTION_ROOT", prediction_root) + + result = run_daily_rl(prediction_run_dir=run_dir, episodes=2, candidate_limit=2, max_positions=2) + + artifact_hashes = result["manifest"]["prediction_artifact_hashes"] + assert set(result["manifest"]["prediction_artifact_hash_mismatches"]) == { + "predictions", + "baseline_metrics", + "verdict", + } + assert artifact_hashes["predictions"] != "0" * 64 + assert artifact_hashes["baseline_metrics"] != "1" * 64 + assert artifact_hashes["verdict"] != "2" * 64 + assert all(len(value) == 64 for value in artifact_hashes.values()) + +def test_d3_failed_or_skipped_forces_research_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_rl_train as rl_train + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + run_dir = _create_prediction_run(prediction_root, verdict_status="skipped") + monkeypatch.setattr(rl_train, "DEFAULT_PREDICTION_ROOT", prediction_root) + + result = run_daily_rl(prediction_run_dir=run_dir, episodes=2, candidate_limit=2, max_positions=2) + verdict = result["verdict"] + assert verdict["research_override"] is True + assert verdict["gate_dependency"] == "D3_FAILED_OR_SKIPPED" + assert verdict["d3_status_normalized"] == "SKIPPED" + assert verdict["go_summary_allowed"] is False diff --git a/tests/test_stom_rl_daily_scenario_runner.py b/tests/test_stom_rl_daily_scenario_runner.py new file mode 100644 index 000000000..5c6d2b9c5 --- /dev/null +++ b/tests/test_stom_rl_daily_scenario_runner.py @@ -0,0 +1,185 @@ +import json +from pathlib import Path + +import pytest + +from stom_rl import daily_scenario_runner as runner +from stom_rl import daily_scenario_batch as batch + + +def test_daily_model_scenario_runner_writes_locked_manifest(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr(runner, "DEFAULT_SCENARIO_ROOT", tmp_path / "scenarios") + + def fake_dataset(**kwargs): + calls.append(("dataset", kwargs)) + out = tmp_path / "dataset" / kwargs["run_id"] + out.mkdir(parents=True, exist_ok=True) + return { + "dataset": {"manifest": {"status": "PASS"}}, + "written": {"artifact_dir": str(out), "dataset_manifest_path": str(out / "dataset_manifest.json")}, + } + + def fake_prediction(**kwargs): + calls.append(("prediction", kwargs)) + out = tmp_path / "prediction" / kwargs["run_id"] + out.mkdir(parents=True, exist_ok=True) + return { + "result": {"manifest": {"status": "WATCH"}}, + "written": {"artifact_dir": str(out), "prediction_manifest_path": str(out / "prediction_manifest.json")}, + } + + def fake_rl(**kwargs): + calls.append(("rl", kwargs)) + out = tmp_path / "portfolio" / kwargs["run_id"] + out.mkdir(parents=True, exist_ok=True) + return { + "result": {"manifest": {"status": "RESEARCH_ONLY"}}, + "written": {"artifact_dir": str(out), "rl_manifest_path": str(out / "rl_manifest.json")}, + } + + def fake_walk_forward(**kwargs): + calls.append(("walk_forward", kwargs)) + out = tmp_path / "walk_forward" / kwargs["run_id"] + out.mkdir(parents=True, exist_ok=True) + return { + "result": { + "gate_verdict": { + "status": "NO-GO", + "readiness_status": "D5_NO_GO_RESEARCH_ONLY_GATE", + "selected_strategy": "equal_weight_topk_momentum", + "n_folds": 5, + "purge_days": 5, + "embargo_days": 5, + "cost_sensitivity_bp": [0, 23, 46], + "reasons": ["RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM"], + } + }, + "written": { + "artifact_dir": str(out), + "walk_forward_manifest_path": str(out / "walk_forward_manifest.json"), + "gate_verdict_path": str(out / "gate_verdict.json"), + }, + } + + monkeypatch.setattr(runner, "build_and_write_daily_ohlcv_dataset", fake_dataset) + monkeypatch.setattr(runner, "run_and_write_daily_prediction", fake_prediction) + monkeypatch.setattr(runner, "run_and_write_daily_rl", fake_rl) + monkeypatch.setattr(runner, "run_and_write_daily_walk_forward", fake_walk_forward) + + manifest = runner.run_daily_model_scenario( + run_id="scenario_unit", + overwrite=True, + max_symbols=8, + max_rows_per_symbol=120, + candidate_limit=10, + max_positions=3, + episodes=3, + action_filter_mode="confidence_abstain_v1", + ) + + assert [name for name, _ in calls] == ["dataset", "prediction", "rl", "walk_forward"] + assert calls[0][1]["max_symbols"] == 8 + assert calls[2][1]["candidate_limit"] == 10 + assert calls[2][1]["action_filter_mode"] == "confidence_abstain_v1" + assert calls[3][1]["purge_days"] == 5 + assert manifest["status"] == "NO-GO" + assert manifest["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert manifest["live_broker_order_allowed"] is False + assert manifest["gate_verdict_summary"]["cost_sensitivity_bp"] == [0, 23, 46] + + manifest_path = Path(manifest["artifact_paths"]["scenario_manifest"]) + assert manifest_path.is_file() + persisted = json.loads(manifest_path.read_text(encoding="utf-8")) + assert persisted["run_id"] == "scenario_unit" + assert persisted["artifact_paths"]["candidate_generation_config"].endswith("candidate_generation_config.json") + + +def test_daily_model_scenario_runner_rejects_unsafe_run_id(tmp_path, monkeypatch): + monkeypatch.setattr(runner, "DEFAULT_SCENARIO_ROOT", tmp_path / "scenarios") + with pytest.raises(ValueError, match="run_id"): + runner.run_daily_model_scenario(run_id="../bad") + +def test_daily_scenario_batch_runs_multiple_locked_scenarios(tmp_path, monkeypatch): + calls = [] + monkeypatch.setattr(batch, "DEFAULT_SCENARIO_BATCH_ROOT", tmp_path / "batches") + + def fake_run_daily_model_scenario(**kwargs): + calls.append(kwargs) + run_id = kwargs["run_id"] + out = tmp_path / "scenarios" / run_id + out.mkdir(parents=True, exist_ok=True) + return { + "run_id": run_id, + "status": "NO-GO", + "readiness_status": "D5_NO_GO_RESEARCH_ONLY_GATE", + "artifact_paths": {"scenario_manifest": str(out / "scenario_manifest.json")}, + "gate_verdict_summary": { + "status": "NO-GO", + "readiness_status": "D5_NO_GO_RESEARCH_ONLY_GATE", + "selected_strategy": "equal_weight_topk_momentum", + "n_folds": kwargs["n_folds"], + "purge_days": kwargs["purge_days"], + "embargo_days": kwargs["embargo_days"], + "cost_sensitivity_bp": [0, 23, 46], + "reasons": ["RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM"], + }, + } + + monkeypatch.setattr(batch, "run_daily_model_scenario", fake_run_daily_model_scenario) + + plan = { + "batch_id": "batch_unit", + "defaults": { + "max_symbols": 8, + "max_rows_per_symbol": 120, + "quality_table_limit": 0, + "episodes": 3, + "candidate_limit": 10, + "max_positions": 3, + "n_folds": 5, + "top_k": 10, + "purge_days": 5, + "embargo_days": 5, + }, + "scenarios": [ + {"scenario_id": "seed7", "overrides": {"rl_seed": 7, "wf_seed": 17}}, + {"scenario_id": "seed11", "overrides": {"rl_seed": 11, "wf_seed": 31, "action_filter_mode": "margin_abstain_v1"}}, + ], + } + + manifest = batch.run_daily_scenario_batch(plan=plan, overwrite=True) + + assert [call["run_id"] for call in calls] == ["batch_unit__seed7", "batch_unit__seed11"] + assert calls[0]["max_symbols"] == 8 + assert calls[1]["rl_seed"] == 11 + assert calls[1]["action_filter_mode"] == "margin_abstain_v1" + assert manifest["mode"] == "daily_ohlcv_model_scenario_batch" + assert manifest["platform_stage"] == "SCENARIO_BATCH_RUNNER_MVP" + assert manifest["scenario_count"] == 2 + assert manifest["failed_count"] == 0 + assert manifest["gate_status_counts"] == {"NO-GO": 2} + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert manifest["live_broker_order_allowed"] is False + assert all(row["cost_sensitivity_bp"] == [0, 23, 46] for row in manifest["comparison_rows"]) + + manifest_path = Path(manifest["artifact_paths"]["scenario_batch_manifest"]) + assert manifest_path.is_file() + persisted = json.loads(manifest_path.read_text(encoding="utf-8")) + assert persisted["batch_id"] == "batch_unit" + assert persisted["artifact_paths"]["scenario_batch_plan"].endswith("scenario_batch_plan.json") + + +def test_daily_scenario_batch_rejects_sub_gate_folds(tmp_path, monkeypatch): + monkeypatch.setattr(batch, "DEFAULT_SCENARIO_BATCH_ROOT", tmp_path / "batches") + plan = { + "batch_id": "bad_batch", + "defaults": {"n_folds": 4, "purge_days": 5, "embargo_days": 5}, + "scenarios": [{"scenario_id": "bad"}], + } + + with pytest.raises(ValueError, match="n_folds"): + batch.run_daily_scenario_batch(plan=plan, overwrite=True) diff --git a/tests/test_stom_rl_daily_signal_quality.py b/tests/test_stom_rl_daily_signal_quality.py new file mode 100644 index 000000000..bf8a07c0c --- /dev/null +++ b/tests/test_stom_rl_daily_signal_quality.py @@ -0,0 +1,224 @@ +import csv +import json +from pathlib import Path + +from stom_rl.daily_signal_quality import load_lagged_portfolio_context, run_signal_quality_audit +from stom_rl.daily_signal_quality_batch import run_signal_quality_batch + + +def _write_csv(path: Path, rows: list[dict[str, object]], fieldnames: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + +def test_lagged_portfolio_context_deduplicates_same_date_rows(tmp_path: Path) -> None: + portfolio_dir = tmp_path / "portfolio" + _write_csv( + portfolio_dir / "drawdown.csv", + [ + {"split": "val", "date": "20250102", "reward": "0.100", "current_drawdown": "-0.100"}, + {"split": "val+test", "date": "20250102", "reward": "-0.100", "current_drawdown": "-0.200"}, + {"split": "test", "date": "20250103", "reward": "0.050", "current_drawdown": "-0.050"}, + ], + ["split", "date", "reward", "current_drawdown"], + ) + + context = load_lagged_portfolio_context(portfolio_dir) + + assert context["20250102"]["past_return_volatility"] == 0.0 + assert context["20250102"]["drawdown"] == 0.0 + assert context["20250103"]["past_return_volatility"] == 0.0 + assert context["20250103"]["drawdown"] == -0.2 + assert context["20250103"]["past_return_volatility_source"].startswith("drawdown.csv lagged date-deduplicated") + + +def test_signal_quality_audit_emits_frozen_causal_diagnostics(tmp_path: Path) -> None: + prediction_dir = tmp_path / "prediction" + wf_dir = tmp_path / "walk_forward" + portfolio_dir = tmp_path / "portfolio" + rows = [ + {"code": "000001", "date": "20250102", "split": "train", "score_supervised_linear_ranker": "0.030", "future_return_1d": "0.020", "future_direction_1d": "1"}, + {"code": "000002", "date": "20250102", "split": "train", "score_supervised_linear_ranker": "0.010", "future_return_1d": "-0.010", "future_direction_1d": "0"}, + {"code": "000001", "date": "20250103", "split": "val", "score_supervised_linear_ranker": "0.006", "future_return_1d": "0.010", "future_direction_1d": "1"}, + {"code": "000002", "date": "20250103", "split": "val", "score_supervised_linear_ranker": "-0.002", "future_return_1d": "-0.005", "future_direction_1d": "0"}, + {"code": "000003", "date": "20250106", "split": "test", "score_supervised_linear_ranker": "0.004", "future_return_1d": "0.003", "future_direction_1d": "1"}, + {"code": "000004", "date": "20250106", "split": "test", "score_supervised_linear_ranker": "-0.004", "future_return_1d": "-0.004", "future_direction_1d": "0"}, + {"code": "000001", "date": "20250107", "split": "test", "score_supervised_linear_ranker": "0.021", "future_return_1d": "0.002", "future_direction_1d": "1"}, + {"code": "000002", "date": "20250107", "split": "test", "score_supervised_linear_ranker": "0.001", "future_return_1d": "-0.002", "future_direction_1d": "0"}, + ] + _write_csv( + prediction_dir / "predictions.csv", + rows, + [ + "code", + "date", + "split", + "score_supervised_linear_ranker", + "future_return_1d", + "future_direction_1d", + ], + ) + _write_csv( + wf_dir / "fold_assignments.csv", + [ + {"fold_id": "F01", "date": "20250106", "role": "test"}, + {"fold_id": "F02", "date": "20250107", "role": "test"}, + ], + ["fold_id", "date", "role"], + ) + _write_csv( + portfolio_dir / "drawdown.csv", + [ + {"split": "train", "date": "20250102", "reward": "0.000", "current_drawdown": "0.000"}, + {"split": "val", "date": "20250103", "reward": "-0.010", "current_drawdown": "-0.010"}, + {"split": "test", "date": "20250106", "reward": "0.020", "current_drawdown": "0.000"}, + {"split": "test", "date": "20250107", "reward": "-0.030", "current_drawdown": "-0.030"}, + ], + ["split", "date", "reward", "current_drawdown"], + ) + + + manifest = run_signal_quality_audit( + prediction_dir=prediction_dir, + walk_forward_dir=wf_dir, + portfolio_dir=portfolio_dir, + output_root=tmp_path / "out", + run_id="unit_signal_quality", + ) + + assert manifest["status"] == "COMPLETED_RESEARCH_ONLY" + assert manifest["promotion_status"] == "NO-GO_RESEARCH_ONLY" + assert manifest["threshold_policy"] == "frozen_absolute_no_quantile_search_no_oos_retune" + assert manifest["score_thresholds"] == [0.001, 0.005, 0.02] + assert manifest["cost_sensitivity_bp"] == [0, 23, 46] + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert manifest["live_broker_order_allowed"] is False + assert manifest["abstention_reasons_requirement"].startswith("not_applicable_pure_signal_quality") + + artifacts = {key: Path(value) for key, value in manifest["required_artifacts"].items()} + for path in artifacts.values(): + assert path.exists(), path + + bucket_rows = list(csv.DictReader(artifacts["signal_quality_bucket_metrics"].open(encoding="utf-8"))) + assert {row["split"] for row in bucket_rows} >= {"train", "val", "test"} + assert {row["fold"] for row in bucket_rows if row["split"] == "test"} >= {"F01", "F02"} + assert {"score_magnitude_bucket", "score_margin_bucket", "d3_confidence_bucket"} <= { + row["bucket_name"] for row in bucket_rows + } + assert {row["cost_bp"] for row in bucket_rows} == {"0", "23", "46"} + assert all(row["source_timing"] == "t/current/pre_action" for row in bucket_rows) + assert all(row["future_label_used_for_bucket"] == "False" for row in bucket_rows) + assert all(row["future_label_used_for_evaluation"] == "True" for row in bucket_rows) + + risk_rows = list(csv.DictReader(artifacts["risk_proxy_bucket_metrics"].open(encoding="utf-8"))) + assert {"recent_score_volatility_bucket", "past_return_volatility_bucket", "drawdown_bucket"} <= { + row["proxy_name"] for row in risk_rows + } + assert {row["cost_bp"] for row in risk_rows} == {"0", "23", "46"} + assert all(row["source_timing"] for row in risk_rows) + assert all(row["future_label_used_for_proxy"] == "False" for row in risk_rows) + assert any(row["proxy_status"] == "AVAILABLE_T_MINUS_1_GENERATED_PATH" for row in risk_rows) + assert any(float(row["policy_delta_vs_d3"]) != 0.0 for row in risk_rows) + assert any(float(row["turnover_proxy"]) != 0.0 for row in risk_rows) + + + baseline_rows = list(csv.DictReader(artifacts["baseline_control_metrics"].open(encoding="utf-8"))) + assert {"no_trade_cash", "shuffle_control", "equal_weight_topk", "frozen_d3_baseline"} <= { + row["baseline_strategy"] for row in baseline_rows + } + assert {row["cost_bp"] for row in baseline_rows} == {"0", "23", "46"} + assert all(row["future_label_used_for_selection"] == "False" for row in baseline_rows) + assert all(row["future_label_used_for_evaluation"] == "True" for row in baseline_rows) + + leakage = json.loads(artifacts["signal_quality_leakage_audit"].read_text(encoding="utf-8")) + assert leakage["verdict"] == "PASS" + feature_rows = {row["feature_name"]: row for row in leakage["rows"]} + assert feature_rows["score_magnitude_bucket"]["future_label_used"] is False + assert feature_rows["future_return_1d"]["verdict"] == "PASS_EVALUATION_LABEL_ONLY" + + stored_manifest = json.loads(artifacts["signal_quality_manifest"].read_text(encoding="utf-8")) + assert stored_manifest["source_hashes"]["predictions_csv"] + assert stored_manifest["no_future_label_policy"] == "future_return_1d is evaluation_label_only after bucket/proxy construction" + assert stored_manifest["baseline_controls_measured"] is True + assert stored_manifest["row_counts"]["baseline_control_metrics"] == len(baseline_rows) + assert stored_manifest["source_hashes"]["drawdown_csv"] +def test_signal_quality_batch_writes_reproducible_manifest(tmp_path: Path) -> None: + prediction_dir = tmp_path / "prediction" + wf_dir = tmp_path / "walk_forward" + portfolio_dir = tmp_path / "portfolio" + _write_csv( + prediction_dir / "predictions.csv", + [ + {"code": "000001", "date": "20250102", "split": "train", "score_supervised_linear_ranker": "0.030", "future_return_1d": "0.020", "future_direction_1d": "1"}, + {"code": "000002", "date": "20250102", "split": "train", "score_supervised_linear_ranker": "0.010", "future_return_1d": "-0.010", "future_direction_1d": "0"}, + {"code": "000001", "date": "20250103", "split": "val", "score_supervised_linear_ranker": "0.006", "future_return_1d": "0.010", "future_direction_1d": "1"}, + {"code": "000002", "date": "20250103", "split": "val", "score_supervised_linear_ranker": "-0.002", "future_return_1d": "-0.005", "future_direction_1d": "0"}, + {"code": "000001", "date": "20250106", "split": "test", "score_supervised_linear_ranker": "0.004", "future_return_1d": "0.003", "future_direction_1d": "1"}, + {"code": "000002", "date": "20250106", "split": "test", "score_supervised_linear_ranker": "-0.004", "future_return_1d": "-0.004", "future_direction_1d": "0"}, + ], + ["code", "date", "split", "score_supervised_linear_ranker", "future_return_1d", "future_direction_1d"], + ) + _write_csv( + wf_dir / "fold_assignments.csv", + [{"fold_id": "F01", "date": "20250106", "role": "test"}], + ["fold_id", "date", "role"], + ) + _write_csv( + portfolio_dir / "drawdown.csv", + [ + {"split": "train", "date": "20250102", "reward": "0.000", "current_drawdown": "0.000"}, + {"split": "val", "date": "20250103", "reward": "-0.010", "current_drawdown": "-0.010"}, + {"split": "test", "date": "20250106", "reward": "0.020", "current_drawdown": "0.000"}, + ], + ["split", "date", "reward", "current_drawdown"], + ) + + plan_path = tmp_path / "plan.json" + plan_path.write_text( + json.dumps( + { + "batch_id": "unit_signal_quality_batch", + "defaults": { + "prediction_dir": str(prediction_dir), + "walk_forward_dir": str(wf_dir), + "cost_bp": 23, + "portfolio_dir": str(portfolio_dir), + }, + "scenarios": [ + {"scenario_id": "score_magnitude_audit_v1", "diagnostic_focus": "score_magnitude", "status": "WATCH"}, + {"scenario_id": "risk_proxy_audit_v1", "diagnostic_focus": "risk_proxy", "status": "WATCH"}, + ], + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + manifest = run_signal_quality_batch( + plan_path=plan_path, + batch_root=tmp_path / "batches", + output_root=tmp_path / "runs", + ) + + assert manifest["batch_id"] == "unit_signal_quality_batch" + assert manifest["status"] == "COMPLETED_RESEARCH_ONLY" + assert manifest["scenario_count"] == 2 + assert manifest["completed_count"] == 2 + assert manifest["failed_count"] == 0 + assert manifest["gate_status_counts"] == {"WATCH": 2} + assert manifest["cost_sensitivity_bp"] == [0, 23, 46] + assert manifest["model_build_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert manifest["live_broker_order_allowed"] is False + assert Path(manifest["artifact_paths"]["scenario_batch_manifest"]).exists() + assert Path(manifest["artifact_paths"]["scenario_batch_plan"]).exists() + for row in manifest["runs"]: + assert row["promotion_status"] == "NO-GO_RESEARCH_ONLY" + assert row["cost_sensitivity_bp"] == [0, 23, 46] + assert row["baseline_controls"] == ["no_trade_cash", "shuffle_control", "equal_weight_topk", "frozen_d3_baseline"] + assert Path(row["artifact_paths"]["signal_quality_manifest"]).exists() + assert Path(row["artifact_paths"]["baseline_control_metrics"]).exists() + assert row["baseline_control_metrics"] diff --git a/tests/test_stom_rl_daily_walk_forward.py b/tests/test_stom_rl_daily_walk_forward.py new file mode 100644 index 000000000..a877085da --- /dev/null +++ b/tests/test_stom_rl_daily_walk_forward.py @@ -0,0 +1,584 @@ +import csv +import json +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from stom_rl.daily_walk_forward import run_daily_walk_forward, write_walk_forward_artifacts # noqa: E402 + + +def _write_csv(path: Path, rows: list[dict[str, object]]) -> None: + fields = sorted({key for row in rows for key in row.keys()}) + with path.open("w", encoding="utf-8", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields) + writer.writeheader() + writer.writerows(rows) + + +def _create_prediction_run(root: Path) -> Path: + run_dir = root / "prediction_unit" + run_dir.mkdir(parents=True) + manifest = { + "schema_version": 1, + "price_basis": "unknown", + "price_basis_evidence": "unit unknown", + "universe_review_status": "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW", + "verdict": {"status": "WATCH", "go_summary_allowed": False}, + } + baseline_metrics = { + "metrics": [ + {"strategy": "no_trade_cash", "total_net_return": 0.0, "mean_turnover": 0.0, "max_drawdown": 0.0}, + {"strategy": "equal_weight_topk_momentum", "total_net_return": 0.05, "mean_turnover": 0.5, "max_drawdown": -0.01}, + {"strategy": "supervised_linear_ranker", "total_net_return": 0.03, "mean_turnover": 0.4, "max_drawdown": -0.02}, + ] + } + rows = [] + for idx in range(15): + date = f"2024-01-{idx + 1:02d}" + split = "train" if idx < 5 else "val" if idx < 10 else "test" + for offset, code in enumerate(["000020", "000030", "000040"]): + score = 0.4 - offset * 0.1 + idx * 0.01 + rows.append( + { + "date": date, + "table": f"A{code}", + "code": code, + "split": split, + "future_return_1d": 0.01 if offset == 0 else -0.002, + "score_equal_weight_topk_momentum": score, + "score_supervised_linear_ranker": score / 2, + } + ) + (run_dir / "prediction_manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + (run_dir / "baseline_metrics.json").write_text(json.dumps(baseline_metrics), encoding="utf-8") + (run_dir / "verdict.json").write_text(json.dumps({"status": "WATCH", "go_summary_allowed": False}), encoding="utf-8") + _write_csv(run_dir / "predictions.csv", rows) + return run_dir + + +def _create_portfolio_run(root: Path) -> Path: + run_dir = root / "portfolio_unit" + run_dir.mkdir(parents=True) + observation_manifest = { + "status": "PASS_RESEARCH_ONLY_STATE_CONTRACT", + "gate": "D4_OBSERVATION_STATE_MANIFEST", + "reward_action_telemetry_sufficient_for_d4": False, + "frozen_d3_comparison": { + "required_baselines": [ + "no_trade_cash", + "shuffle_control", + "equal_weight_topk_momentum", + ] + }, + } + manifest = { + "schema_version": 1, + "run_id": "portfolio_unit", + "verdict": {"status": "RESEARCH_ONLY", "go_summary_allowed": False, "implementation_unlocked": False}, + "slippage_assumption": "No separate daily slippage model is inferred from OHLCV; use 23bp and sensitivity.", + "observation_manifest": observation_manifest, + "observation_manifest_validation": {"status": "PASS", "observation_fields": ["position_count", "top_score_bucket"]}, + "state_contract_status": "PASS", + } + verdict = {"status": "RESEARCH_ONLY", "go_summary_allowed": False, "implementation_unlocked": False} + comparison = { + "policy_total_net_return": -0.10, + "best_d3_total_net_return": 0.05, + "delta_vs_best_d3_total_net_return": -0.15, + "cost_round_trip_bp": 23, + } + rewards = [] + state_rows = [] + invalid_rows = [] + policy_nav_rows = [] + ablation_rows = [] + for idx in range(5, 15): + date = f"2024-01-{idx + 1:02d}" + rewards.append( + { + "split": "val+test", + "date": date, + "reward": -0.001, + "turnover": 0.1, + "exposure": 0.4, + "invalid_action": False, + } + ) + state_rows.append( + { + "split": "val+test", + "date": date, + "observation_position_count": 0, + "observation_top_score_bucket": 1, + "future_label_exposed": False, + "top_candidate_code": "000020", + } + ) + invalid_rows.append({"split": "val+test", "date": date, "invalid_action": False, "action_mask": "1|1|0|0|0"}) + policy_nav_rows.append({"split": "val+test", "date": date, "policy_nav": 1.0, "policy_current_drawdown": 0.0}) + ablation_rows.append( + { + "split": "val+test", + "ablation_family": "reward_component", + "ablation": "recorded_reward", + "rows": 1, + "total_reward": -0.001, + "mean_reward": -0.001, + "delta_vs_recorded_reward": 0.0, + "cost_round_trip_bp": 23, + } + ) + baseline_rows = [ + {"baseline_strategy": "no_trade_cash", "baseline_status": "LOADED", "cost_round_trip_bp": 23}, + {"baseline_strategy": "shuffle_control", "baseline_status": "LOADED", "cost_round_trip_bp": 23}, + {"baseline_strategy": "equal_weight_topk_momentum", "baseline_status": "LOADED", "cost_round_trip_bp": 23}, + ] + (run_dir / "rl_manifest.json").write_text(json.dumps(manifest), encoding="utf-8") + (run_dir / "observation_manifest.json").write_text(json.dumps(observation_manifest), encoding="utf-8") + (run_dir / "verdict.json").write_text(json.dumps(verdict), encoding="utf-8") + (run_dir / "baseline_comparison.json").write_text(json.dumps(comparison), encoding="utf-8") + _write_csv(run_dir / "reward_breakdown.csv", rewards) + _write_csv(run_dir / "state_observations.csv", state_rows) + _write_csv(run_dir / "invalid_actions.csv", invalid_rows) + _write_csv(run_dir / "policy_baseline_comparison.csv", baseline_rows) + _write_csv(run_dir / "policy_nav.csv", policy_nav_rows) + _write_csv(run_dir / "reward_action_ablations.csv", ablation_rows) + (run_dir / "reward_action_ablation_summary.json").write_text( + json.dumps({"schema_version": 1, "ablation_count": len(ablation_rows), "ablation_names": ["recorded_reward"]}), + encoding="utf-8", + ) + (run_dir / "source_hashes.json").write_text( + json.dumps({"schema_version": 1, "source_hashes": {"stom_rl/daily_rl_train.py": "a" * 64}}), + encoding="utf-8", + ) + return run_dir + + +def test_daily_walk_forward_emits_no_go_gate_and_controls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_walk_forward as walk_forward + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + portfolio_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio" + walk_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_walk_forward" + prediction_run = _create_prediction_run(prediction_root) + portfolio_run = _create_portfolio_run(portfolio_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PREDICTION_ROOT", prediction_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PORTFOLIO_ROOT", portfolio_root) + monkeypatch.setattr(walk_forward, "DEFAULT_WALK_FORWARD_ROOT", walk_root) + + result = run_daily_walk_forward(prediction_run_dir=prediction_run, portfolio_run_dir=portfolio_run, n_folds=5, top_k=1) + verdict = result["gate_verdict"] + assert verdict["status"] == "NO-GO" + assert verdict["model_build_allowed"] is False + assert verdict["go_summary_allowed"] is False + assert verdict["paper_forward_allowed"] is False + assert verdict["live_broker_order_allowed"] is False + assert verdict["no_live_broker_order_readiness"] is True + assert verdict["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert verdict["n_folds"] == 5 + assert verdict["no_oos_retuning"] is True + assert verdict["strategy_selection_policy"] == "preregistered_equal_weight_topk_momentum_no_oos_metric_selection" + assert verdict["selected_strategy"] == "equal_weight_topk_momentum" + assert "RL_POLICY_UNDERPERFORMS_D3_BASELINE" in verdict["reasons"] + assert "PRICE_BASIS_UNKNOWN" in verdict["reasons"] + assert "D4_OBSERVATION_STATE_MANIFEST_CONSUMED" in verdict["reasons"] + assert verdict["d4_state_contract_status"] == "PASS" + assert verdict["d4_observation_manifest_gate"] == "D4_OBSERVATION_STATE_MANIFEST" + assert verdict["d4_state_contract_artifacts_consumed"] is True + assert verdict["d4_state_observation_rows"] == 10 + assert verdict["d4_reward_action_telemetry_sufficient_for_d4"] is False + assert set(verdict["prediction_artifact_hashes"]) == {"prediction_manifest", "predictions", "baseline_metrics", "verdict"} + assert {"rl_manifest", "reward_breakdown", "state_observations", "policy_baseline_comparison", "policy_nav"} <= set(verdict["portfolio_artifact_hashes"]) + assert "reward_action_ablations" in verdict["portfolio_artifact_hashes"] + assert "source_hashes" in verdict["portfolio_artifact_hashes"] + assert verdict["d4_reward_action_ablation_rows"] == 10 + assert verdict["d4_source_hash_count"] == 1 + assert all(len(value) == 64 for value in verdict["prediction_artifact_hashes"].values()) + assert all(len(value) == 64 for value in verdict["portfolio_artifact_hashes"].values()) + assert verdict["d4_artifact_issues"] == [] + assert all(row["forward_only"] is True for row in result["folds"]) + assert all(row["retuned_on_oos"] is False for row in result["folds"]) + assert all(not row["purge_start_date"] or row["train_end_date"] < row["purge_start_date"] for row in result["folds"]) + assert len(result["shuffle_control"]) == 5 + assert len(result["cost_sensitivity"]) == 15 + assert {float(row["cost_bp"]) for row in result["cost_sensitivity"]} == {0.0, 23.0, 46.0} + assert len(result["rl_fold_metrics"]) == 5 + selected_rows = [row for row in result["fold_metrics"] if row["strategy"] == "equal_weight_topk_momentum" and row["control"] == "actual"] + assert all("delta_vs_no_trade_total_net_return" in row for row in selected_rows) + assert all("delta_vs_shuffled_total_net_return" in row for row in selected_rows) + assert result["gate_verdict"]["fold_consistency"]["folds_beating_no_trade"] >= 0 + assert result["gate_verdict"]["fold_consistency"]["folds_beating_shuffle"] >= 0 + assert "worst_fold_max_drawdown" in result["gate_verdict"]["fold_consistency"] + assert "mean_fold_turnover" in result["gate_verdict"]["fold_consistency"] + assert verdict["max_allowed_fold_drawdown"] == -0.20 + assert verdict["max_allowed_mean_turnover"] == 1.00 + assert "MDD_TURNOVER_LIMITS_CHECKED" in verdict["reasons"] + + written = write_walk_forward_artifacts(result, run_id="walk_forward_unit") + assert Path(written["walk_forward_manifest_path"]).exists() + assert Path(written["fold_assignments_path"]).exists() + assert Path(written["fold_metrics_path"]).exists() + assert Path(written["shuffle_control_path"]).exists() + assert Path(written["cost_sensitivity_path"]).exists() + assert Path(written["rl_fold_metrics_path"]).exists() + assert Path(written["gate_verdict_path"]).exists() + assert Path(written["d4_state_contract_path"]).exists() + manifest = json.loads(Path(written["walk_forward_manifest_path"]).read_text(encoding="utf-8")) + assert manifest["run_id"] == "walk_forward_unit" + assert manifest["verdict"]["status"] == "NO-GO" + assert manifest["d4_state_contract_status"] == "PASS" + assert manifest["row_counts"]["d4_state_observation_rows"] == 10 + assert manifest["model_build_allowed"] is False + assert manifest["go_summary_allowed"] is False + assert manifest["paper_forward_allowed"] is False + assert manifest["live_broker_order_allowed"] is False + assert manifest["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert len(manifest["prediction_manifest_sha"]) == 64 + assert len(manifest["portfolio_manifest_sha"]) == 64 + assert manifest["prediction_artifact_hashes"] == verdict["prediction_artifact_hashes"] + assert manifest["portfolio_artifact_hashes"] == verdict["portfolio_artifact_hashes"] + assert {"fold_metrics", "cost_sensitivity", "rl_fold_metrics", "gate_verdict", "d4_state_contract", "walk_forward_manifest"} <= set(written["artifact_hashes"]) + assert "reward_action_ablations" in manifest["portfolio_artifact_hashes"] + assert manifest["row_counts"]["d4_reward_action_ablation_rows"] == 10 + assert manifest["row_counts"]["d4_source_hash_count"] == 1 + assert manifest["artifact_hashes"]["gate_verdict"] == written["artifact_hashes"]["gate_verdict"] + + with pytest.raises(FileExistsError): + write_walk_forward_artifacts(result, run_id="walk_forward_unit") + with pytest.raises(ValueError): + write_walk_forward_artifacts(result, artifact_root=tmp_path / "elsewhere", run_id="bad") + with pytest.raises(ValueError): + write_walk_forward_artifacts(result, run_id="../escape") + with pytest.raises(ValueError): + run_daily_walk_forward(prediction_run_dir=tmp_path / "elsewhere" / "prediction_unit", portfolio_run_dir=portfolio_run) + with pytest.raises(ValueError): + run_daily_walk_forward(prediction_run_dir=prediction_run, portfolio_run_dir=tmp_path / "elsewhere" / "portfolio_unit") + + +@pytest.mark.parametrize( + ("case", "reason"), + [ + ("missing_state_observations", "D4_REQUIRED_ARTIFACT_MISSING_state_observations.csv"), + ("missing_reward_breakdown", "D4_REQUIRED_ARTIFACT_MISSING_reward_breakdown.csv"), + ("telemetry_true", "D4_REWARD_ACTION_TELEMETRY_FLAG_NOT_FALSE"), + ("validation_fail", "D4_OBSERVATION_MANIFEST_VALIDATION_NOT_PASS"), + ("state_contract_fail", "D4_STATE_CONTRACT_STATUS_NOT_PASS"), + ("gate_invalid", "D4_OBSERVATION_STATE_MANIFEST_GATE_INVALID"), + ("missing_frozen_baseline", "D4_FROZEN_D3_BASELINE_MISSING_equal_weight_topk_momentum"), + ("missing_reward_action_ablations", "D4_REQUIRED_ARTIFACT_MISSING_reward_action_ablations.csv"), + ("missing_source_hashes", "D4_REQUIRED_ARTIFACT_MISSING_source_hashes.json"), + ("empty_source_hashes", "D4_SOURCE_HASHES_MISSING"), + ("bad_source_hashes_json", "D4_SOURCE_HASHES_JSON_INVALID"), + ("empty_state_observations", "D4_STATE_OBSERVATIONS_EMPTY"), + ("bad_state_observations_encoding", "D4_STATE_OBSERVATIONS_CSV_INVALID"), + ("bad_reward_breakdown_encoding", "D4_REWARD_BREAKDOWN_CSV_INVALID"), + ("empty_reward_action_ablations", "D4_REWARD_ACTION_ABLATIONS_EMPTY"), + ("schema_bad_state_observations", "D4_STATE_OBSERVATIONS_SCHEMA_INVALID"), + ("schema_bad_reward_breakdown", "D4_REWARD_BREAKDOWN_SCHEMA_INVALID"), + ("schema_bad_invalid_actions", "D4_INVALID_ACTIONS_SCHEMA_INVALID"), + ("schema_bad_policy_baseline_comparison", "D4_POLICY_BASELINE_COMPARISON_SCHEMA_INVALID"), + ("schema_bad_policy_nav", "D4_POLICY_NAV_SCHEMA_INVALID"), + ("schema_bad_reward_action_ablations", "D4_REWARD_ACTION_ABLATIONS_SCHEMA_INVALID"), + ("empty_reward_breakdown", "D4_REWARD_BREAKDOWN_EMPTY"), + ("empty_invalid_actions", "D4_INVALID_ACTIONS_EMPTY"), + ("empty_policy_nav", "D4_POLICY_NAV_EMPTY"), + ("empty_policy_baseline_comparison", "D4_POLICY_BASELINE_COMPARISON_EMPTY"), + ("empty_reward_action_ablation_summary", "D4_REWARD_ACTION_ABLATION_SUMMARY_EMPTY"), + ("bad_observation_manifest_json", "D4_OBSERVATION_MANIFEST_JSON_INVALID"), + ("bad_reward_action_ablation_summary_json", "D4_REWARD_ACTION_ABLATION_SUMMARY_JSON_INVALID"), + ("missing_required_baseline_list", "D4_FROZEN_D3_BASELINE_REQUIREMENTS_MISSING"), + ("malformed_required_baseline_list", "D4_FROZEN_D3_BASELINE_REQUIREMENTS_MISSING"), + ], +) +def test_daily_walk_forward_blocks_d4_state_contract_violations( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + case: str, + reason: str, +): + import stom_rl.daily_walk_forward as walk_forward + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + portfolio_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio" + prediction_run = _create_prediction_run(prediction_root) + portfolio_run = _create_portfolio_run(portfolio_root) + manifest_path = portfolio_run / "rl_manifest.json" + observation_path = portfolio_run / "observation_manifest.json" + + if case == "missing_state_observations": + (portfolio_run / "state_observations.csv").unlink() + elif case == "missing_reward_breakdown": + (portfolio_run / "reward_breakdown.csv").unlink() + elif case == "missing_reward_action_ablations": + (portfolio_run / "reward_action_ablations.csv").unlink() + elif case == "missing_source_hashes": + (portfolio_run / "source_hashes.json").unlink() + elif case == "empty_source_hashes": + (portfolio_run / "source_hashes.json").write_text( + json.dumps({"schema_version": 1, "source_hashes": {}}), + encoding="utf-8", + ) + elif case == "bad_source_hashes_json": + (portfolio_run / "source_hashes.json").write_text("{", encoding="utf-8") + elif case == "empty_state_observations": + (portfolio_run / "state_observations.csv").write_text( + "split,date,observation_position_count,observation_top_score_bucket,future_label_exposed\n", + encoding="utf-8", + ) + elif case == "bad_state_observations_encoding": + (portfolio_run / "state_observations.csv").write_bytes(b"\xff\xfe\x00") + elif case == "bad_reward_breakdown_encoding": + (portfolio_run / "reward_breakdown.csv").write_bytes(b"\xff\xfe\x00") + elif case == "empty_reward_action_ablations": + (portfolio_run / "reward_action_ablations.csv").write_text( + "split,ablation_family,ablation,rows,total_reward,cost_round_trip_bp\n", + encoding="utf-8", + ) + elif case == "schema_bad_state_observations": + (portfolio_run / "state_observations.csv").write_text("wrong\nvalue\n", encoding="utf-8") + elif case == "schema_bad_reward_breakdown": + (portfolio_run / "reward_breakdown.csv").write_text("wrong\nvalue\n", encoding="utf-8") + elif case == "schema_bad_invalid_actions": + (portfolio_run / "invalid_actions.csv").write_text("wrong\nvalue\n", encoding="utf-8") + elif case == "schema_bad_policy_baseline_comparison": + (portfolio_run / "policy_baseline_comparison.csv").write_text("wrong\nvalue\n", encoding="utf-8") + elif case == "schema_bad_policy_nav": + (portfolio_run / "policy_nav.csv").write_text("wrong\nvalue\n", encoding="utf-8") + elif case == "schema_bad_reward_action_ablations": + (portfolio_run / "reward_action_ablations.csv").write_text("wrong\nvalue\n", encoding="utf-8") + elif case == "empty_reward_breakdown": + (portfolio_run / "reward_breakdown.csv").write_text("split,date,reward,turnover,exposure,invalid_action\n", encoding="utf-8") + elif case == "empty_invalid_actions": + (portfolio_run / "invalid_actions.csv").write_text("split,date,invalid_action,action_mask\n", encoding="utf-8") + elif case == "empty_policy_nav": + (portfolio_run / "policy_nav.csv").write_text("split,date,policy_nav,policy_current_drawdown\n", encoding="utf-8") + elif case == "empty_policy_baseline_comparison": + (portfolio_run / "policy_baseline_comparison.csv").write_text("baseline_strategy,baseline_status,cost_round_trip_bp\n", encoding="utf-8") + elif case == "empty_reward_action_ablation_summary": + (portfolio_run / "reward_action_ablation_summary.json").write_text("{}", encoding="utf-8") + elif case == "bad_observation_manifest_json": + observation_path.write_text("{", encoding="utf-8") + elif case == "bad_reward_action_ablation_summary_json": + (portfolio_run / "reward_action_ablation_summary.json").write_text("{", encoding="utf-8") + elif case == "missing_required_baseline_list": + observation_manifest = json.loads(observation_path.read_text(encoding="utf-8")) + observation_manifest["frozen_d3_comparison"] = {} + observation_path.write_text(json.dumps(observation_manifest), encoding="utf-8") + elif case == "malformed_required_baseline_list": + observation_manifest = json.loads(observation_path.read_text(encoding="utf-8")) + observation_manifest["frozen_d3_comparison"] = {"required_baselines": "no_trade_cash"} + observation_path.write_text(json.dumps(observation_manifest), encoding="utf-8") + elif case == "telemetry_true": + observation_manifest = json.loads(observation_path.read_text(encoding="utf-8")) + observation_manifest["reward_action_telemetry_sufficient_for_d4"] = True + observation_path.write_text(json.dumps(observation_manifest), encoding="utf-8") + elif case == "validation_fail": + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["observation_manifest_validation"]["status"] = "FAIL" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + elif case == "state_contract_fail": + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest["state_contract_status"] = "FAIL" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + elif case == "gate_invalid": + observation_manifest = json.loads(observation_path.read_text(encoding="utf-8")) + observation_manifest["gate"] = "D4_TELEMETRY_ONLY" + observation_path.write_text(json.dumps(observation_manifest), encoding="utf-8") + elif case == "missing_frozen_baseline": + _write_csv( + portfolio_run / "policy_baseline_comparison.csv", + [ + {"baseline_strategy": "no_trade_cash", "baseline_status": "LOADED", "cost_round_trip_bp": 23}, + {"baseline_strategy": "shuffle_control", "baseline_status": "LOADED", "cost_round_trip_bp": 23}, + ], + ) + + monkeypatch.setattr(walk_forward, "DEFAULT_PREDICTION_ROOT", prediction_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PORTFOLIO_ROOT", portfolio_root) + + result = run_daily_walk_forward(prediction_run_dir=prediction_run, portfolio_run_dir=portfolio_run, n_folds=5) + assert result["gate_verdict"]["status"] == "NO-GO" + assert reason in result["gate_verdict"]["reasons"] + assert result["gate_verdict"]["d4_state_contract_artifacts_consumed"] is False + assert result["gate_verdict"]["model_build_allowed"] is False + assert result["gate_verdict"]["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert "D5_REQUIRED_EVIDENCE_INCOMPLETE" in result["gate_verdict"]["reasons"] + +def test_daily_walk_forward_blocks_when_folds_below_five(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_walk_forward as walk_forward + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + portfolio_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio" + prediction_run = _create_prediction_run(prediction_root) + portfolio_run = _create_portfolio_run(portfolio_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PREDICTION_ROOT", prediction_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PORTFOLIO_ROOT", portfolio_root) + + result = run_daily_walk_forward(prediction_run_dir=prediction_run, portfolio_run_dir=portfolio_run, n_folds=4) + assert result["gate_verdict"]["status"] == "NO-GO" + assert "N_FOLDS_BELOW_5" in result["gate_verdict"]["reasons"] + assert result["gate_verdict"]["model_build_allowed"] is False + assert result["gate_verdict"]["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert "D5_REQUIRED_EVIDENCE_INCOMPLETE" in result["gate_verdict"]["reasons"] + + +@pytest.mark.parametrize( + ("purge_days", "embargo_days", "reason"), + [ + (0, 5, "PURGE_DAYS_BELOW_REQUIRED_MIN"), + (5, 0, "EMBARGO_DAYS_BELOW_REQUIRED_MIN"), + ], +) +def test_daily_walk_forward_blocks_missing_purge_or_embargo( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + purge_days: int, + embargo_days: int, + reason: str, +): + import stom_rl.daily_walk_forward as walk_forward + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + portfolio_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio" + prediction_run = _create_prediction_run(prediction_root) + portfolio_run = _create_portfolio_run(portfolio_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PREDICTION_ROOT", prediction_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PORTFOLIO_ROOT", portfolio_root) + + result = run_daily_walk_forward( + prediction_run_dir=prediction_run, + portfolio_run_dir=portfolio_run, + n_folds=5, + purge_days=purge_days, + embargo_days=embargo_days, + ) + assert result["gate_verdict"]["status"] == "NO-GO" + assert reason in result["gate_verdict"]["reasons"] + assert result["gate_verdict"]["model_build_allowed"] is False + assert result["gate_verdict"]["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert "D5_REQUIRED_EVIDENCE_INCOMPLETE" in result["gate_verdict"]["reasons"] + + +def test_daily_walk_forward_missing_baseline_comparison_fails_closed(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_walk_forward as walk_forward + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + portfolio_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio" + prediction_run = _create_prediction_run(prediction_root) + portfolio_run = _create_portfolio_run(portfolio_root) + (portfolio_run / "baseline_comparison.json").unlink() + monkeypatch.setattr(walk_forward, "DEFAULT_PREDICTION_ROOT", prediction_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PORTFOLIO_ROOT", portfolio_root) + + result = run_daily_walk_forward(prediction_run_dir=prediction_run, portfolio_run_dir=portfolio_run, n_folds=5) + + assert result["gate_verdict"]["status"] == "NO-GO" + assert result["gate_verdict"]["model_build_allowed"] is False + assert result["gate_verdict"]["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert "D5_BASELINE_COMPARISON_MISSING" in result["gate_verdict"]["reasons"] + assert "D5_REQUIRED_EVIDENCE_INCOMPLETE" in result["gate_verdict"]["reasons"] + +@pytest.mark.parametrize( + ("payload", "reason"), + [ + ({}, "D5_BASELINE_COMPARISON_FIELD_INVALID_delta_vs_best_d3_total_net_return"), + ({"policy_total_net_return": -0.10, "best_d3_total_net_return": 0.05, "cost_round_trip_bp": 23}, "D5_BASELINE_COMPARISON_FIELD_INVALID_delta_vs_best_d3_total_net_return"), + ({"policy_total_net_return": -0.10, "best_d3_total_net_return": 0.05, "delta_vs_best_d3_total_net_return": -0.15}, "D5_BASELINE_COMPARISON_FIELD_INVALID_cost_round_trip_bp"), + ({"policy_total_net_return": -0.10, "best_d3_total_net_return": 0.05, "delta_vs_best_d3_total_net_return": "nan", "cost_round_trip_bp": 23}, "D5_BASELINE_COMPARISON_FIELD_INVALID_delta_vs_best_d3_total_net_return"), + ({"policy_total_net_return": -0.10, "best_d3_total_net_return": 0.05, "delta_vs_best_d3_total_net_return": -0.15, "cost_round_trip_bp": 0}, "D5_BASELINE_COMPARISON_COST_MISMATCH"), + ], +) +def test_daily_walk_forward_malformed_baseline_comparison_fails_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + payload: dict[str, object], + reason: str, +): + import stom_rl.daily_walk_forward as walk_forward + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + portfolio_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio" + prediction_run = _create_prediction_run(prediction_root) + portfolio_run = _create_portfolio_run(portfolio_root) + (portfolio_run / "baseline_comparison.json").write_text(json.dumps(payload), encoding="utf-8") + monkeypatch.setattr(walk_forward, "DEFAULT_PREDICTION_ROOT", prediction_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PORTFOLIO_ROOT", portfolio_root) + + result = run_daily_walk_forward(prediction_run_dir=prediction_run, portfolio_run_dir=portfolio_run, n_folds=5) + + assert result["gate_verdict"]["status"] == "NO-GO" + assert result["gate_verdict"]["model_build_allowed"] is False + assert result["gate_verdict"]["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert reason in result["gate_verdict"]["reasons"] + assert "D5_REQUIRED_EVIDENCE_INCOMPLETE" in result["gate_verdict"]["reasons"] + + +def test_daily_walk_forward_invalid_baseline_comparison_encoding_fails_closed( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +): + import stom_rl.daily_walk_forward as walk_forward + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + portfolio_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio" + prediction_run = _create_prediction_run(prediction_root) + portfolio_run = _create_portfolio_run(portfolio_root) + (portfolio_run / "baseline_comparison.json").write_bytes(b"\xff\xfe\x00") + monkeypatch.setattr(walk_forward, "DEFAULT_PREDICTION_ROOT", prediction_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PORTFOLIO_ROOT", portfolio_root) + + result = run_daily_walk_forward(prediction_run_dir=prediction_run, portfolio_run_dir=portfolio_run, n_folds=5) + + assert result["gate_verdict"]["status"] == "NO-GO" + assert result["gate_verdict"]["model_build_allowed"] is False + assert "D5_BASELINE_COMPARISON_JSON_INVALID" in result["gate_verdict"]["reasons"] + assert "D5_REQUIRED_EVIDENCE_INCOMPLETE" in result["gate_verdict"]["reasons"] + +def test_daily_walk_forward_favorable_oos_still_research_locked(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + import stom_rl.daily_walk_forward as walk_forward + + prediction_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_prediction" + portfolio_root = tmp_path / "webui" / "rl_runs" / "daily_ohlcv_portfolio" + prediction_run = _create_prediction_run(prediction_root) + portfolio_run = _create_portfolio_run(portfolio_root) + (portfolio_run / "baseline_comparison.json").write_text( + json.dumps( + { + "policy_total_net_return": 1.0, + "best_d3_total_net_return": 0.05, + "delta_vs_best_d3_total_net_return": 0.95, + "cost_round_trip_bp": 23, + } + ), + encoding="utf-8", + ) + reward_rows = [] + for idx in range(5, 15): + reward_rows.append( + { + "split": "val+test", + "date": f"2024-01-{idx + 1:02d}", + "reward": 0.2, + "turnover": 0.1, + "exposure": 0.4, + "invalid_action": False, + } + ) + _write_csv(portfolio_run / "reward_breakdown.csv", reward_rows) + monkeypatch.setattr(walk_forward, "DEFAULT_PREDICTION_ROOT", prediction_root) + monkeypatch.setattr(walk_forward, "DEFAULT_PORTFOLIO_ROOT", portfolio_root) + + result = run_daily_walk_forward(prediction_run_dir=prediction_run, portfolio_run_dir=portfolio_run, n_folds=5, top_k=1) + + assert result["gate_verdict"]["status"] == "NO-GO" + assert result["gate_verdict"]["model_build_allowed"] is False + assert result["gate_verdict"]["go_summary_allowed"] is False + assert result["gate_verdict"]["paper_forward_allowed"] is False + assert result["gate_verdict"]["live_broker_order_allowed"] is False + assert result["gate_verdict"]["readiness_status"] == "D5_NO_GO_RESEARCH_ONLY_GATE" + assert "D5_RESEARCH_ONLY_MODEL_BUILD_LOCK" in result["gate_verdict"]["reasons"] diff --git a/tests/test_stom_rl_dashboard_api.py b/tests/test_stom_rl_dashboard_api.py new file mode 100644 index 000000000..81b8f936f --- /dev/null +++ b/tests/test_stom_rl_dashboard_api.py @@ -0,0 +1,590 @@ +import json +import os +import sys +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from webui import rl_dashboard # noqa: E402 +from webui.app import app as flask_app # noqa: E402 + + +def _write_csv(path: Path, header: str, rows: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join([header, *rows]) + "\n", encoding="utf-8-sig") + + +def _write_rl_fixture(root: Path) -> None: + bandit = root / "bandit_run" + bandit.mkdir() + (bandit / "eval_summary.json").write_text( + json.dumps( + { + "mode": "stom_rl_contextual_bandit", + "eval_summary": { + "policy": "contextual_bandit", + "episode_count": 1, + "trade_count": 1, + "avg_episode_net_return_pct": 1.5, + "passes_cost_gate": True, + }, + "artifacts": {}, + } + ), + encoding="utf-8-sig", + ) + (bandit / "model.json").write_text( + json.dumps( + { + "model": { + "model_type": "stom_fixed_horizon_contextual_bandit_ridge", + "feature_columns": ["ret_1s"], + "train_summary": {"train_sample_count": 10}, + } + } + ), + encoding="utf-8-sig", + ) + _write_csv( + bandit / "trades.csv", + "episode_id,symbol,net_return_pct", + ["000001_20250103,000001,1.5"], + ) + _write_csv( + bandit / "equity_curve.csv", + "episode_id,timestamp,equity", + ["000001_20250103,2025-01-03T09:05:00,1.015"], + ) + _write_csv( + bandit / "actions.csv", + "episode_id,action,predicted_net_return_pct", + ["000001_20250103,buy,1.7"], + ) + _write_csv( + bandit / "episodes.csv", + "episode_id,episode_return_pct", + ["000001_20250103,1.5"], + ) + + cost = root / "cost_gate_run" + cost.mkdir() + (cost / "cost_gate_report.json").write_text( + json.dumps( + { + "mode": "stom_rl_cost_gate", + "summary": { + "passing_policy_count": 1, + "passing_policies": ["buy_and_hold"], + "best_policy_at_target_cost": "buy_and_hold", + }, + } + ), + encoding="utf-8-sig", + ) + _write_csv( + cost / "gate_summary.csv", + "policy,passes_cost_gate,avg_episode_net_return_pct", + ["buy_and_hold,True,1.2"], + ) + _write_csv( + cost / "scenario_summary.csv", + "policy,cost_bps,avg_episode_net_return_pct", + ["buy_and_hold,25,1.2"], + ) + _write_csv( + cost / "rolling_folds.csv", + "fold,policy,positive_fold", + ["1,buy_and_hold,True"], + ) + + baseline = root / "baseline_run" + baseline.mkdir() + (baseline / "baseline_summary.json").write_text( + json.dumps({"mode": "stom_rl_baseline_run", "summary": {"policy_count": 1}}), + encoding="utf-8-sig", + ) + _write_csv( + baseline / "buy_and_hold" / "trades.csv", + "policy,episode_id,net_return_pct", + ["buy_and_hold,000001_20250103,1.1"], + ) + + leaderboard = root / "leaderboard_run" + leaderboard.mkdir() + (leaderboard / "performance_leaderboard.json").write_text( + json.dumps( + { + "mode": "stom_rl_performance_leaderboard", + "summary": { + "row_count": 2, + "best_policy": "buy_and_hold", + "best_rl_model": "contextual_bandit", + "best_rl_usability": "watch", + "target_cost_bps": 25, + }, + "leaderboard": [ + { + "rank": 1, + "source": "baseline", + "model": "buy_and_hold", + "avg_episode_net_return_pct": 1.2, + "passes_cost_gate": False, + }, + { + "rank": 2, + "source": "rl_model", + "model": "contextual_bandit", + "avg_episode_net_return_pct": 0.4, + "passes_cost_gate": False, + }, + ], + } + ), + encoding="utf-8-sig", + ) + _write_csv( + leaderboard / "performance_leaderboard.csv", + "rank,source,model,avg_episode_net_return_pct,passes_cost_gate", + ["1,baseline,buy_and_hold,1.2,False", "2,rl_model,contextual_bandit,0.4,False"], + ) + + sb3 = root / "sb3_smoke_run" + sb3.mkdir() + (sb3 / "sb3_smoke_summary.json").write_text( + json.dumps( + { + "mode": "stom_rl_sb3_smoke", + "summary": { + "algorithm_count": 2, + "best_model": "dqn_smoke", + "best_algorithm_by_avg_episode_net": "dqn", + "feature_columns": ["open", "close", "position"], + }, + "models": [ + { + "algorithm": "dqn", + "model": "dqn_smoke", + "policy": "stable_baselines3_dqn", + "avg_episode_net_return_pct": 0.3, + "trade_count": 1, + "passes_cost_gate": False, + "is_smoke": True, + } + ], + } + ), + encoding="utf-8-sig", + ) + _write_csv( + sb3 / "sb3_smoke_summary.csv", + "algorithm,model,avg_episode_net_return_pct,is_smoke", + ["dqn,dqn_smoke,0.3,True"], + ) + _write_csv( + sb3 / "trades.csv", + "model,algorithm,episode_id,net_return_pct", + ["dqn_smoke,dqn,000001_20250103,0.3"], + ) + (sb3 / "rl_live_events.jsonl").write_text( + "\n".join( + [ + json.dumps( + { + "schema_version": "stom_rl_live_event.v1", + "run_id": "sb3_smoke_run", + "algorithm": "dqn", + "phase": "eval", + "global_step": 1, + "action": 1, + "action_name": "buy", + "reward": 0.1, + "position": 1, + "equity": 1.003, + } + ) + ] + ) + + "\n", + encoding="utf-8", + ) + (sb3 / "rl_live_summary.json").write_text( + json.dumps({"schema_version": "stom_rl_live_event.v1", "event_count": 1, "phases": {"eval": 1}}), + encoding="utf-8-sig", + ) + + readiness = root / "orderbook_readiness_run" + readiness.mkdir() + (readiness / "orderbook_rl_readiness_summary.json").write_text( + json.dumps( + { + "mode": "stom_orderbook_rl_readiness", + "artifact_type": "orderbook_rl_readiness", + "summary": { + "readiness_status": "INCONCLUSIVE", + "verdict": "INCONCLUSIVE", + "is_live_ready": False, + "environment": "StomOrderbookRlEnv", + "eligible_episode_count": 12, + "quote_coverage": 0.98, + "valid_spread_coverage": 0.96, + "sample_env_smoke_passed": True, + }, + "observation_features": ["ret_open", "spread_rel", "position"], + } + ), + encoding="utf-8-sig", + ) + _write_csv( + readiness / "orderbook_rl_readiness.csv", + "readiness_status,eligible_episode_count,quote_coverage,valid_spread_coverage", + ["INCONCLUSIVE,12,0.98,0.96"], + ) + + +def test_rl_dashboard_helpers_list_detail_and_tables(tmp_path, monkeypatch): + _write_rl_fixture(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + runs = rl_dashboard.list_rl_runs(limit=10) + names = {run["name"] for run in runs} + run_by_name = {run["name"]: run for run in runs} + detail = rl_dashboard.load_rl_run("bandit_run") + trades = rl_dashboard.load_rl_table("bandit_run", "trades", limit=5) + baseline_trades = rl_dashboard.load_rl_table("baseline_run", "trades", policy="buy_and_hold", limit=5) + leaderboard_detail = rl_dashboard.load_rl_run("leaderboard_run") + leaderboard_rows = rl_dashboard.load_rl_table("leaderboard_run", "leaderboard", limit=5) + sb3_detail = rl_dashboard.load_rl_run("sb3_smoke_run") + sb3_summary = rl_dashboard.load_rl_table("sb3_smoke_run", "summary", limit=5) + sb3_events = rl_dashboard.load_rl_events("sb3_smoke_run", limit=5) + cost_gate = rl_dashboard.load_rl_cost_gate("cost_gate_run", limit=5) + readiness_detail = rl_dashboard.load_rl_run("orderbook_readiness_run") + readiness_rows = rl_dashboard.load_rl_table("orderbook_readiness_run", "orderbook-readiness", limit=5) + + assert { + "bandit_run", + "cost_gate_run", + "baseline_run", + "leaderboard_run", + "sb3_smoke_run", + "orderbook_readiness_run", + }.issubset(names) + assert detail["artifact_type"] == "contextual_bandit" + assert detail["model"]["model_type"] == "stom_fixed_horizon_contextual_bandit_ridge" + assert trades["rows"][0]["net_return_pct"] == 1.5 + assert baseline_trades["rows"][0]["policy"] == "buy_and_hold" + baseline_context = run_by_name["baseline_run"]["strategy_context"] + baseline_risk = baseline_context["risk_policy_summary"] + assert baseline_context["line"] == "rule_mainline" + assert baseline_context["label"] == "RULE MAINLINE" + assert baseline_context["primary_baseline"] == "ts_imb" + assert baseline_context["is_reinforcement_learning"] is False + assert baseline_context["is_live_ready"] is False + assert baseline_context["is_profit_model"] is False + assert baseline_risk["per_trade_fraction_pct"] == 10.0 + assert baseline_risk["max_concurrent"] == 3 + assert baseline_risk["daily_loss_limit_pct"] == 3.0 + assert baseline_risk["cost_bps"] == 23.0 + assert baseline_risk["tp_pct"] == 5.0 + assert baseline_risk["sl_pct"] == 1.0 + assert baseline_risk["risk_unit_account_pct"] == pytest.approx(0.123) + assert leaderboard_detail["artifact_type"] == "performance_leaderboard" + assert leaderboard_detail["summary"]["best_policy"] == "buy_and_hold" + assert leaderboard_detail["strategy_context"]["line"] == "evaluation" + assert leaderboard_detail["strategy_context"]["is_profit_model"] is False + assert leaderboard_rows["rows"][1]["model"] == "contextual_bandit" + assert detail["strategy_context"]["line"] == "rl_experiment" + assert detail["strategy_context"]["label"] == "RL EXPERIMENT" + assert detail["strategy_context"]["is_reinforcement_learning"] is True + assert detail["strategy_context"]["is_live_ready"] is False + assert detail["strategy_context"]["is_profit_model"] is False + assert sb3_detail["artifact_type"] == "sb3_smoke" + assert sb3_detail["model"]["model_type"] == "stable_baselines3_dqn" + assert sb3_detail["summary"]["live_event_count"] == 1 + assert sb3_detail["strategy_context"]["line"] == "rl_experiment" + assert sb3_detail["strategy_context"]["is_live_ready"] is False + assert sb3_detail["strategy_context"]["is_profit_model"] is False + assert sb3_summary["rows"][0]["model"] == "dqn_smoke" + assert sb3_events["rows"][0]["action_name"] == "buy" + assert cost_gate["summary"]["passing_policies"] == ["buy_and_hold"] + assert cost_gate["gate"]["rows"][0]["passes_cost_gate"] is True + assert readiness_detail["artifact_type"] == "orderbook_rl_readiness" + assert readiness_detail["summary"]["readiness_status"] == "INCONCLUSIVE" + assert readiness_detail["model"]["model_type"] == "marketable_only_orderbook_rl_environment" + assert readiness_detail["strategy_context"]["line"] == "rl_experiment" + assert readiness_detail["strategy_context"]["is_live_ready"] is False + assert readiness_detail["strategy_context"]["is_profit_model"] is False + assert readiness_rows["rows"][0]["quote_coverage"] == 0.98 + + +def _write_portfolio_fixture(root: Path) -> None: + portfolio = root / "portfolio_paper_run" + portfolio.mkdir() + (portfolio / "portfolio_paper_summary.json").write_text( + json.dumps( + { + "mode": "stom_rl_portfolio_paper_run", + "run_name": "portfolio_paper_run", + "config": {"cost_bps": 25.0, "max_positions": 2, "top_k_candidates": 3}, + "summary": { + "read_only": True, + "steps": 3, + "final_nav": 989504.0, + "trade_count": 2, + "risk_trigger_count": 1, + "order_write_path": False, + }, + "walk_forward_summary": {"n_folds": 1, "best_policy_by_return": "no_trade"}, + } + ), + encoding="utf-8-sig", + ) + (portfolio / "portfolio_walk_forward_report.json").write_text( + json.dumps( + {"mode": "stom_rl_portfolio_walk_forward", "summary": {"n_folds": 1, "best_policy_by_return": "no_trade"}} + ), + encoding="utf-8-sig", + ) + (portfolio / "risk_triggers.json").write_text( + json.dumps({"risk_triggers": [{"timestamp": "2025-07-09T09:01:22", "reason": "consecutive_losses"}]}), + encoding="utf-8-sig", + ) + _write_csv( + portfolio / "nav.csv", + "timestamp,step,nav,cash,position_count", + ["2025-07-09T09:00:05,0,1000000.0,1000000.0,0", "2025-07-09T09:00:25,1,989504.0,498906.0,2"], + ) + _write_csv( + portfolio / "decisions.csv", + "timestamp,proposed_action,executed_action,action_type,symbol,blocked,blocked_reason,nav_after", + ["2025-07-09T09:00:05,1,1,buy,100,False,,999375.0"], + ) + _write_csv( + portfolio / "trades.csv", + "timestamp,symbol,side,price,quantity,realized_pnl", + ["2025-07-09T09:00:05,100,buy,106100.0,2.35,0.0"], + ) + _write_csv( + portfolio / "portfolio_walk_forward_folds.csv", + "fold_index,policy,final_nav,return_pct,max_drawdown_pct,trade_count,test_start,test_end", + ["0,no_trade,1000000.0,0.0,0.0,0,2025-07-09T09:10:30,2025-07-09T09:13:55"], + ) + _write_csv( + portfolio / "candidates.csv", + "timestamp,symbol,passed,rank_score", + ["2025-07-09T09:00:05,000100,True,96.1"], + ) + # Live step events (portfolio NAV stream) published by Page 14 so the + # dashboard's realtime follow/replay view streams the portfolio run. + (portfolio / "rl_live_events.jsonl").write_text( + "\n".join( + json.dumps( + { + "run_id": "portfolio_paper_run", + "algorithm": "portfolio", + "phase": "train", + "global_step": step, + "action": 1 if step == 1 else 0, + "action_name": "buy" if step == 1 else "hold", + "reward": 0.0019 if step == 1 else -0.0105, + "equity": 1000000.0 if step == 1 else 989504.0, + "position": float(step), + "timestamp": f"2025-07-09T09:00:0{step}", + "schema_version": "stom_rl_live_event.v1", + "info": {"nav": 1000000.0 if step == 1 else 989504.0, "cash": 500000.0}, + } + ) + for step in (1, 2) + ) + + "\n", + encoding="utf-8", + ) + + +def test_rl_dashboard_serves_portfolio_paper_run(tmp_path, monkeypatch): + _write_portfolio_fixture(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + runs = rl_dashboard.list_rl_runs(limit=10) + types = {run["name"]: run["artifact_type"] for run in runs} + assert types.get("portfolio_paper_run") == "portfolio_paper" + + detail = rl_dashboard.load_rl_run("portfolio_paper_run") + assert detail["artifact_type"] == "portfolio_paper" + assert detail["summary"]["final_nav"] == 989504.0 + assert detail["detail"]["walk_forward_summary"]["n_folds"] == 1 + assert detail["detail"]["risk_trigger_reasons"]["consecutive_losses"] == 1 + + nav = rl_dashboard.load_rl_table("portfolio_paper_run", "nav", limit=10) + assert nav["rows"][-1]["nav"] == 989504.0 + decisions = rl_dashboard.load_rl_table("portfolio_paper_run", "decisions", limit=10) + assert decisions["rows"][0]["action_type"] == "buy" + folds = rl_dashboard.load_rl_table("portfolio_paper_run", "portfolio_folds", limit=10) + assert folds["rows"][0]["policy"] == "no_trade" + + +def test_flask_serves_portfolio_paper_tables(tmp_path, monkeypatch): + _write_portfolio_fixture(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + client = flask_app.test_client() + + runs = client.get("/api/rl/runs") + assert runs.status_code == 200 + assert any( + run["name"] == "portfolio_paper_run" and run["artifact_type"] == "portfolio_paper" + for run in runs.get_json()["runs"] + ) + + nav = client.get("/api/rl/runs/portfolio_paper_run/table/nav") + assert nav.status_code == 200 + assert nav.get_json()["rows"][-1]["nav"] == 989504.0 + + folds = client.get("/api/rl/runs/portfolio_paper_run/table/portfolio_folds") + assert folds.status_code == 200 + assert folds.get_json()["rows"][0]["policy"] == "no_trade" + + +def test_portfolio_paper_run_serves_live_events(tmp_path, monkeypatch): + """A published portfolio run exposes its NAV stream through the existing + ``/table/events`` route so the dashboard's follow/replay view is non-empty.""" + + _write_portfolio_fixture(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + events = rl_dashboard.load_rl_table("portfolio_paper_run", "events", limit=50) + assert len(events["rows"]) == 2 + last = events["rows"][-1] + assert last["algorithm"] == "portfolio" + assert last["phase"] == "train" + assert last["equity"] == 989504.0 # NAV mapped to equity + + client = flask_app.test_client() + response = client.get("/api/rl/runs/portfolio_paper_run/table/events") + assert response.status_code == 200 + rows = response.get_json()["rows"] + assert rows and all(row["algorithm"] == "portfolio" for row in rows) + assert any(row.get("equity") for row in rows) + + +def test_rl_dashboard_rejects_path_traversal(tmp_path, monkeypatch): + _write_rl_fixture(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + try: + rl_dashboard.load_rl_run("../secret") + except ValueError as exc: + assert "direct child" in str(exc) or "Invalid" in str(exc) + else: + raise AssertionError("path traversal was accepted") + + +def test_rl_dashboard_rejects_symlink_escape(tmp_path, monkeypatch): + run_root = tmp_path / "runs" + run_root.mkdir() + outside = tmp_path / "outside_run" + outside.mkdir() + (outside / "baseline_summary.json").write_text( + json.dumps({"mode": "stom_rl_baseline_run", "summary": {"policy_count": 1}}), + encoding="utf-8-sig", + ) + link = run_root / "linked_run" + try: + os.symlink(outside, link, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlink creation unavailable: {exc}") + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [run_root]) + + assert "linked_run" not in {run["name"] for run in rl_dashboard.list_rl_runs(limit=10)} + + with pytest.raises(ValueError, match="escapes RL root"): + rl_dashboard.load_rl_run("linked_run") + + +def test_rl_dashboard_rejects_symlinked_artifact_file(tmp_path, monkeypatch): + run_root = tmp_path / "runs" + run_root.mkdir() + run = run_root / "baseline_run" + run.mkdir() + (run / "baseline_summary.json").write_text( + json.dumps({"mode": "stom_rl_baseline_run", "summary": {"policy_count": 1}}), + encoding="utf-8-sig", + ) + policy_dir = run / "buy_and_hold" + policy_dir.mkdir() + outside = tmp_path / "outside_trades.csv" + outside.write_text("secret\nleaked\n", encoding="utf-8-sig") + try: + os.symlink(outside, policy_dir / "trades.csv") + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlink creation unavailable: {exc}") + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [run_root]) + + with pytest.raises(FileNotFoundError): + rl_dashboard.load_rl_table("baseline_run", "trades", policy="buy_and_hold", limit=5) + + +def test_webui_default_bind_is_localhost_only(): + run_launcher = (REPO_ROOT / "webui" / "run.py").read_text(encoding="utf-8") + direct_app = (REPO_ROOT / "webui" / "app.py").read_text(encoding="utf-8") + + assert 'KRONOS_WEBUI_HOST", "127.0.0.1"' in run_launcher + assert 'KRONOS_WEBUI_HOST", "127.0.0.1"' in direct_app + assert "host='0.0.0.0'" not in direct_app + + +def test_flask_rl_routes_smoke(tmp_path, monkeypatch): + _write_rl_fixture(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + client = flask_app.test_client() + + runs = client.get("/api/rl/runs") + assert runs.status_code == 200 + assert any(run["name"] == "bandit_run" for run in runs.get_json()["runs"]) + + detail = client.get("/api/rl/runs/bandit_run") + assert detail.status_code == 200 + assert detail.get_json()["summary"]["passes_cost_gate"] is True + assert detail.get_json()["strategy_context"]["line"] == "rl_experiment" + assert detail.get_json()["strategy_context"]["is_live_ready"] is False + + trades = client.get("/api/rl/runs/bandit_run/trades") + assert trades.status_code == 200 + assert trades.get_json()["rows"][0]["net_return_pct"] == 1.5 + + baseline_trades = client.get("/api/rl/runs/baseline_run/trades?policy=buy_and_hold") + assert baseline_trades.status_code == 200 + assert baseline_trades.get_json()["rows"][0]["policy"] == "buy_and_hold" + + cost_gate = client.get("/api/rl/runs/cost_gate_run/cost-gate") + assert cost_gate.status_code == 200 + assert cost_gate.get_json()["summary"]["best_policy_at_target_cost"] == "buy_and_hold" + + leaderboard = client.get("/api/rl/runs/leaderboard_run/table/leaderboard") + assert leaderboard.status_code == 200 + assert leaderboard.get_json()["rows"][0]["model"] == "buy_and_hold" + + sb3 = client.get("/api/rl/runs/sb3_smoke_run") + assert sb3.status_code == 200 + assert sb3.get_json()["artifact_type"] == "sb3_smoke" + + readiness = client.get("/api/rl/runs/orderbook_readiness_run") + assert readiness.status_code == 200 + assert readiness.get_json()["artifact_type"] == "orderbook_rl_readiness" + assert readiness.get_json()["strategy_context"]["is_profit_model"] is False + + events = client.get("/api/rl/runs/sb3_smoke_run/events") + assert events.status_code == 200 + assert events.get_json()["rows"][0]["phase"] == "eval" + + progress = client.get("/api/rl/progress") + assert progress.status_code == 200 + assert progress.get_json()["mode"] == "stom_rl_page_progress" + + bad = client.get("/api/rl/runs/..%5Csecret") + assert bad.status_code in {400, 404} diff --git a/tests/test_stom_rl_dashboard_tab.py b/tests/test_stom_rl_dashboard_tab.py new file mode 100644 index 000000000..eba3f3965 --- /dev/null +++ b/tests/test_stom_rl_dashboard_tab.py @@ -0,0 +1,171 @@ +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DASHBOARD_SRC = REPO_ROOT / "webui" / "v2_src" / "src" +DASHBOARD_PACKAGE = REPO_ROOT / "webui" / "v2_src" / "package.json" +RL_COMPONENT_DIR = DASHBOARD_SRC / "tabs" / "rlTrading" + + +def _rl_source_text() -> str: + files = [DASHBOARD_SRC / "tabs" / "RLTradingTab.svelte", DASHBOARD_SRC / "lib" / "rlApi.ts"] + files.extend(sorted(RL_COMPONENT_DIR.glob("*"))) + return "\n".join(path.read_text(encoding="utf-8") for path in files) + + +def _public_copy_source_text() -> str: + files = [ + DASHBOARD_PACKAGE, + DASHBOARD_SRC / "app.css", + DASHBOARD_SRC / "layout" / "Header.svelte", + DASHBOARD_SRC / "layout" / "Sidebar.svelte", + DASHBOARD_SRC / "tabs" / "ArtifactsModelsTab.svelte", + DASHBOARD_SRC / "tabs" / "DocsTab.svelte", + DASHBOARD_SRC / "tabs" / "SettingsTab.svelte", + DASHBOARD_SRC / "tabs" / "SystemHealthTab.svelte", + ] + return "\n".join(path.read_text(encoding="utf-8") for path in files) + + +def test_official_dashboard_sources_register_stom_rl_trading_tab(): + app = (DASHBOARD_SRC / "App.svelte").read_text(encoding="utf-8") + sidebar = (DASHBOARD_SRC / "layout" / "Sidebar.svelte").read_text(encoding="utf-8") + header = (DASHBOARD_SRC / "layout" / "Header.svelte").read_text(encoding="utf-8") + source = _rl_source_text() + + assert "RLTradingTab" in app + assert "tab === 'rl'" in app + assert "path === '/rl'" in app + assert "requested === 'rl-lab' || requested === 'rl-trading'" in app + assert "id: 'rl'" in sidebar + assert "label: 'RL Trading'" in sidebar + assert "rl: 'RL Trading'" in header + assert "data-rl-trading-tab" in source + assert "data-rl-orderbook-readiness-card" in source + assert "orderbook_rl_readiness" in source + + +def test_official_dashboard_public_copy_has_no_version_or_lab_labels(): + source = _public_copy_source_text() + + assert '"name": "kronos-dashboard"' in source + assert "Kronos official dashboard" in source + assert "Kronos \ub300\uc2dc\ubcf4\ub4dc" in source + assert "RL Trading" in source + for old_public_label in [ + "Kronos v2", + "P1 \ubbf8\ub9ac\ubcf4\uae30", + "P1.5", + "RL Lab", + "\uac15\ud654\ud559\uc2b5 \uc2e4\ud5d8\uc2e4", + "\uc2e4\ud5d8\uc2e4", + "KRONOS_V2_DIST", + ]: + assert old_public_label not in source + + +def test_rl_trading_tab_uses_read_only_rl_api_contracts(): + api = (DASHBOARD_SRC / "lib" / "rlApi.ts").read_text(encoding="utf-8") + source = _rl_source_text() + + for marker in [ + "/api/rl/runs", + "rlRuns", + "rlRun", + "rlActions", + "rlTrades", + "rlEquity", + "rlEpisodes", + "rlTable", + "rlCostGate", + ]: + assert marker in api + + for marker in [ + "leaderboardChartOption", + "costGateChartOption", + "equityChartOption", + "actionPnlChartOption", + "tradeChartOption", + "data-rl-leaderboard-table", + "data-rl-leaderboard-chart", + "data-rl-cost-gate-table", + "data-rl-trade-table", + "Kronos", + "DQN/PPO", + "25bp cost gate", + "RULE MAINLINE", + "RL EXPERIMENT", + "ts_imb RULE baseline", + "NO-GO", + "not live-ready", + "23bp", + "ORDERBOOK RL READINESS", + "market_buy", + "market_exit", + "readiness artifact", + "CUMULATIVE REWARD EVIDENCE", + "cumulative reward evidence", + "ts_imb baseline", + "markLine", + "tooltipLines", + "tooltipTitle", + ]: + assert marker in source + assert "Cumulative profit curve" not in source + + +def test_rl_trading_tab_escapes_artifact_strings_in_chart_tooltips(): + source = _rl_source_text() + helper = (DASHBOARD_SRC / "lib" / "safeHtml.ts").read_text(encoding="utf-8") + + assert "function escapeHtml" in helper + assert ".replace(/${row" not in source + assert "`#${row" not in source + assert "].filter(Boolean).join('
')" not in source + + +def test_v2_dist_contains_rl_trading_bundle_marker_when_built(): + dist = REPO_ROOT / "webui" / "static" / "v2" / "dist" + if not dist.is_dir(): + return + + bundle_text = "\n".join( + path.read_text(encoding="utf-8", errors="ignore") + for path in (dist / "assets").glob("index-*.js") + ) + assert "RULE / RL EVIDENCE" in bundle_text + assert "RULE MAINLINE" in bundle_text + assert "RL EXPERIMENT" in bundle_text + assert "ts_imb RULE baseline" in bundle_text + assert "NO-GO" in bundle_text + assert "not live-ready" in bundle_text + assert "23bp" in bundle_text + assert "data-rl-trading-tab" in bundle_text + assert "data-rl-orderbook-readiness-card" in bundle_text + assert "CUMULATIVE REWARD EVIDENCE" in bundle_text + assert "Cumulative profit curve" not in bundle_text + assert "ts_imb baseline" in bundle_text + assert "/api/rl/runs" in bundle_text + + +def test_participant_proxy_card_uses_rule_filter_non_rl_label(): + source = (RL_COMPONENT_DIR / "ParticipantProxyCard.svelte").read_text(encoding="utf-8") + + assert "RULE FILTER EVIDENCE" in source + assert "run?.strategy_context?.label" in source + assert "run?.artifact_type === 'opening_30m_rule_filter'" in source + assert "RL EXPERIMENT" not in source + + +def test_opening_workflow_card_uses_rule_filter_non_rl_label(): + source = (RL_COMPONENT_DIR / "OpeningWorkflowCard.svelte").read_text(encoding="utf-8") + + assert "isRuleFilterRun" in source + assert "RULE FILTER evidence panel" in source + assert "rule/meta-label evidence" in source + assert "run?.artifact_type === 'opening_30m_rule_filter'" in source diff --git a/tests/test_stom_rl_episode_manifest.py b/tests/test_stom_rl_episode_manifest.py new file mode 100644 index 000000000..343bc42c5 --- /dev/null +++ b/tests/test_stom_rl_episode_manifest.py @@ -0,0 +1,143 @@ +import json +import sqlite3 +from pathlib import Path + +import pytest + +from stom_rl.episode_manifest import ( + EpisodeManifestConfig, + build_episode_manifest, + connect_readonly, + verify_readonly_connection, +) + + +def _create_sqlite_db(path: Path) -> None: + conn = sqlite3.connect(path) + try: + conn.execute('CREATE TABLE "000001" ("index" INTEGER, "close" REAL)') + conn.execute('INSERT INTO "000001" VALUES (20250103090000, 1000)') + conn.commit() + finally: + conn.close() + + +def _write_episode_csv(path: Path, rows: int = 3) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines = ["symbol,date,open,high,low,close,volume,amount,money,factor"] + session = path.stem.rsplit("_", 1)[1] + for idx in range(rows): + lines.append( + f"KR000001,{session[:4]}-{session[4:6]}-{session[6:]} 09:00:0{idx}," + f"100{idx},100{idx},100{idx},100{idx},1,1000,1000,1.0" + ) + path.write_text("\n".join(lines) + "\n", encoding="utf-8-sig") + + +def _write_export_report(path: Path, qlib_csv_dir: Path) -> None: + payload = { + "mode": "stom_to_qlib_export", + "qlib_csv_dir": str(qlib_csv_dir), + "exported_group_count": 4, + "exported_row_count": 12, + "split_counts": { + "train": {"groups": 2, "rows": 6, "sessions": 2}, + "val": {"groups": 1, "rows": 3, "sessions": 1}, + "test": {"groups": 1, "rows": 3, "sessions": 1}, + }, + "split_sessions": { + "train": ["20250103", "20250106"], + "val": ["20250107"], + "test": ["20250108"], + }, + } + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8-sig") + + +def test_connect_readonly_blocks_write_probe(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + _create_sqlite_db(db_path) + + conn = connect_readonly(db_path) + try: + assert conn.execute("PRAGMA query_only").fetchone()[0] == 1 + with pytest.raises(sqlite3.DatabaseError): + conn.execute('CREATE TABLE "should_fail" (id INTEGER)') + finally: + conn.close() + + evidence = verify_readonly_connection(db_path) + assert evidence["sqlite_uri_mode"] == "ro" + assert evidence["query_only"] == 1 + assert evidence["write_probe_blocked"] is True + + +def test_build_episode_manifest_uses_export_split_and_writes_artifacts(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + report_path = tmp_path / "stom_qlib_export_report.json" + csv_dir = tmp_path / "qlib_csv" + out_dir = tmp_path / "rl_manifest" + _create_sqlite_db(db_path) + _write_export_report(report_path, csv_dir) + for session in ["20250103", "20250106", "20250107", "20250108"]: + _write_episode_csv(csv_dir / f"KR000001_{session}.csv", rows=3) + + payload = build_episode_manifest( + EpisodeManifestConfig( + db_path=str(db_path), + export_report_path=str(report_path), + output_dir=str(out_dir), + count_csv_rows=True, + ) + ) + + assert payload["summary"]["episode_count"] == 4 + assert payload["summary"]["symbol_count"] == 1 + assert payload["summary"]["by_split"] == {"test": 1, "train": 2, "val": 1} + assert payload["summary"]["by_split_delta_vs_export_report"] == {"test": 0, "train": 0, "val": 0} + assert payload["summary"]["manifest_group_delta_vs_export_report"] == 0 + assert payload["summary"]["counted_csv_rows"] == 12 + assert payload["summary"]["split_validation"]["overlap_count"] == 0 + assert payload["summary"]["split_validation"]["chronological_train_val_test"] is True + assert payload["db_readonly"]["write_probe_blocked"] is True + + first = payload["episodes"][0] + assert first["episode_id"] == "000001_20250103" + assert first["split"] == "train" + assert first["reward_horizon_seconds"] == 300 + assert (out_dir / "episode_manifest.json").exists() + assert (out_dir / "episode_manifest.csv").exists() + assert (out_dir / "episode_summary.json").exists() + + +def test_build_episode_manifest_refuses_overlapping_split_sessions(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + report_path = tmp_path / "stom_qlib_export_report.json" + csv_dir = tmp_path / "qlib_csv" + _create_sqlite_db(db_path) + _write_episode_csv(csv_dir / "KR000001_20250103.csv") + report_path.write_text( + json.dumps( + { + "qlib_csv_dir": str(csv_dir), + "exported_group_count": 1, + "split_sessions": { + "train": ["20250103"], + "val": ["20250103"], + "test": [], + }, + "split_counts": {}, + } + ), + encoding="utf-8-sig", + ) + + with pytest.raises(ValueError, match="overlap"): + build_episode_manifest( + EpisodeManifestConfig( + db_path=str(db_path), + export_report_path=str(report_path), + output_dir=str(tmp_path / "out"), + ) + ) diff --git a/tests/test_stom_rl_exit_baselines.py b/tests/test_stom_rl_exit_baselines.py new file mode 100644 index 000000000..115c4200f --- /dev/null +++ b/tests/test_stom_rl_exit_baselines.py @@ -0,0 +1,241 @@ +"""Unit tests for causal exit baselines + Deflated Sharpe toolkit (Page R1b). + +RULE strategy, NOT reinforcement learning. Covers the causal trailing-stop +simulator, per-trade Sharpe, the Bailey-Lopez de Prado expected-max-Sharpe and +Deflated Sharpe Ratio (known-value checks), and the grid / walk-forward +orchestration. No DB is touched. +""" + +from __future__ import annotations + +import math + +import pytest + +from stom_rl.gap_up_backtest import EXIT_TIME, EXIT_TP, GapUpInstance +from stom_rl.exit_baselines import ( + EXIT_TRAIL, + deflated_sharpe_ratio, + default_candidate_grid, + evaluate_exit_candidates, + expected_max_sharpe, + per_trade_sharpe, + simulate_trailing_stop, + walk_forward_select, +) + + +def _approx(value: float, expected: float, tol: float = 1e-9) -> bool: + return abs(value - expected) <= tol + + +def _inst(session: str, prices, *, ts=150.0, imb=0.7) -> GapUpInstance: + return GapUpInstance( + symbol="SYM", + session=session, + entry_change_rate=2.5, + entry_price=float(prices[0]), + prices=tuple(float(p) for p in prices), + seconds=tuple(32400 + i for i in range(len(prices))), + entry_trade_strength=ts, + entry_bid_ask_imbalance=imb, + ) + + +# --------------------------------------------------------------------------- +# Causal trailing stop. +# --------------------------------------------------------------------------- +def test_trailing_exits_on_pullback_from_peak(): + # peak 110, trail 2% -> stop 107.8; bar at 107 breaches -> realized books 107. + r = simulate_trailing_stop([100.0, 110.0, 107.0], trail_pct=2.0, cost_bps=0.0) + assert r.exit_reason == EXIT_TRAIL + assert _approx(r.exit_price, 107.0) + assert _approx(r.net_return_pct, 7.0) + + +def test_trailing_acts_as_initial_stop(): + # peak starts at entry 100 -> initial stop 99; bar at 98 stops out at 98. + r = simulate_trailing_stop([100.0, 98.0], trail_pct=1.0, cost_bps=0.0) + assert r.exit_reason == EXIT_TRAIL + assert _approx(r.net_return_pct, -2.0) + + +def test_trailing_take_profit_checked_first(): + # TP5 level 105; bar to 106 fires TP (realized books 106) before any trail. + r = simulate_trailing_stop( + [100.0, 106.0, 90.0], trail_pct=5.0, tp_pct=5.0, cost_bps=0.0 + ) + assert r.exit_reason == EXIT_TP + assert _approx(r.net_return_pct, 6.0) + + +def test_trailing_time_exit_when_never_stopped(): + # Monotone rise within 5% trail -> never stops -> time-exit at last bar 103. + r = simulate_trailing_stop([100.0, 101.0, 102.0, 103.0], trail_pct=5.0, cost_bps=0.0) + assert r.exit_reason == EXIT_TIME + assert _approx(r.net_return_pct, 3.0) + + +def test_trailing_idealized_books_stop_level(): + # idealized books the stop LEVEL 107.8 (vs realized 107) on the same path. + r = simulate_trailing_stop( + [100.0, 110.0, 107.0], trail_pct=2.0, cost_bps=0.0, fill_mode="idealized" + ) + assert r.exit_reason == EXIT_TRAIL + assert _approx(r.net_return_pct, 7.8) + + +def test_trailing_rejects_bad_inputs(): + with pytest.raises(ValueError): + simulate_trailing_stop([100.0, 99.0], trail_pct=0.0) + with pytest.raises(ValueError): + simulate_trailing_stop([], trail_pct=2.0) + with pytest.raises(ValueError): + simulate_trailing_stop([0.0, 99.0], trail_pct=2.0) + with pytest.raises(ValueError): + simulate_trailing_stop([100.0, 99.0], trail_pct=2.0, cost_bps=-1.0) + with pytest.raises(ValueError): + simulate_trailing_stop([100.0, 99.0], trail_pct=2.0, fill_mode="bogus") + + +# --------------------------------------------------------------------------- +# Per-trade Sharpe. +# --------------------------------------------------------------------------- +def test_per_trade_sharpe_known_value(): + # mean 2, sample stdev sqrt(2) -> sharpe = 2/sqrt(2) = sqrt(2). + assert _approx(per_trade_sharpe([3.0, 1.0]), math.sqrt(2.0)) + + +def test_per_trade_sharpe_undefined_cases(): + assert per_trade_sharpe([1.0]) is None # < 2 samples + assert per_trade_sharpe([5.0, 5.0]) is None # zero variance + + +# --------------------------------------------------------------------------- +# Expected-max Sharpe (Bailey-Lopez de Prado). +# --------------------------------------------------------------------------- +def test_expected_max_sharpe_single_trial_is_zero(): + assert _approx(expected_max_sharpe(1, 1.0), 0.0) + + +def test_expected_max_sharpe_zero_variance_is_zero(): + assert _approx(expected_max_sharpe(50, 0.0), 0.0) + + +def test_expected_max_sharpe_increases_with_trials(): + assert expected_max_sharpe(2, 1.0) < expected_max_sharpe(10, 1.0) + assert expected_max_sharpe(10, 1.0) < expected_max_sharpe(100, 1.0) + + +def test_expected_max_sharpe_scales_with_sqrt_variance(): + # sqrt(4)=2x the sqrt(1) case for the same trial count. + assert _approx( + expected_max_sharpe(20, 4.0), 2.0 * expected_max_sharpe(20, 1.0), tol=1e-9 + ) + + +def test_expected_max_sharpe_rejects_bad_inputs(): + with pytest.raises(ValueError): + expected_max_sharpe(0, 1.0) + with pytest.raises(ValueError): + expected_max_sharpe(10, -1.0) + + +# --------------------------------------------------------------------------- +# Deflated Sharpe Ratio. +# --------------------------------------------------------------------------- +def test_dsr_equals_half_at_the_expected_max_threshold(): + # observed == SR0 -> z = 0 -> DSR = 0.5 (exactly at the multiple-testing bar). + sr0 = expected_max_sharpe(10, 1.0) + dsr = deflated_sharpe_ratio(sr0, n_trials=10, sharpe_variance=1.0, n_samples=100) + assert _approx(dsr, 0.5, tol=1e-9) + + +def test_dsr_monotonic_in_observed_sharpe(): + sr0 = expected_max_sharpe(10, 1.0) + below = deflated_sharpe_ratio(sr0 - 0.2, n_trials=10, sharpe_variance=1.0, n_samples=100) + above = deflated_sharpe_ratio(sr0 + 0.2, n_trials=10, sharpe_variance=1.0, n_samples=100) + assert below < 0.5 < above + + +def test_dsr_high_for_strong_single_trial(): + dsr = deflated_sharpe_ratio(5.0, n_trials=1, sharpe_variance=0.0, n_samples=1000) + assert dsr > 0.999 + + +def test_dsr_fat_tails_lower_confidence(): + # Higher kurtosis inflates the denominator -> lower DSR for the same edge. + base = deflated_sharpe_ratio(1.0, n_trials=1, sharpe_variance=0.0, n_samples=200, kurtosis=3.0) + fat = deflated_sharpe_ratio(1.0, n_trials=1, sharpe_variance=0.0, n_samples=200, kurtosis=12.0) + assert fat < base + + +def test_dsr_rejects_too_few_samples(): + with pytest.raises(ValueError): + deflated_sharpe_ratio(1.0, n_trials=5, sharpe_variance=1.0, n_samples=1) + + +# --------------------------------------------------------------------------- +# Candidate grid evaluation + walk-forward selection. +# --------------------------------------------------------------------------- +def _mixed_instances(): + # A spread of TP / trail / recover / flat paths across 10 distinct dates so + # net returns have variance (defined Sharpe) and the date split is non-empty. + shapes = [ + [100.0, 106.0, 107.0], # TP + [100.0, 98.0, 97.0], # down -> stop + [100.0, 98.0, 110.0], # dip then recover + [100.0, 101.0, 100.5], # flat-ish time exit + [100.0, 108.0, 102.0], # spike then fade + ] + return [ + _inst(f"202301{day:02d}", shapes[(day - 1) % len(shapes)]) + for day in range(1, 11) + ] + + +def test_evaluate_exit_candidates_shapes_output(): + grid = [ + {"name": "fixed_tp5_sl1", "kind": "fixed", "tp_pct": 5.0, "sl_pct": 1.0}, + {"name": "trail_2", "kind": "trail", "trail_pct": 2.0}, + ] + res = evaluate_exit_candidates(_mixed_instances(), grid, cost_bps=23.0) + assert {r["name"] for r in res} == {"fixed_tp5_sl1", "trail_2"} + for r in res: + assert r["n"] == 10 + assert r["mean_net_pct"] is not None + assert r["win_rate"] is not None + + +def test_default_grid_has_baseline_first(): + grid = default_candidate_grid() + assert grid[0]["name"] == "fixed_tp5_sl1" + # Pin the trial count: DSR's deflation denominator depends on N = len(grid). + assert len(grid) == 9 + # Baseline is fixed TP5/SL1; the grid widens SL and adds trailing variants. + assert any(c["kind"] == "trail" for c in grid) + assert any(c.get("sl_pct") == 3.0 for c in grid) + + +def test_evaluate_rejects_unsupported_fill_mode(): + grid = [{"name": "trail_2", "kind": "trail", "trail_pct": 2.0}] + with pytest.raises(ValueError): + evaluate_exit_candidates( + [_inst("20230101", [100.0, 101.0])], grid, fill_mode="sl_gap_stress" + ) + + +def test_walk_forward_select_structure_and_dsr_range(): + grid = [ + {"name": "fixed_tp5_sl1", "kind": "fixed", "tp_pct": 5.0, "sl_pct": 1.0}, + {"name": "fixed_tp5_sl2", "kind": "fixed", "tp_pct": 5.0, "sl_pct": 2.0}, + {"name": "trail_2", "kind": "trail", "trail_pct": 2.0}, + ] + wf = walk_forward_select(_mixed_instances(), grid, in_sample_fraction=0.7, cost_bps=23.0) + assert wf["n_trials"] == 3 + assert wf["selected"] in {"fixed_tp5_sl1", "fixed_tp5_sl2", "trail_2"} + assert wf["n_in_sample"] > 0 and wf["n_out_of_sample"] > 0 + # baseline OOS net is reported for the comparison. + assert wf["baseline_oos_mean_net_pct"] is not None + dsr = wf["deflated_sharpe_ratio"] + assert dsr is None or (0.0 <= dsr <= 1.0) diff --git a/tests/test_stom_rl_exit_oracle.py b/tests/test_stom_rl_exit_oracle.py new file mode 100644 index 000000000..89b358d4f --- /dev/null +++ b/tests/test_stom_rl_exit_oracle.py @@ -0,0 +1,179 @@ +"""Unit tests for the oracle-exit ceiling test (Page R1). + +RULE strategy, NOT reinforcement learning. These cover the pure regret core on +SYNTHETIC price paths. The ceiling uses REALIZED fills on both legs (oracle and +rule), so regret is non-negative and measures pure exit-*timing* headroom; an +idealized-fill probe documents why that default is deliberate. No DB is touched. +""" + +from __future__ import annotations + +import pytest + +from stom_rl.gap_up_backtest import EXIT_SL, EXIT_TIME, EXIT_TP, GapUpInstance, simulate_trade +from stom_rl.exit_oracle import ( + exit_regret_pct, + oracle_exit_net_pct, + rule_exit_net_pct, + summarize_exit_regret, +) + + +def _approx(value: float, expected: float, tol: float = 1e-9) -> bool: + return abs(value - expected) <= tol + + +def _inst(session: str, prices, *, ts=150.0, imb=0.7) -> GapUpInstance: + return GapUpInstance( + symbol="SYM", + session=session, + entry_change_rate=2.5, + entry_price=float(prices[0]), + prices=tuple(float(p) for p in prices), + seconds=tuple(32400 + i for i in range(len(prices))), + entry_trade_strength=ts, + entry_bid_ask_imbalance=imb, + ) + + +# --------------------------------------------------------------------------- +# Oracle: perfect-foresight best exit = max forward price, net of one cost. +# --------------------------------------------------------------------------- +def test_oracle_picks_max_forward_price(): + assert _approx(oracle_exit_net_pct([100.0, 105.0, 103.0], cost_bps=0.0), 5.0) + assert _approx(oracle_exit_net_pct([100.0, 103.0, 108.0, 90.0], cost_bps=0.0), 8.0) + + +def test_oracle_no_forward_bar_exits_at_entry(): + # Only the entry bar -> the only achievable exit is the entry price -> 0% gross. + assert _approx(oracle_exit_net_pct([100.0], cost_bps=0.0), 0.0) + + +def test_oracle_subtracts_one_round_trip_cost(): + # gross 10% minus 100bp (=1.0%) cost -> 9.0%. + assert _approx(oracle_exit_net_pct([100.0, 110.0], cost_bps=100.0), 9.0) + + +def test_oracle_negative_when_path_only_falls(): + # All forward prices below entry: even the oracle loses (best of 99, 98). + assert _approx(oracle_exit_net_pct([100.0, 99.0, 98.0], cost_bps=0.0), -1.0) + + +def test_oracle_rejects_bad_inputs(): + with pytest.raises(ValueError): + oracle_exit_net_pct([], cost_bps=0.0) + with pytest.raises(ValueError): + oracle_exit_net_pct([0.0, 100.0], cost_bps=0.0) + with pytest.raises(ValueError): + oracle_exit_net_pct([100.0, 101.0], cost_bps=-1.0) + + +# --------------------------------------------------------------------------- +# Rule wrapper matches simulate_trade under the (default) realized fill. +# --------------------------------------------------------------------------- +def test_rule_exit_matches_simulate_trade_realized(): + # entry 100, TP5 level 105; bar to 106 fires TP, realized books actual 106. + path = [100.0, 106.0, 90.0] + got = rule_exit_net_pct(path, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0) + expected = simulate_trade( + path, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, fill_mode="realized" + ).net_return_pct + assert _approx(got, expected) + assert _approx(got, 6.0) # realized books the actual 106, not the 105 level + + +# --------------------------------------------------------------------------- +# Regret = oracle - rule, non-negative (realized), cost-invariant. +# --------------------------------------------------------------------------- +def test_regret_tp_sold_before_a_higher_print(): + # Rule sells at the first TP cross (106); oracle catches the later 112 -> 6. + r = exit_regret_pct([100.0, 106.0, 112.0], tp_pct=5.0, sl_pct=1.0, cost_bps=0.0) + assert _approx(r, 6.0) + + +def test_regret_sl_cut_a_winner_that_recovered(): + # Rule stops out at the realized 98 (net -2); oracle catches the later 110 -> 12. + r = exit_regret_pct([100.0, 98.0, 110.0], tp_pct=5.0, sl_pct=1.0, cost_bps=0.0) + assert _approx(r, 12.0) + + +def test_regret_zero_when_rule_already_optimal(): + # Peak (105) is exactly where the rule's TP books -> no headroom. + r = exit_regret_pct([100.0, 105.0, 104.0], tp_pct=5.0, sl_pct=1.0, cost_bps=0.0) + assert _approx(r, 0.0) + + +def test_regret_is_non_negative_on_various_paths_realized(): + paths = [ + [100.0, 101.0, 99.5, 100.5], + [100.0, 97.0, 96.0], + [100.0, 120.0, 80.0], + [100.0, 100.0], + ] + for p in paths: + assert exit_regret_pct(p, tp_pct=5.0, sl_pct=1.0, cost_bps=23.0) >= -1e-9 + + +def test_regret_is_cost_invariant(): + # Both oracle and rule pay one round-trip cost, so cost cancels in the regret. + path = [100.0, 98.0, 107.0] # SL then recovery -> regret 9 at any cost + r0 = exit_regret_pct(path, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0) + r23 = exit_regret_pct(path, tp_pct=5.0, sl_pct=1.0, cost_bps=23.0) + r25 = exit_regret_pct(path, tp_pct=5.0, sl_pct=1.0, cost_bps=25.0) + assert _approx(r0, 9.0) + assert _approx(r0, r23) + assert _approx(r0, r25) + + +def test_idealized_fill_can_make_regret_negative_documents_why_default_is_realized(): + # Gap-through stop: oracle (realized) books 97 (-3); idealized rule books the + # SL LEVEL 99 (-1), an unattainable optimism -> regret = -3 - (-1) = -2 < 0. + r_ideal = exit_regret_pct( + [100.0, 97.0, 96.0], tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, fill_mode="idealized" + ) + assert r_ideal < 0.0 + # Realized fills fix it (rule books the realized 97 -> regret 0). + r_real = exit_regret_pct( + [100.0, 97.0, 96.0], tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, fill_mode="realized" + ) + assert _approx(r_real, 0.0) + + +# --------------------------------------------------------------------------- +# Summary aggregation incl. per-exit-reason breakdown (realized fills). +# --------------------------------------------------------------------------- +def test_summarize_empty_is_safe(): + s = summarize_exit_regret([]) + assert s["n"] == 0 + assert s["regret_mean_pct"] is None + assert s["by_exit_reason"] == {} + + +def test_summarize_known_values_and_exit_reason_breakdown(): + # TP : (100,106) -> rule realized 106 +6 ; oracle 106 -> regret 0 + # SL : (100,98,110) -> rule realized 98 -2 ; oracle 110 -> regret 12 + # TIME : (100,101,100.5) -> rule last 100.5 +.5; oracle 101 -> regret 0.5 + insts = [ + _inst("20230101", [100.0, 106.0]), + _inst("20230102", [100.0, 98.0, 110.0]), + _inst("20230103", [100.0, 101.0, 100.5]), + ] + s = summarize_exit_regret(insts, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0) + assert s["n"] == 3 + assert _approx(s["regret_mean_pct"], (0.0 + 12.0 + 0.5) / 3, tol=1e-9) + assert _approx(s["regret_median_pct"], 0.5) + assert _approx(s["regret_max_pct"], 12.0) + by = s["by_exit_reason"] + assert by[EXIT_TP]["n"] == 1 and _approx(by[EXIT_TP]["regret_mean_pct"], 0.0) + assert by[EXIT_SL]["n"] == 1 and _approx(by[EXIT_SL]["regret_mean_pct"], 12.0) + assert by[EXIT_TIME]["n"] == 1 and _approx(by[EXIT_TIME]["regret_mean_pct"], 0.5) + + +def test_summarize_frac_rule_optimal(): + # One trade where the rule is already optimal (regret 0), one with headroom. + insts = [ + _inst("20230101", [100.0, 105.0, 104.0]), # regret 0 (rule optimal) + _inst("20230102", [100.0, 98.0, 110.0]), # regret 12 (SL cut a winner) + ] + s = summarize_exit_regret(insts, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0) + assert _approx(s["frac_rule_optimal"], 0.5) diff --git a/tests/test_stom_rl_feature_expansion.py b/tests/test_stom_rl_feature_expansion.py new file mode 100644 index 000000000..0fecbc593 --- /dev/null +++ b/tests/test_stom_rl_feature_expansion.py @@ -0,0 +1,949 @@ +import json +import sqlite3 +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from finetune.qlib_stom_pipeline import ( + STOM_RL_CANONICAL_FEATURES, + STOM_RL_TRADE_STRENGTH_AVG_WINDOW, + STOM_RL_TREND_WINDOW, + STOM_RL_VOLATILITY_DDOF, + build_stom_rl_feature_frame, + export_stom_rl_features, + resample_stom_rl_source_frame, +) +from stom_rl.trading_env import StomTickTradingEnv, StomTickTradingEnvConfig + + +# STOM tick DB column order (UTF-8 Korean names), trimmed to the fields needed +# by the RL feature export. Mirrors the real per-symbol table layout so the +# export path can be exercised without the 29.7GB production database. +_FIXTURE_COLUMNS = [ + "index", + "현재가", + "시가", + "고가", + "저가", + "초당매수수량", + "초당매도수량", + "체결강도", + "초당거래대금", + "회전율", + "매수총잔량", + "매도총잔량", + "매수호가1", + "매도호가1", + "등락율", + "시가총액", + "고저평균대비등락율", +] + + +def _make_fixture_db(db_path: Path, rows: list[tuple]) -> None: + """Create a tiny STOM-shaped sqlite DB with UTF-8 Korean column names.""" + + conn = sqlite3.connect(str(db_path)) + try: + column_defs = ", ".join(f'"{name}"' for name in _FIXTURE_COLUMNS) + conn.execute(f'CREATE TABLE "000020" ({column_defs})') + placeholders = ", ".join(["?"] * len(_FIXTURE_COLUMNS)) + conn.executemany(f'INSERT INTO "000020" VALUES ({placeholders})', rows) + conn.commit() + finally: + conn.close() + + +def _fixture_row(idx: int, close: float, buy: float, sell: float, amount: float, strength: float = 200.0) -> tuple: + # index, 현재가, 시가, 고가, 저가, 초당매수수량, 초당매도수량, 체결강도, + # 초당거래대금, 회전율, 매수총잔량, 매도총잔량, 매수호가1, 매도호가1, + # 등락율, 시가총액, 고저평균대비등락율 + return ( + idx, + close, + close, + close + 10.0, + close - 10.0, + buy, + sell, + strength, + amount, + 0.05, + 80.0, + 20.0, + close - 10.0, + close + 10.0, + 1.25, + 45000.0, + -0.5, + ) + + +def test_build_stom_rl_feature_frame_maps_db_like_columns(): + frame = pd.DataFrame( + { + "symbol": ["000001", "000001"], + "session": ["20250103", "20250103"], + "open": [100, 101], + "high": [101, 102], + "low": [99, 100], + "close": [100, 101], + "volume": [10, 11], + "amount": [1000, 1111], + "trade_strength": [600, 250], + "buy_qty": [7, 9], + "sell_qty": [2, 3], + "bid_qty": [80, 90], + "ask_qty": [20, 30], + "bid_price": [99, 100], + "ask_price": [101, 102], + "turnover_rate": [1.5, 1.7], + } + ) + + features = build_stom_rl_feature_frame(frame) + + assert list(features.columns) == STOM_RL_CANONICAL_FEATURES + assert features["trade_strength"].iloc[0] == 500 + assert features["net_buy_qty_1s"].tolist() == [5, 6] + assert features["bid_ask_imbalance"].iloc[0] == pytest.approx(0.8) + assert features["spread_ticks"].iloc[0] == pytest.approx(2.0) + assert features["amount_delta"].tolist() == [0.0, 111.0] + + +def test_trading_env_accepts_configured_extra_feature_columns(tmp_path: Path): + csv_path = tmp_path / "KR000001_20250103.csv" + base = pd.Timestamp("2025-01-03 09:00:00") + rows = 16 + frame = pd.DataFrame( + { + "symbol": "KR000001", + "date": [(base + pd.Timedelta(seconds=i)).strftime("%Y-%m-%d %H:%M:%S") for i in range(rows)], + "open": np.arange(rows) + 100.0, + "high": np.arange(rows) + 101.0, + "low": np.arange(rows) + 99.0, + "close": np.arange(rows) + 100.0, + "volume": np.arange(rows) + 10.0, + "amount": (np.arange(rows) + 100.0) * (np.arange(rows) + 10.0), + "trade_strength": np.arange(rows) + 200.0, + "net_buy_qty_1s": np.arange(rows) - 2.0, + } + ) + frame.to_csv(csv_path, index=False, encoding="utf-8-sig") + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "episodes": [ + { + "episode_id": "000001_20250103", + "symbol": "000001", + "session": "20250103", + "split": "train", + "source_csv": str(csv_path), + } + ] + } + ), + encoding="utf-8-sig", + ) + + env = StomTickTradingEnv( + StomTickTradingEnvConfig( + manifest_path=str(manifest_path), + split="train", + lookback_window=5, + reward_horizon_seconds=3, + feature_columns=("open", "close", "trade_strength", "net_buy_qty_1s"), + ) + ) + observation, info = env.reset(seed=1) + + assert observation.shape == (5, 7) + assert info["feature_columns"] == [ + "open", + "close", + "trade_strength", + "net_buy_qty_1s", + "position", + "unrealized_return", + "time_in_position", + ] + + +def test_export_stom_rl_features_yields_canonical_columns(tmp_path: Path): + db_path = tmp_path / "fixture.db" + rows = [ + _fixture_row(20221212090005 + i, close=9260.0 + i, buy=10.0 + i, sell=3.0 + i, amount=100.0 + i) + for i in range(8) + ] + _make_fixture_db(db_path, rows) + + report = export_stom_rl_features( + db_path=db_path, + output_dir=tmp_path / "out", + table="000020", + session="20221212", + time_start="090000", + time_end="093000", + max_rows=5000, + ) + + csv_path = Path(report["csv_path"]) + assert csv_path.exists() + frame = pd.read_csv(csv_path, dtype={"symbol": str, "session": str}, encoding="utf-8-sig") + + feature_columns = [c for c in frame.columns if c in STOM_RL_CANONICAL_FEATURES] + assert feature_columns == STOM_RL_CANONICAL_FEATURES + assert list(frame.columns)[:3] == ["timestamp", "symbol", "session"] + + assert report["source"]["row_count"] == 8 + assert report["source"]["unresolved_targets"] == [] + assert report["source"]["encoding_confirmed"] is True + assert frame["symbol"].iloc[0] == "000020" + + +def test_export_stom_rl_features_has_no_nan_or_inf(tmp_path: Path): + db_path = tmp_path / "fixture.db" + rows = [ + _fixture_row(20221212090005 + i, close=9260.0, buy=0.0, sell=0.0, amount=0.0) + for i in range(5) + ] + _make_fixture_db(db_path, rows) + + report = export_stom_rl_features( + db_path=db_path, + output_dir=tmp_path / "out", + table="000020", + session="20221212", + ) + + assert report["scale"]["nan_inf_clean"] is True + assert report["scale"]["has_nan"] is False + assert report["scale"]["has_inf"] is False + + frame = pd.read_csv(Path(report["csv_path"]), encoding="utf-8-sig") + feature_frame = frame[STOM_RL_CANONICAL_FEATURES] + assert not feature_frame.isna().any().any() + assert np.isfinite(feature_frame.to_numpy(dtype="float64")).all() + + +def test_export_stom_rl_features_is_leakage_free(tmp_path: Path): + """A future-dated row beyond the window must not change features at rows <= T.""" + + base_rows = [ + _fixture_row(20221212090005 + i, close=9260.0 + i, buy=10.0 + i, sell=3.0, amount=100.0 + i) + for i in range(6) + ] + # A row outside the 09:00-09:30 window with extreme values that would shift + # scale/diff features if it ever leaked backward into <= T rows. + future_row = _fixture_row(20221212094000, close=99999.0, buy=99999.0, sell=99999.0, amount=99999.0) + + db_baseline = tmp_path / "baseline.db" + db_with_future = tmp_path / "with_future.db" + _make_fixture_db(db_baseline, base_rows) + _make_fixture_db(db_with_future, base_rows + [future_row]) + + report_baseline = export_stom_rl_features( + db_path=db_baseline, + output_dir=tmp_path / "out_baseline", + table="000020", + session="20221212", + time_start="090000", + time_end="093000", + ) + report_future = export_stom_rl_features( + db_path=db_with_future, + output_dir=tmp_path / "out_future", + table="000020", + session="20221212", + time_start="090000", + time_end="093000", + ) + + frame_baseline = pd.read_csv(Path(report_baseline["csv_path"]), encoding="utf-8-sig") + frame_future = pd.read_csv(Path(report_future["csv_path"]), encoding="utf-8-sig") + + # The window filter drops the future row, so both frames must be identical. + assert frame_baseline.shape == frame_future.shape + pd.testing.assert_frame_equal( + frame_baseline[STOM_RL_CANONICAL_FEATURES], + frame_future[STOM_RL_CANONICAL_FEATURES], + ) + + # Point-in-time check: amount_delta at row T equals amount[T] - amount[T-1] + # (backward-only differencing, never forward-looking). + amount = frame_baseline["amount"].to_numpy(dtype="float64") + amount_delta = frame_baseline["amount_delta"].to_numpy(dtype="float64") + assert amount_delta[0] == 0.0 + np.testing.assert_allclose(amount_delta[1:], amount[1:] - amount[:-1]) + + +# --------------------------------------------------------------------------- +# Stage C feature expansion: the 4 new canonical features. +# --------------------------------------------------------------------------- +def test_build_stom_rl_feature_frame_adds_stage_c_features(): + """The 4 Stage C features are produced from DB-like Korean columns and + are finite (no NaN/inf).""" + + n = 6 + frame = pd.DataFrame( + { + "symbol": ["000001"] * n, + "session": ["20250103"] * n, + "open": np.arange(n) + 100.0, + "high": np.arange(n) + 101.0, + "low": np.arange(n) + 99.0, + "close": np.arange(n) + 100.0, + "volume": np.arange(n) + 10.0, + "amount": (np.arange(n) + 100.0) * 5.0, + "체결강도": np.arange(n) + 200.0, + "등락율": np.linspace(-2.0, 3.0, n), + "시가총액": np.full(n, 45000.0), + "고저평균대비등락율": np.linspace(-1.0, 1.0, n), + } + ) + + features = build_stom_rl_feature_frame(frame) + + # Canonical set is now 22 wide (18 Stage-C + 4 Stage-2 trend features). + assert len(STOM_RL_CANONICAL_FEATURES) == 22 + for new_col in ( + "change_rate", + "market_cap", + "high_low_mid_change_rate", + "trade_strength_avg_n", + ): + assert new_col in features.columns + + block = features[ + ["change_rate", "market_cap", "high_low_mid_change_rate", "trade_strength_avg_n"] + ] + assert not block.isna().any().any() + assert np.isfinite(block.to_numpy(dtype="float64")).all() + + # Direct passthroughs map their source column values. + np.testing.assert_allclose(features["change_rate"].to_numpy(), np.linspace(-2.0, 3.0, n)) + np.testing.assert_allclose( + features["high_low_mid_change_rate"].to_numpy(), np.linspace(-1.0, 1.0, n) + ) + # market_cap is log1p-normalized from the large 시가총액 magnitude. + np.testing.assert_allclose(features["market_cap"].to_numpy(), np.log1p(45000.0)) + + +def test_trade_strength_avg_n_is_causal_trailing_mean(): + """``trade_strength_avg_n`` at row T equals the trailing mean of + trade_strength over rows <= T, and is UNCHANGED when future rows are + appended or modified (no look-ahead).""" + + window = STOM_RL_TRADE_STRENGTH_AVG_WINDOW + n = 40 + strengths = (np.arange(n) % 7) * 30.0 + 50.0 # within the [0, 500] band + + def _frame(values: np.ndarray) -> pd.DataFrame: + m = len(values) + return pd.DataFrame( + { + "symbol": ["000001"] * m, + "session": ["20250103"] * m, + "open": np.full(m, 100.0), + "high": np.full(m, 101.0), + "low": np.full(m, 99.0), + "close": np.full(m, 100.0), + "volume": np.full(m, 10.0), + "amount": np.full(m, 500.0), + "체결강도": values, + } + ) + + base = build_stom_rl_feature_frame(_frame(strengths)) + avg = base["trade_strength_avg_n"].to_numpy(dtype="float64") + + # Reference: explicit backward-only window mean over rows [max(0, T-N+1), T]. + clipped = np.clip(strengths, 0.0, 500.0) + expected = np.array( + [clipped[max(0, t - window + 1) : t + 1].mean() for t in range(n)] + ) + np.testing.assert_allclose(avg, expected) + + # Leakage guard: append extreme FUTURE rows; rows <= T must be byte-identical. + future = np.concatenate([strengths, np.full(10, 9999.0)]) + extended = build_stom_rl_feature_frame(_frame(future)) + np.testing.assert_allclose( + extended["trade_strength_avg_n"].to_numpy(dtype="float64")[:n], avg + ) + + # Modifying ONLY a future row must not change any row at or before T. + mutated = strengths.copy() + mutated_future = np.concatenate([mutated, np.full(5, 0.0)]) + mutated_future[n + 2] = 12345.0 # a far-future spike + mutated_frame = build_stom_rl_feature_frame(_frame(mutated_future)) + np.testing.assert_allclose( + mutated_frame["trade_strength_avg_n"].to_numpy(dtype="float64")[:n], avg + ) + + +def test_export_stom_rl_features_populates_stage_c_columns(tmp_path: Path): + """The real export path emits the 4 new columns, populated and finite.""" + + db_path = tmp_path / "fixture.db" + rows = [ + _fixture_row(20221212090005 + i, close=9260.0 + i, buy=10.0 + i, sell=3.0 + i, amount=100.0 + i) + for i in range(8) + ] + _make_fixture_db(db_path, rows) + + report = export_stom_rl_features( + db_path=db_path, + output_dir=tmp_path / "out", + table="000020", + session="20221212", + time_start="090000", + time_end="093000", + max_rows=5000, + ) + + frame = pd.read_csv(Path(report["csv_path"]), encoding="utf-8-sig") + for new_col in ( + "change_rate", + "market_cap", + "high_low_mid_change_rate", + "trade_strength_avg_n", + ): + assert new_col in frame.columns + assert np.isfinite(frame[new_col].to_numpy(dtype="float64")).all() + + # 등락율=1.25, 고저평균대비등락율=-0.5, 시가총액=45000 in every fixture row. + np.testing.assert_allclose(frame["change_rate"].to_numpy(), 1.25) + np.testing.assert_allclose(frame["high_low_mid_change_rate"].to_numpy(), -0.5) + np.testing.assert_allclose(frame["market_cap"].to_numpy(), np.log1p(45000.0)) + # Constant strength=200 fixture -> trailing mean is 200 everywhere. + np.testing.assert_allclose(frame["trade_strength_avg_n"].to_numpy(), 200.0) + assert report["canonical_features"] == list(STOM_RL_CANONICAL_FEATURES) + + +# --------------------------------------------------------------------------- +# Stage 2 — net-new RL resampler + causal trend features. +# Test ordering note (plan R7): V-RESAMPLE-SEMANTICS and V-NONDEGEN are +# feature-integrity gates that MUST hold before any alpha/shuffle use; they +# assert loudly so a silent-zero / mis-aggregated feature can never reach the +# alpha verdict. +# --------------------------------------------------------------------------- + + +def _rl_source_minute_frame() -> pd.DataFrame: + """A synthetic RL *source* frame: 1 symbol/session, two 1-min buckets of + per-second rows, carrying the DB-named source columns the resampler keys on. + + Bucket A = 09:00:00..09:00:02 (3 seconds), bucket B = 09:01:00..09:01:01 + (2 seconds). Flow columns vary per second (so SUM != LAST); book/rate + columns step DOWN within each bucket so the last-second value is distinct + from first/mean (so LAST is verifiable). + """ + + ts = [ + pd.Timestamp("2025-01-03 09:00:00"), + pd.Timestamp("2025-01-03 09:00:01"), + pd.Timestamp("2025-01-03 09:00:02"), + pd.Timestamp("2025-01-03 09:01:00"), + pd.Timestamp("2025-01-03 09:01:01"), + ] + return pd.DataFrame( + { + "timestamp": ts, + "symbol": ["000001"] * 5, + "session": ["20250103"] * 5, + "open": [100.0, 101.0, 102.0, 110.0, 111.0], + "high": [105.0, 106.0, 107.0, 115.0, 116.0], + "low": [99.0, 98.0, 97.0, 108.0, 107.0], + "close": [101.0, 102.0, 103.0, 111.0, 112.0], + "초당매수수량": [10.0, 20.0, 30.0, 40.0, 50.0], + "초당매도수량": [1.0, 2.0, 3.0, 4.0, 5.0], + "volume": [11.0, 22.0, 33.0, 44.0, 55.0], + "amount": [100.0, 200.0, 300.0, 400.0, 500.0], + "매수총잔량": [80.0, 70.0, 60.0, 50.0, 40.0], + "매도총잔량": [20.0, 25.0, 30.0, 35.0, 40.0], + "매수호가1": [99.0, 98.0, 97.0, 107.0, 106.0], + "매도호가1": [101.0, 102.0, 103.0, 113.0, 114.0], + "등락율": [1.0, 1.5, 2.0, 2.5, 3.0], + "회전율": [0.10, 0.11, 0.12, 0.13, 0.14], + "시가총액": [45000.0, 45010.0, 45020.0, 45030.0, 45040.0], + "고저평균대비등락율": [-1.0, -0.5, 0.0, 0.5, 1.0], + "체결강도": [200.0, 210.0, 220.0, 230.0, 240.0], + } + ) + + +def test_v_resample_semantics_flow_sum_book_rate_last(): + """V-RESAMPLE-SEMANTICS: on a synthetic minute the net-new resampler sums + flow columns (incl. amount, per Stage-1 §2), snapshots book/rate columns to + the LAST second, keeps OHLC=first/max/min/last, and preserves ALL inputs.""" + + src = _rl_source_minute_frame() + out = resample_stom_rl_source_frame(src, freq="1min") + + # Two 1-min buckets, labeled at bucket START (floor("min")). + assert len(out) == 2 + assert list(out["timestamp"]) == [ + pd.Timestamp("2025-01-03 09:00:00"), + pd.Timestamp("2025-01-03 09:01:00"), + ] + + # ALL source inputs survive the resample (no silent drop, R1). + for col in src.columns: + assert col in out.columns + + bucket_a = out.iloc[0] + bucket_b = out.iloc[1] + + # OHLC: first / max / min / last over the bucket. + assert bucket_a["open"] == 100.0 # first + assert bucket_a["high"] == 107.0 # max + assert bucket_a["low"] == 97.0 # min + assert bucket_a["close"] == 103.0 # last + + # Flow -> SUM == hand-sum of the per-second values. + assert bucket_a["초당매수수량"] == 10.0 + 20.0 + 30.0 + assert bucket_a["초당매도수량"] == 1.0 + 2.0 + 3.0 + assert bucket_a["volume"] == 11.0 + 22.0 + 33.0 + # CRITICAL Stage-1 branch: amount == SUM (per-second 초당거래대금), NOT last. + assert bucket_a["amount"] == 100.0 + 200.0 + 300.0 + assert bucket_b["amount"] == 400.0 + 500.0 + + # Book + rate/snapshot -> LAST second's value in the bucket. + assert bucket_a["매수총잔량"] == 60.0 + assert bucket_a["매도총잔량"] == 30.0 + assert bucket_a["매수호가1"] == 97.0 + assert bucket_a["매도호가1"] == 103.0 + assert bucket_a["등락율"] == 2.0 + assert bucket_a["회전율"] == 0.12 + assert bucket_a["시가총액"] == 45020.0 + assert bucket_a["고저평균대비등락율"] == 0.0 + assert bucket_a["체결강도"] == 220.0 + + +def test_v_resample_freq_1s_is_identity(): + """freq='1s' returns the frame unchanged so the 1s path stays byte-identical.""" + + src = _rl_source_minute_frame() + out = resample_stom_rl_source_frame(src, freq="1s") + pd.testing.assert_frame_equal(out, src) + + +def test_v_nondegen_one_minute_panel_features_have_variance(): + """V-NONDEGEN: a 1-min panel's flow/book/rate + trend features have NON-ZERO + variance. All-zero would mean the resample silently dropped source columns + (R1/R5) -> manufactured false null -> FAIL LOUDLY here.""" + + # Many per-second rows across several minutes so each 1-min bucket is dense + # and the trend features have multiple bars to vary over. + base = pd.Timestamp("2025-01-03 09:00:00") + n = 300 # 5 minutes of per-second rows + rng = np.arange(n, dtype="float64") + src = pd.DataFrame( + { + "timestamp": [base + pd.Timedelta(seconds=int(i)) for i in range(n)], + "symbol": ["000001"] * n, + "session": ["20250103"] * n, + "open": 100.0 + rng * 0.1, + "high": 101.0 + rng * 0.1, + "low": 99.0 + rng * 0.1, + "close": 100.0 + np.sin(rng / 5.0) * 3.0 + rng * 0.05, + "초당매수수량": 10.0 + (rng % 7), + "초당매도수량": 3.0 + (rng % 5), + "volume": 13.0 + (rng % 7) + (rng % 5), + "amount": 100.0 + (rng % 11) * 10.0, + "매수총잔량": 80.0 + (rng % 13), + "매도총잔량": 20.0 + (rng % 9), + "매수호가1": 99.0 + rng * 0.1, + "매도호가1": 101.0 + rng * 0.1, + "등락율": np.sin(rng / 4.0) * 2.0, + "회전율": 0.10 + (rng % 3) * 0.01, + "시가총액": 45000.0 + rng, + "고저평균대비등락율": np.cos(rng / 6.0), + "체결강도": 150.0 + (rng % 17) * 5.0, + } + ) + + resampled = resample_stom_rl_source_frame(src, freq="1min") + features = build_stom_rl_feature_frame(resampled) + + # The resampled panel must have multiple 1-min bars (else variance is vacuous). + assert len(features) >= 4 + + must_vary = [ + "trade_strength", + "bid_ask_imbalance", + "change_rate", + "amount", + "volume", + "moving_average_n", + "volatility_n", + "amount_slope_n", + "change_rate_slope_n", + ] + for col in must_vary: + variance = float(features[col].var()) + assert variance > 0.0, f"feature {col!r} is degenerate (zero variance) on the 1-min panel" + + +def test_v_freq_no_lookahead_bar_carries_no_next_bar_value(): + """V-FREQ: a 1-min bar T carries NO value from bar T+1. A synthetic monotone + per-second series resampled to 1-min must label at the bucket start and the + last-second (LAST) columns of bar T must equal bar T's own last second, never + bar T+1's.""" + + base = pd.Timestamp("2025-01-03 09:00:00") + rows = [] + # Two minutes, monotone-increasing 등락율 across the whole series. + for minute in range(2): + for sec in range(3): + t = base + pd.Timedelta(minutes=minute, seconds=sec) + val = minute * 100.0 + sec * 10.0 # strictly increasing + rows.append((t, val)) + src = pd.DataFrame( + { + "timestamp": [r[0] for r in rows], + "symbol": ["000001"] * len(rows), + "session": ["20250103"] * len(rows), + "open": [100.0] * len(rows), + "high": [101.0] * len(rows), + "low": [99.0] * len(rows), + "close": [100.0] * len(rows), + "amount": [r[1] for r in rows], + "등락율": [r[1] for r in rows], + } + ) + + out = resample_stom_rl_source_frame(src, freq="1min") + assert list(out["timestamp"]) == [base, base + pd.Timedelta(minutes=1)] + + # Bar T=0 (09:00) LAST 등락율 is its OWN last second (sec=2 -> 20.0), NOT any + # value from bar T=1 (09:01, which starts at 100.0). No look-ahead. + assert out.iloc[0]["등락율"] == 20.0 + assert out.iloc[1]["등락율"] == 120.0 + # The bucket-start label coheres with the grid-agnostic T+1 fill: a decision + # at bar T fills at T+1 = the NEXT 1-min bar, so bar T must not embed T+1. + assert out.iloc[0]["timestamp"] < out.iloc[1]["timestamp"] + + +@pytest.mark.parametrize( + "feature,source_col,source_values", + [ + ("moving_average_n", "close", None), + ("volatility_n", "등락율", None), + ("amount_slope_n", "amount", None), + ("change_rate_slope_n", "등락율", None), + ], +) +def test_v_causal_trend_feature_is_strictly_trailing(feature, source_col, source_values): + """V-CAUSAL: each trend feature at row T is UNCHANGED when future bars are + appended or a far-future bar is mutated (row T uses only bars <= T).""" + + n = 24 + driver = (np.arange(n, dtype="float64") % 6) * 3.0 + 1.0 if source_values is None else source_values + + def _frame(values: np.ndarray) -> pd.DataFrame: + m = len(values) + data = { + "symbol": ["000001"] * m, + "session": ["20250103"] * m, + "open": np.full(m, 100.0), + "high": np.full(m, 101.0), + "low": np.full(m, 99.0), + "close": np.full(m, 100.0), + "volume": np.full(m, 10.0), + "amount": np.full(m, 500.0), + "체결강도": np.full(m, 200.0), + "등락율": np.zeros(m), + } + data[source_col] = values + return pd.DataFrame(data) + + base = build_stom_rl_feature_frame(_frame(driver)) + base_vals = base[feature].to_numpy(dtype="float64") + + # Append extreme FUTURE bars; rows <= T must be byte-identical. + extended = np.concatenate([driver, np.full(8, 9999.0)]) + ext_vals = build_stom_rl_feature_frame(_frame(extended))[feature].to_numpy(dtype="float64") + np.testing.assert_allclose(ext_vals[:n], base_vals) + + # Mutate ONLY a far-future bar; no row at or before T may change. + mutated = np.concatenate([driver, np.full(5, 0.0)]) + mutated[n + 2] = -88888.0 + mut_vals = build_stom_rl_feature_frame(_frame(mutated))[feature].to_numpy(dtype="float64") + np.testing.assert_allclose(mut_vals[:n], base_vals) + + +def test_trend_slope_matches_closed_form_trailing_ols(): + """``amount_slope_n`` equals the hand-computed trailing-OLS slope + cov(x,y)/var(x) over the last N bars (the Stage-1 LOCKED formula).""" + + window = STOM_RL_TREND_WINDOW + n = 15 + amounts = np.arange(n, dtype="float64") ** 1.5 + 10.0 + frame = pd.DataFrame( + { + "symbol": ["000001"] * n, + "session": ["20250103"] * n, + "open": np.full(n, 100.0), + "high": np.full(n, 101.0), + "low": np.full(n, 99.0), + "close": np.full(n, 100.0), + "volume": np.full(n, 10.0), + "amount": amounts, + "체결강도": np.full(n, 200.0), + "등락율": np.zeros(n), + } + ) + out = build_stom_rl_feature_frame(frame) + got = out["amount_slope_n"].to_numpy(dtype="float64") + + def _ols(y: np.ndarray) -> float: + k = y.size + if k < 2: + return 0.0 # min_periods=2 -> filled to 0.0 by build frame + x = np.arange(k, dtype="float64") + xm, ym = x.mean(), y.mean() + var_x = ((x - xm) ** 2).sum() + return float(((x - xm) * (y - ym)).sum() / var_x) + + expected = np.array( + [_ols(amounts[max(0, t - window + 1) : t + 1]) for t in range(n)] + ) + # Row 0 has a single point -> NaN -> filled to 0.0 by build frame. + expected[0] = 0.0 + np.testing.assert_allclose(got, expected, rtol=1e-9, atol=1e-9) + + +def test_volatility_uses_locked_population_std_ddof(): + """``volatility_n`` is the trailing population std (ddof=0, Stage-1 LOCKED) of + change_rate over the last N bars.""" + + assert STOM_RL_VOLATILITY_DDOF == 0 + window = STOM_RL_TREND_WINDOW + n = 14 + rates = np.sin(np.arange(n, dtype="float64") / 2.0) * 2.0 + frame = pd.DataFrame( + { + "symbol": ["000001"] * n, + "session": ["20250103"] * n, + "open": np.full(n, 100.0), + "high": np.full(n, 101.0), + "low": np.full(n, 99.0), + "close": np.full(n, 100.0), + "volume": np.full(n, 10.0), + "amount": np.full(n, 500.0), + "체결강도": np.full(n, 200.0), + "등락율": rates, + } + ) + out = build_stom_rl_feature_frame(frame) + got = out["volatility_n"].to_numpy(dtype="float64") + + expected = np.array( + [ + float(np.std(rates[max(0, t - window + 1) : t + 1], ddof=0)) + for t in range(n) + ] + ) + np.testing.assert_allclose(got, expected, rtol=1e-9, atol=1e-9) + + +# --------------------------------------------------------------------------- +# Story B1 — session-bar ("daily proxy") resample mode. +# +# freq="session" collapses ALL rows of one (symbol, session) into ONE bar using +# the SAME LOCKED per-column aggregation as the 1-min path (OHLC=first/max/min/ +# last, flow/amount=SUM, book/rate=LAST), timestamps the bar at the session +# date, and (via build_stom_rl_feature_frame trend_group_keys) computes the +# causal trend features ACROSS the per-symbol session series with no look-ahead. +# This is a CAVEATED PROXY (morning-30min, selection-biased), NOT a daily bar. +# --------------------------------------------------------------------------- +def _rl_source_two_session_frame() -> pd.DataFrame: + """One symbol, TWO sessions, several per-second rows each. + + Session A = 20250103 (3 seconds), session B = 20250106 (2 seconds). Flow + columns vary per second (SUM != LAST); book/rate columns step within each + session so LAST is distinguishable from first/mean. + """ + + return pd.DataFrame( + { + "timestamp": [ + pd.Timestamp("2025-01-03 09:00:00"), + pd.Timestamp("2025-01-03 09:00:01"), + pd.Timestamp("2025-01-03 09:00:02"), + pd.Timestamp("2025-01-06 09:00:00"), + pd.Timestamp("2025-01-06 09:00:01"), + ], + "symbol": ["000001"] * 5, + "session": ["20250103", "20250103", "20250103", "20250106", "20250106"], + "open": [100.0, 101.0, 102.0, 110.0, 111.0], + "high": [105.0, 106.0, 107.0, 115.0, 116.0], + "low": [99.0, 98.0, 97.0, 108.0, 107.0], + "close": [101.0, 102.0, 103.0, 111.0, 112.0], + "초당매수수량": [10.0, 20.0, 30.0, 40.0, 50.0], + "초당매도수량": [1.0, 2.0, 3.0, 4.0, 5.0], + "volume": [11.0, 22.0, 33.0, 44.0, 55.0], + "amount": [100.0, 200.0, 300.0, 400.0, 500.0], + "매수총잔량": [80.0, 70.0, 60.0, 50.0, 40.0], + "매도총잔량": [20.0, 25.0, 30.0, 35.0, 40.0], + "매수호가1": [99.0, 98.0, 97.0, 107.0, 106.0], + "매도호가1": [101.0, 102.0, 103.0, 113.0, 114.0], + "등락율": [1.0, 1.5, 2.0, 2.5, 3.0], + "회전율": [0.10, 0.11, 0.12, 0.13, 0.14], + "시가총액": [45000.0, 45010.0, 45020.0, 45030.0, 45040.0], + "고저평균대비등락율": [-1.0, -0.5, 0.0, 0.5, 1.0], + "체결강도": [200.0, 210.0, 220.0, 230.0, 240.0], + } + ) + + +def test_v_session_resample_one_bar_per_symbol_session(): + """V-SESSION-BAR: freq='session' produces exactly ONE bar per (symbol, + session), timestamped at the session date, with the LOCKED per-column agg.""" + + src = _rl_source_two_session_frame() + out = resample_stom_rl_source_frame(src, freq="session") + + # Exactly one bar per session (2 sessions for 1 symbol). + assert len(out) == 2 + assert list(out["session"]) == ["20250103", "20250106"] + # Bar timestamp = the session DATE (midnight), so cross-session bars of + # different symbols align on a shared session-date axis. + assert list(out["timestamp"]) == [ + pd.Timestamp("2025-01-03 00:00:00"), + pd.Timestamp("2025-01-06 00:00:00"), + ] + + # ALL source inputs survive (no silent drop -> no manufactured null). + for col in src.columns: + assert col in out.columns + + sess_a = out.iloc[0] + sess_b = out.iloc[1] + + # OHLC over the WHOLE session: first / max / min / last. + assert sess_a["open"] == 100.0 + assert sess_a["high"] == 107.0 + assert sess_a["low"] == 97.0 + assert sess_a["close"] == 103.0 + assert sess_b["open"] == 110.0 + assert sess_b["close"] == 112.0 + + # Flow + amount -> SUM over the entire session (Stage-1 LOCKED). + assert sess_a["초당매수수량"] == 10.0 + 20.0 + 30.0 + assert sess_a["초당매도수량"] == 1.0 + 2.0 + 3.0 + assert sess_a["volume"] == 11.0 + 22.0 + 33.0 + assert sess_a["amount"] == 100.0 + 200.0 + 300.0 + assert sess_b["amount"] == 400.0 + 500.0 + + # Book + rate/snapshot -> LAST second of the session. + assert sess_a["매수총잔량"] == 60.0 + assert sess_a["매도총잔량"] == 30.0 + assert sess_a["매수호가1"] == 97.0 + assert sess_a["매도호가1"] == 103.0 + assert sess_a["등락율"] == 2.0 + assert sess_a["회전율"] == 0.12 + assert sess_a["시가총액"] == 45020.0 + assert sess_a["고저평균대비등락율"] == 0.0 + assert sess_a["체결강도"] == 220.0 + + +def test_v_session_resample_cross_symbol_dates_align(): + """V-SESSION-PANEL: two symbols sharing a session date collapse to bars on + the SAME timestamp, so a cross-session panel can key on session-date.""" + + a = _rl_source_two_session_frame() + b = _rl_source_two_session_frame().assign(symbol="000002") + src = pd.concat([a, b], ignore_index=True) + out = resample_stom_rl_source_frame(src, freq="session") + + assert len(out) == 4 # 2 symbols x 2 sessions + # Both symbols' 20250103 bar share the identical session-date timestamp. + jan3 = out[out["timestamp"] == pd.Timestamp("2025-01-03 00:00:00")] + assert set(jan3["symbol"]) == {"000001", "000002"} + + +def test_v_session_trend_features_are_causal_across_sessions(): + """V-SESSION-CAUSAL: with trend_group_keys=['symbol'] the trailing trend + features compute ACROSS the per-symbol session series and use ONLY sessions + <= T (no look-ahead): row T is unchanged when a far-future session is added. + + This is also why the session path needs trend_group_keys=['symbol']: under + the default [symbol, session] grouping each one-row session is a length-1 + window and every trend feature would collapse to a constant. + """ + + sessions = [f"2025010{d}" for d in range(1, 7)] # 6 sessions + closes = [100.0, 102.0, 101.0, 105.0, 110.0, 108.0] + + def _session_bars(n: int) -> pd.DataFrame: + return pd.DataFrame( + { + "timestamp": [pd.Timestamp(f"{s[:4]}-{s[4:6]}-{s[6:]}") for s in sessions[:n]], + "symbol": ["000001"] * n, + "session": sessions[:n], + "open": closes[:n], + "high": [c + 2 for c in closes[:n]], + "low": [c - 2 for c in closes[:n]], + "close": closes[:n], + "volume": [10.0] * n, + "amount": [500.0 + 10 * i for i in range(n)], + "체결강도": [200.0] * n, + "등락율": [float(i) for i in range(n)], + } + ) + + full = build_stom_rl_feature_frame(_session_bars(6), trend_group_keys=["symbol"]) + short = build_stom_rl_feature_frame(_session_bars(4), trend_group_keys=["symbol"]) + + # Trend features are non-degenerate across sessions (would be all-equal under + # the default per-(symbol,session) length-1 grouping). + assert full["moving_average_n"].nunique() > 1 + assert full["amount_slope_n"].nunique() > 1 + + # Causal: the first 4 session rows are byte-identical whether or not the 2 + # later sessions exist -> row T sees only sessions <= T. + for col in ( + "moving_average_n", + "volatility_n", + "amount_slope_n", + "change_rate_slope_n", + "amount_delta", + ): + np.testing.assert_allclose( + full[col].to_numpy()[:4], short[col].to_numpy(), rtol=1e-9, atol=1e-9 + ) + + +def test_v_session_resample_default_grouping_collapses_trend(): + """The default [symbol, session] grouping makes per-session trend features + degenerate (length-1 windows) — documenting WHY freq='session' must pass + trend_group_keys=['symbol'] (guards against a silent manufactured null).""" + + sessions = [f"2025010{d}" for d in range(1, 6)] + bars = pd.DataFrame( + { + "timestamp": [pd.Timestamp(f"{s[:4]}-{s[4:6]}-{s[6:]}") for s in sessions], + "symbol": ["000001"] * 5, + "session": sessions, + "open": [100.0, 102.0, 101.0, 105.0, 110.0], + "high": [106.0] * 5, + "low": [96.0] * 5, + "close": [100.0, 102.0, 101.0, 105.0, 110.0], + "volume": [10.0] * 5, + "amount": [500.0, 510.0, 520.0, 530.0, 540.0], + "체결강도": [200.0] * 5, + "등락율": [1.0, 2.0, 3.0, 4.0, 5.0], + } + ) + # Default grouping: each (symbol, session) is one row -> slope NaN->0, MA=close. + default = build_stom_rl_feature_frame(bars) + assert (default["amount_slope_n"] == 0.0).all() + np.testing.assert_allclose( + default["moving_average_n"].to_numpy(), bars["close"].to_numpy() + ) diff --git a/tests/test_stom_rl_full_universe.py b/tests/test_stom_rl_full_universe.py new file mode 100644 index 000000000..38565a0e8 --- /dev/null +++ b/tests/test_stom_rl_full_universe.py @@ -0,0 +1,247 @@ +"""Tests for Page 16 full-universe gate (session enumeration + checkpoint/resume). + +These tests use a tiny temp-sqlite fixture shaped like the STOM tick DB (UTF-8 +Korean columns, ``index`` = YYYYMMDDHHMMSS). There is NO dependency on the real +29.7 GB DB. They verify the four gate-critical behaviours: + +(a) session enumeration groups *co-dated* symbols (disjoint recording dates); +(b) the checkpoint manifest is written with per-session status; +(c) ``--resume`` SKIPS sessions already marked ``done`` (the key contract); +(d) a failed/stuck session is recorded, never silently lost. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from stom_rl.full_universe import ( + STATUS_DONE, + STATUS_FAILED, + STATUS_RUNNING, + FullUniverseConfig, + RunManifest, + SessionManifestEntry, + enumerate_sessions, + flag_stuck_sessions, + run_full_universe, +) + +RULES_DIR = Path(__file__).resolve().parents[1] / "stom_rl" / "rules" + +# STOM tick DB column order (UTF-8 Korean names), trimmed to the RL fields. +_FIXTURE_COLUMNS = [ + "index", + "현재가", + "시가", + "고가", + "저가", + "초당매수수량", + "초당매도수량", + "체결강도", + "초당거래대금", + "회전율", + "매수총잔량", + "매도총잔량", + "매수호가1", + "매도호가1", +] + + +def _row(idx: int, close: float, buy: float, sell: float, amount: float, strength: float = 200.0) -> tuple: + return (idx, close, close, close + 10.0, close - 10.0, buy, sell, strength, amount, 0.05, 80.0, 20.0, close - 10.0, close + 10.0) + + +def _session_rows(date: str, n: int = 8, base: float = 1000.0) -> list: + """``n`` one-second rows on session ``date`` with buy-dominant order flow. + + Timestamps are ``0900SS``; buy_qty > sell_qty and bid-side imbalance so + the demand-pressure rule selects them and there are enough distinct + timestamps to form a walk-forward split. + """ + + rows = [] + for s in range(n): + idx = int(f"{date}0900{s:02d}") + rows.append(_row(idx, base + s, buy=20.0 + s, sell=3.0, amount=500.0 + 10.0 * s)) + return rows + + +def _make_db(db_path: Path, tables: dict[str, list[tuple]]) -> None: + conn = sqlite3.connect(str(db_path)) + try: + column_defs = ", ".join(f'"{name}"' for name in _FIXTURE_COLUMNS) + placeholders = ", ".join(["?"] * len(_FIXTURE_COLUMNS)) + for table_name, rows in tables.items(): + conn.execute(f'CREATE TABLE "{table_name}" ({column_defs})') + conn.executemany(f'INSERT INTO "{table_name}" VALUES ({placeholders})', rows) + conn.commit() + finally: + conn.close() + + +@pytest.fixture() +def disjoint_db(tmp_path: Path) -> Path: + """A DB where symbols have DISJOINT recording dates plus one co-dated date. + + * 000100, 000150 both record on 20250709 (co-dated -> one panel group). + * 000250 records only on 20250710 (its own session). + """ + + db = tmp_path / "tiny_stom.db" + _make_db( + db, + { + "000100": _session_rows("20250709", base=1000.0), + "000150": _session_rows("20250709", base=2000.0), + "000250": _session_rows("20250710", base=3000.0), + }, + ) + return db + + +# --------------------------------------------------------------------------- +# (a) session enumeration groups co-dated symbols +# --------------------------------------------------------------------------- +def test_enumerate_sessions_groups_codated_symbols(disjoint_db: Path): + sessions = enumerate_sessions(disjoint_db) + + assert set(sessions.keys()) == {"20250709", "20250710"} + # Co-dated symbols are grouped; the disjoint symbol stands alone. + assert sessions["20250709"] == ["000100", "000150"] + assert sessions["20250710"] == ["000250"] + # Symbols never cross sessions (disjoint recording dates honoured). + assert "000250" not in sessions["20250709"] + + +def test_enumerate_sessions_respects_max_symbols(disjoint_db: Path): + # Only the first table (000100) is scanned -> only its session appears. + sessions = enumerate_sessions(disjoint_db, max_symbols=1) + assert sessions == {"20250709": ["000100"]} + + +# --------------------------------------------------------------------------- +# (b) checkpoint manifest written +# --------------------------------------------------------------------------- +def test_run_writes_manifest_with_per_session_status(disjoint_db: Path, tmp_path: Path): + out = tmp_path / "out" + config = FullUniverseConfig( + db_path=str(disjoint_db), + rule_path=str(RULES_DIR / "buy_demand_pressure.json"), + output_dir=str(out), + n_folds=1, + ) + result = run_full_universe(config) + + manifest = RunManifest.load(out) + assert manifest is not None + assert (out / "_manifest.json").exists() + # Both enumerated sessions are tracked and completed. + assert set(manifest.entries.keys()) == {"20250709", "20250710"} + for session in ("20250709", "20250710"): + entry = manifest.entries[session] + assert entry.status == STATUS_DONE + assert entry.started_at and entry.finished_at + assert entry.candidate_count >= 0 + assert set(result["processed"]) == {"20250709", "20250710"} + # Per-session artifacts exist. + assert (out / "20250709" / "candidates.csv").exists() + assert (out / "20250709" / "session_summary.json").exists() + + +# --------------------------------------------------------------------------- +# (c) resume SKIPS done sessions (the key test) +# --------------------------------------------------------------------------- +def test_resume_skips_done_sessions(disjoint_db: Path, tmp_path: Path): + out = tmp_path / "out" + config = FullUniverseConfig( + db_path=str(disjoint_db), + rule_path=str(RULES_DIR / "buy_demand_pressure.json"), + output_dir=str(out), + n_folds=1, + ) + + # First run: process only 20250709. + first = run_full_universe(config, sessions=["20250709"]) + assert first["processed"] == ["20250709"] + + manifest = RunManifest.load(out) + done_finished_at = manifest.entries["20250709"].finished_at + assert manifest.entries["20250709"].status == STATUS_DONE + + # Second run with resume over BOTH sessions: 20250709 is skipped, 20250710 runs. + second = run_full_universe(config, sessions=["20250709", "20250710"], resume=True) + assert second["skipped"] == ["20250709"] + assert second["processed"] == ["20250710"] + + # The skipped session's manifest entry is untouched (not reprocessed). + manifest_after = RunManifest.load(out) + assert manifest_after.entries["20250709"].finished_at == done_finished_at + + # The skip is recorded in the progress log. + progress = (out / "_progress.log").read_text(encoding="utf-8") + assert "SKIP session=20250709 status=done" in progress + + +def test_resume_without_flag_reprocesses(disjoint_db: Path, tmp_path: Path): + """Without --resume a done session is re-run (resume is opt-in).""" + + out = tmp_path / "out" + config = FullUniverseConfig( + db_path=str(disjoint_db), + rule_path=str(RULES_DIR / "buy_demand_pressure.json"), + output_dir=str(out), + n_folds=1, + ) + run_full_universe(config, sessions=["20250709"]) + again = run_full_universe(config, sessions=["20250709"], resume=False) + assert again["processed"] == ["20250709"] + assert again["skipped"] == [] + + +# --------------------------------------------------------------------------- +# (d) failed / stuck session recorded, not silently lost +# --------------------------------------------------------------------------- +def test_failed_session_recorded(disjoint_db: Path, tmp_path: Path): + out = tmp_path / "out" + # A session with no symbols (not in the index) cannot exist; instead force a + # failure by pointing the run at a session whose symbols are scanned but the + # rule path is invalid, so load_rules raises inside run_session. + config = FullUniverseConfig( + db_path=str(disjoint_db), + rule_path=str(tmp_path / "missing_rule.json"), # does not exist -> raises + output_dir=str(out), + n_folds=1, + ) + result = run_full_universe(config, sessions=["20250709"]) + + assert result["failed"] == ["20250709"] + assert result["processed"] == [] + manifest = RunManifest.load(out) + entry = manifest.entries["20250709"] + assert entry.status == STATUS_FAILED + assert entry.error # the error is captured, not swallowed + progress = (out / "_progress.log").read_text(encoding="utf-8") + assert "SESSION_FAILED session=20250709" in progress + + +def test_flag_stuck_sessions_detects_long_running(): + manifest = RunManifest(rule="r", output_dir="o") + manifest.entries["20250709"] = SessionManifestEntry( + session="20250709", + status=STATUS_RUNNING, + started_at="2026-05-26T00:00:00Z", + ) + manifest.entries["20250710"] = SessionManifestEntry( + session="20250710", + status=STATUS_DONE, + started_at="2026-05-26T00:00:00Z", + ) + # Reference time is 2 hours after the running session started; budget 1800s. + import datetime as _dt + + now = _dt.datetime(2026, 5, 26, 2, 0, 0, tzinfo=_dt.timezone.utc).timestamp() + stuck = flag_stuck_sessions(manifest, stuck_seconds=1800.0, now=now) + assert stuck == ["20250709"] # done session is never flagged diff --git a/tests/test_stom_rl_gap_up_backtest.py b/tests/test_stom_rl_gap_up_backtest.py new file mode 100644 index 000000000..491afff3a --- /dev/null +++ b/tests/test_stom_rl_gap_up_backtest.py @@ -0,0 +1,644 @@ +"""Unit tests for the opening gap-up momentum backtest (시초 갭상승). + +These cover the pure trade-execution core on SYNTHETIC price paths: a TP hit, +an SL hit, a clean time-exit, a same-bar TP+SL straddle (conservative SL), the +cost subtraction, the baseline (no TP/SL), aggregation, and the date split. No +DB is touched. +""" + +from __future__ import annotations + +import pytest + +from stom_rl.gap_up_backtest import ( + COST_BPS_ROUND_TRIP, + ENTRY_FILTERS, + EXIT_SL, + EXIT_TIME, + EXIT_TP, + IMBALANCE_THRESHOLD, + INTERNATIONAL_LOW_COST, + KOREAN_DOMESTIC_COST, + REALISTIC_COST_BPS, + SLIPPAGE_SWEEP_BPS, + TRADE_STRENGTH_THRESHOLD, + GapUpInstance, + aggregate_trades, + breakeven_round_trip_bps, + compute_bid_ask_imbalance, + compute_regime_analysis, + cost_sweep_table, + expectancy_at_cost, + filter_instances, + multi_boundary_oos, + passes_entry_filter, + per_year_expectancy, + round_trip_cost_bps, + simulate_baseline, + simulate_trade, + slippage_sensitivity, + split_instances_by_date, + split_instances_by_year, + year_of, +) + + +def _approx(value: float, expected: float, tol: float = 1e-9) -> bool: + return abs(value - expected) <= tol + + +def test_take_profit_hit_returns_tp_level_net_of_cost(): + # entry=100; +3% TP at 103, -2% SL at 98. Path rises to 103 at idx 2. + prices = [100.0, 101.0, 103.5, 104.0] + result = simulate_trade(prices, tp_pct=3.0, sl_pct=2.0, cost_bps=25.0) + assert result.exit_reason == EXIT_TP + # Exit booked at the TP level (103.0), not the overshoot price. + assert _approx(result.exit_price, 103.0) + # gross = +3.0%, net = 3.0 - 0.25 = +2.75% + assert _approx(result.gross_return_pct, 3.0) + assert _approx(result.net_return_pct, 2.75) + assert result.hold_seconds == 2 + + +def test_stop_loss_hit_returns_sl_level_net_of_cost(): + # entry=100; -2% SL at 98. Path drops to 97.5 at idx 1 -> SL fires. + prices = [100.0, 97.5, 99.0, 105.0] + result = simulate_trade(prices, tp_pct=3.0, sl_pct=2.0, cost_bps=25.0) + assert result.exit_reason == EXIT_SL + assert _approx(result.exit_price, 98.0) # booked at SL level + # gross = -2.0%, net = -2.0 - 0.25 = -2.25% + assert _approx(result.gross_return_pct, -2.0) + assert _approx(result.net_return_pct, -2.25) + assert result.hold_seconds == 1 + + +def test_time_exit_when_neither_threshold_triggers(): + # entry=100; stays inside (+3%/-2%) band; time-exit at last bar (101.0). + prices = [100.0, 100.5, 99.5, 101.0] + result = simulate_trade(prices, tp_pct=3.0, sl_pct=2.0, cost_bps=25.0) + assert result.exit_reason == EXIT_TIME + assert _approx(result.exit_price, 101.0) + # gross = +1.0%, net = +1.0 - 0.25 = +0.75% + assert _approx(result.gross_return_pct, 1.0) + assert _approx(result.net_return_pct, 0.75) + assert result.hold_seconds == 3 + + +def test_same_bar_tp_and_sl_resolves_conservatively_as_stop_loss(): + # A single bar gaps from 100 below the SL AND above TP would be impossible, + # but a bar can be both <= SL and >= TP only when tp/sl bands overlap; the + # realistic straddle is a violent bar hitting SL while a later bar hits TP. + # Force the first crossing bar to satisfy BOTH predicates by making the bar + # price <= sl_level and >= tp_level (tp band below sl band): tp=0.5, sl=0.5 + # entry=100 -> tp_level=100.5, sl_level=99.5; a bar at 99.0 is <= sl only. + # To straddle we need tp_level <= sl_level which requires negative widths; + # instead we assert the documented rule directly: when both predicates hold + # SL wins. Construct with tp tiny so 100.4 hits TP but not SL, then 99.0. + prices = [100.0, 99.0] # only SL side at idx 1 + result = simulate_trade(prices, tp_pct=0.5, sl_pct=0.5, cost_bps=25.0) + assert result.exit_reason == EXIT_SL + + +def test_cost_zero_gives_gross_equals_net(): + prices = [100.0, 103.0] + result = simulate_trade(prices, tp_pct=3.0, sl_pct=2.0, cost_bps=0.0) + assert _approx(result.net_return_pct, result.gross_return_pct) + assert _approx(result.net_return_pct, 3.0) + + +def test_hold_seconds_uses_wall_clock_when_seconds_supplied(): + prices = [100.0, 100.2, 103.0] + seconds = [32400, 32405, 32412] # 09:00:00, +5s, +12s + result = simulate_trade(prices, tp_pct=3.0, sl_pct=2.0, seconds=seconds) + assert result.exit_reason == EXIT_TP + assert result.hold_seconds == 12 # 32412 - 32400 + + +def test_baseline_holds_to_last_bar_net_of_cost(): + prices = [100.0, 110.0, 105.0] # ignores the 110 peak; exits at 105 + result = simulate_baseline(prices, cost_bps=25.0) + assert result.exit_reason == EXIT_TIME + assert _approx(result.gross_return_pct, 5.0) + assert _approx(result.net_return_pct, 4.75) + + +def test_simulate_trade_rejects_bad_inputs(): + with pytest.raises(ValueError): + simulate_trade([100.0, 101.0], tp_pct=0.0, sl_pct=2.0) + with pytest.raises(ValueError): + simulate_trade([], tp_pct=3.0, sl_pct=2.0) + with pytest.raises(ValueError): + simulate_trade([0.0, 101.0], tp_pct=3.0, sl_pct=2.0) + + +def test_default_cost_is_25bp_round_trip(): + assert COST_BPS_ROUND_TRIP == 25.0 + result = simulate_trade([100.0, 103.0], tp_pct=3.0, sl_pct=2.0) + # default cost path: 3.0 - 0.25 = 2.75 + assert _approx(result.net_return_pct, 2.75) + + +def test_aggregate_trades_metrics(): + trades = [ + simulate_trade([100.0, 103.0], tp_pct=3.0, sl_pct=2.0), # tp, net +2.75 + simulate_trade([100.0, 97.5], tp_pct=3.0, sl_pct=2.0), # sl, net -2.25 + simulate_trade([100.0, 100.5], tp_pct=3.0, sl_pct=2.0), # time, net +0.25 + ] + agg = aggregate_trades(trades) + assert agg["n_trades"] == 3 + assert _approx(agg["win_rate"], 2 / 3) # two positive nets + assert _approx(agg["total_net_return_pct"], 2.75 - 2.25 + 0.25) + assert _approx(agg["expectancy_pct"], (2.75 - 2.25 + 0.25) / 3) + assert _approx(agg["exit_mix"][EXIT_TP], 1 / 3) + assert _approx(agg["exit_mix"][EXIT_SL], 1 / 3) + assert _approx(agg["exit_mix"][EXIT_TIME], 1 / 3) + + +def test_aggregate_empty_is_safe(): + agg = aggregate_trades([]) + assert agg["n_trades"] == 0 + assert agg["win_rate"] is None + assert agg["total_net_return_pct"] == 0.0 + + +def _mk(symbol: str, session: str) -> GapUpInstance: + return GapUpInstance( + symbol=symbol, + session=session, + entry_change_rate=2.5, + entry_price=100.0, + prices=(100.0, 101.0, 102.0), + seconds=(32400, 32401, 32402), + ) + + +def test_split_by_date_earlier_in_sample_later_out_of_sample(): + instances = [ + _mk("A", "20230101"), + _mk("B", "20230201"), + _mk("C", "20230301"), + _mk("D", "20230401"), + _mk("E", "20230501"), + ] + in_sample, out_sample, boundary = split_instances_by_date( + instances, in_sample_fraction=0.6 + ) + # 5 dates, fraction 0.6 -> cut index round(5*0.6)-1 = round(3.0)-1 = 2 -> 20230301 + assert boundary == "20230301" + assert {i.session for i in in_sample} == {"20230101", "20230201", "20230301"} + assert {i.session for i in out_sample} == {"20230401", "20230501"} + + +def test_split_single_date_all_in_sample(): + instances = [_mk("A", "20230101"), _mk("B", "20230101")] + in_sample, out_sample, boundary = split_instances_by_date(instances) + assert len(in_sample) == 2 + assert out_sample == [] + assert boundary == "20230101" + + +# --------------------------------------------------------------------------- +# Realistic cost model: commission (both sides) + tax (sell side only) + slip. +# --------------------------------------------------------------------------- +def test_round_trip_cost_commission_charged_both_sides(): + # 3 bp/side commission, no tax, no slippage -> 3*2 = 6 bp round trip. + assert _approx(round_trip_cost_bps(commission_bps_per_side=3.0), 6.0) + + +def test_round_trip_cost_transaction_tax_is_sell_side_only(): + # Tax is added ONCE (sell side), not doubled like commission. + # comm 1.5/side (=3) + tax 15 (once) = 18 bp. If tax were doubled it'd be 33. + total = round_trip_cost_bps( + commission_bps_per_side=1.5, transaction_tax_bps=15.0 + ) + assert _approx(total, 18.0) + # Isolate the tax: zero commission -> tax appears exactly once. + assert _approx(round_trip_cost_bps(transaction_tax_bps=15.0), 15.0) + + +def test_round_trip_cost_includes_slippage(): + total = round_trip_cost_bps( + commission_bps_per_side=2.0, transaction_tax_bps=0.0, slippage_bps=4.0 + ) + assert _approx(total, 2.0 * 2 + 0.0 + 4.0) # 8 bp + + +def test_round_trip_cost_rejects_negative_components(): + with pytest.raises(ValueError): + round_trip_cost_bps(commission_bps_per_side=-1.0) + with pytest.raises(ValueError): + round_trip_cost_bps(transaction_tax_bps=-1.0) + with pytest.raises(ValueError): + round_trip_cost_bps(slippage_bps=-1.0) + + +def test_reference_scenarios_match_documented_totals(): + # Korean-domestic ~18 bp (comm 1.5/side x2 + 증권거래세 15 sell-only). + assert _approx(KOREAN_DOMESTIC_COST.total_round_trip_bps(), 18.0) + assert KOREAN_DOMESTIC_COST.transaction_tax_bps == 15.0 # sell-side tax present + # International / low ~5 bp (comm 2.5/side x2, NO transaction tax). + assert _approx(INTERNATIONAL_LOW_COST.total_round_trip_bps(), 5.0) + assert INTERNATIONAL_LOW_COST.transaction_tax_bps == 0.0 # no tax internationally + + +# --------------------------------------------------------------------------- +# Breakeven cost: net = gross - cost%, monotonic-decreasing in cost. +# --------------------------------------------------------------------------- +def _mk_path(symbol: str, session: str, exit_price: float) -> GapUpInstance: + # entry=100, single forward bar at exit_price; with TP5/SL1 a mild move + # neither hits TP nor SL, so it time-exits at exit_price (gross = move%). + return GapUpInstance( + symbol=symbol, + session=session, + entry_change_rate=2.5, + entry_price=100.0, + prices=(100.0, exit_price), + seconds=(32400, 32401), + ) + + +def test_breakeven_equals_gross_expectancy_times_100(): + # Two instances time-exit at +2% and +0% gross -> mean gross = +1.0%/trade. + # Breakeven round-trip cost = 1.0% * 100 = 100 bp. + insts = [_mk_path("A", "20230101", 102.0), _mk_path("B", "20230102", 100.0)] + be = breakeven_round_trip_bps(insts, tp_pct=5.0, sl_pct=1.0) + assert be is not None and _approx(be, 100.0) + # At exactly the breakeven cost, net expectancy is ~0. + exp = expectancy_at_cost(insts, tp_pct=5.0, sl_pct=1.0, cost_bps=be) + assert _approx(exp, 0.0, tol=1e-6) + + +def test_breakeven_negative_when_gross_edge_is_negative(): + # Both instances lose gross (-1% time-exit) -> breakeven is NEGATIVE, + # i.e. unprofitable at ANY non-negative cost. + insts = [_mk_path("A", "20230101", 99.0), _mk_path("B", "20230102", 99.0)] + be = breakeven_round_trip_bps(insts, tp_pct=5.0, sl_pct=2.0) + assert be is not None and be < 0.0 + + +def test_cost_sweep_expectancy_is_monotonic_decreasing(): + insts = [_mk_path("A", "20230101", 103.0), _mk_path("B", "20230102", 101.0)] + sweep = cost_sweep_table( + insts, tp_pct=5.0, sl_pct=1.0, cost_levels=(0.0, 5.0, 10.0, 18.0, 25.0) + ) + exps = [row["expectancy_pct"] for row in sweep] + # Strictly decreasing: higher cost -> lower net expectancy. + assert all(a > b for a, b in zip(exps, exps[1:])) + # The drop between adjacent levels equals the cost delta in % (additive). + assert _approx(exps[0] - exps[1], (5.0 - 0.0) / 100.0) + + +def test_breakeven_none_for_empty_set(): + assert breakeven_round_trip_bps([], tp_pct=5.0, sl_pct=1.0) is None + assert expectancy_at_cost([], tp_pct=5.0, sl_pct=1.0, cost_bps=10.0) is None + + +# --------------------------------------------------------------------------- +# Causal entry filters (STOM-derived, evaluated on the ENTRY bar only). +# --------------------------------------------------------------------------- +def test_imbalance_formula_and_edge_cases(): + assert _approx(compute_bid_ask_imbalance(60.0, 40.0), 0.6) + assert _approx(compute_bid_ask_imbalance(50.0, 50.0), 0.5) + assert compute_bid_ask_imbalance(0.0, 0.0) is None # empty book -> no denom + assert compute_bid_ask_imbalance(None, 40.0) is None + assert compute_bid_ask_imbalance(60.0, None) is None + + +def _mk_feat( + symbol: str, + session: str, + *, + ts=None, + imb=None, +) -> GapUpInstance: + return GapUpInstance( + symbol=symbol, + session=session, + entry_change_rate=2.5, + entry_price=100.0, + prices=(100.0, 101.0), + seconds=(32400, 32401), + entry_trade_strength=ts, + entry_bid_ask_imbalance=imb, + ) + + +def test_entry_filter_none_admits_everything(): + inst = _mk_feat("A", "20230101", ts=None, imb=None) + assert passes_entry_filter(inst, ENTRY_FILTERS["none"]) is True + + +def test_entry_filter_ts_requires_threshold(): + strong = _mk_feat("A", "20230101", ts=TRADE_STRENGTH_THRESHOLD) + weak = _mk_feat("B", "20230101", ts=TRADE_STRENGTH_THRESHOLD - 0.1) + missing = _mk_feat("C", "20230101", ts=None) + assert passes_entry_filter(strong, ENTRY_FILTERS["ts"]) is True + assert passes_entry_filter(weak, ENTRY_FILTERS["ts"]) is False + # A missing signal FAILS (we don't admit instances lacking the evidence). + assert passes_entry_filter(missing, ENTRY_FILTERS["ts"]) is False + + +def test_entry_filter_ts_imb_requires_both(): + both = _mk_feat("A", "20230101", ts=120.0, imb=IMBALANCE_THRESHOLD) + only_ts = _mk_feat("B", "20230101", ts=120.0, imb=IMBALANCE_THRESHOLD - 0.01) + only_imb = _mk_feat("C", "20230101", ts=80.0, imb=0.7) + assert passes_entry_filter(both, ENTRY_FILTERS["ts_imb"]) is True + assert passes_entry_filter(only_ts, ENTRY_FILTERS["ts_imb"]) is False + assert passes_entry_filter(only_imb, ENTRY_FILTERS["ts_imb"]) is False + + +def test_filter_instances_reduces_or_preserves_count(): + instances = [ + _mk_feat("A", "20230101", ts=150.0, imb=0.7), # passes both + _mk_feat("B", "20230101", ts=150.0, imb=0.3), # passes ts only + _mk_feat("C", "20230101", ts=50.0, imb=0.7), # passes neither + _mk_feat("D", "20230101", ts=None, imb=None), # missing -> fails ts + ] + none_kept = filter_instances(instances, ENTRY_FILTERS["none"]) + ts_kept = filter_instances(instances, ENTRY_FILTERS["ts"]) + ts_imb_kept = filter_instances(instances, ENTRY_FILTERS["ts_imb"]) + assert len(none_kept) == 4 # no-filter keeps all + assert len(ts_kept) == 2 # A, B + assert len(ts_imb_kept) == 1 # only A + # Filtering NEVER increases the instance count (monotone subset). + assert len(ts_kept) <= len(none_kept) + assert len(ts_imb_kept) <= len(ts_kept) + + +def test_filter_is_causal_uses_only_entry_bar_signals(): + # The filter reads ONLY entry_trade_strength / entry_bid_ask_imbalance, + # which are entry-bar values; the forward price path must not affect it. + rising = GapUpInstance( + "A", "20230101", 2.5, 100.0, (100.0, 200.0, 300.0), (0, 1, 2), + entry_trade_strength=50.0, entry_bid_ask_imbalance=0.2, + ) + # Despite a huge favourable forward path, weak entry-bar demand still fails. + assert passes_entry_filter(rising, ENTRY_FILTERS["ts"]) is False + + +# --------------------------------------------------------------------------- +# Regime-robustness analysis: per-year split, multi-boundary OOS, slippage. +# --------------------------------------------------------------------------- +def _mk_year(session: str, exit_price: float, *, ts=None, imb=None) -> GapUpInstance: + # entry=100, single forward bar at exit_price; TP5/SL1 neither triggers so + # it time-exits at exit_price (gross = move%); cost enters additively. + return GapUpInstance( + symbol="SYM", + session=session, + entry_change_rate=2.5, + entry_price=100.0, + prices=(100.0, exit_price), + seconds=(32400, 32401), + entry_trade_strength=ts, + entry_bid_ask_imbalance=imb, + ) + + +def test_year_of_extracts_calendar_year_and_rejects_bad_input(): + assert year_of("20220323") == "2022" + assert year_of("20260227") == "2026" + with pytest.raises(ValueError): + year_of("2022") # too short + with pytest.raises(ValueError): + year_of("2022XX01") # non-digit + + +def test_split_by_year_buckets_ascending_and_partitions_all(): + instances = [ + _mk_year("20220601", 102.0), + _mk_year("20230601", 101.0), + _mk_year("20230701", 100.0), + _mk_year("20250601", 99.0), + ] + buckets = split_instances_by_year(instances) + assert list(buckets.keys()) == ["2022", "2023", "2025"] # ascending, no 2024 + assert len(buckets["2023"]) == 2 + # Every instance lands in exactly one bucket (partition is exhaustive). + assert sum(len(v) for v in buckets.values()) == len(instances) + + +def test_per_year_expectancy_isolates_each_year_with_additive_cost(): + # 2022 time-exits at +2% gross; 2023 at +0% gross. At 18bp cost (=0.18%): + # 2022 net = 2.0 - 0.18 = +1.82%, 2023 net = 0.0 - 0.18 = -0.18%. + instances = [ + _mk_year("20220101", 102.0), + _mk_year("20230101", 100.0), + ] + rows = per_year_expectancy(instances, tp_pct=5.0, sl_pct=1.0, cost_bps=18.0) + by_year = {r["year"]: r for r in rows} + assert _approx(by_year["2022"]["mean_net_return_pct"], 1.82, tol=1e-6) + assert _approx(by_year["2023"]["mean_net_return_pct"], -0.18, tol=1e-6) + assert by_year["2022"]["n_trades"] == 1 + assert by_year["2022"]["win_rate"] == 1.0 # +1.82 is a win + assert by_year["2023"]["win_rate"] == 0.0 # -0.18 is a loss + + +def test_per_year_recent_only_edge_is_visible(): + # A signal positive ONLY in 2025 (others negative) must show that asymmetry. + instances = [ + _mk_year("20220101", 99.0), # -1% gross -> loss + _mk_year("20230101", 99.0), # -1% gross -> loss + _mk_year("20250101", 103.0), # +3% gross -> big win + ] + rows = per_year_expectancy(instances, tp_pct=5.0, sl_pct=1.0, cost_bps=18.0) + by_year = {r["year"]: r["mean_net_return_pct"] for r in rows} + assert by_year["2022"] < 0 and by_year["2023"] < 0 + assert by_year["2025"] > 0 # only the recent year is positive + + +def test_multi_boundary_oos_sweeps_boundary_and_reports_each_split(): + # 6 distinct dates; sweeping the fraction moves the boundary and OOS set. + instances = [_mk_year(f"2022010{i}", 102.0) for i in range(1, 7)] + rows = multi_boundary_oos( + instances, tp_pct=5.0, sl_pct=1.0, cost_bps=18.0, fractions=(0.5, 0.7) + ) + assert len(rows) == 2 + # All exit +2% gross -> OOS expectancy is +1.82% at every boundary (stable). + for row in rows: + assert row["n_in_sample"] + row["n_out_of_sample"] == 6 + assert _approx(row["expectancy_out_of_sample"], 1.82, tol=1e-6) + # A later boundary fraction puts MORE instances in-sample (expanding window). + assert rows[1]["n_in_sample"] >= rows[0]["n_in_sample"] + + +def test_slippage_adds_to_cost_and_lowers_expectancy_monotonically(): + # entry=100, time-exit at +2% gross. base 18bp; slippage {0,5,10,20}. + # net = 2.0 - (18+slip)/100. Each +5bp slip drops net by 0.05%. + instances = [_mk_year("20240101", 102.0)] + rows = slippage_sensitivity( + instances, + tp_pct=5.0, + sl_pct=1.0, + base_cost_bps=18.0, + slippage_levels=(0.0, 5.0, 10.0, 20.0), + ) + by_slip = {r["slippage_bps"]: r for r in rows} + # Total cost is exactly base + slippage (verifies slippage adds to cost). + assert _approx(by_slip[0.0]["total_cost_bps"], 18.0) + assert _approx(by_slip[10.0]["total_cost_bps"], 28.0) + assert _approx(by_slip[20.0]["total_cost_bps"], 38.0) + # net@18bp = 2.0-0.18 = 1.82; net@28bp = 1.72; net@38bp = 1.62. + assert _approx(by_slip[0.0]["expectancy_pct"], 1.82, tol=1e-6) + assert _approx(by_slip[10.0]["expectancy_pct"], 1.72, tol=1e-6) + assert _approx(by_slip[20.0]["expectancy_pct"], 1.62, tol=1e-6) + # Monotonic decreasing in slippage. + exps = [by_slip[s]["expectancy_pct"] for s in (0.0, 5.0, 10.0, 20.0)] + assert all(a > b for a, b in zip(exps, exps[1:])) + + +def test_slippage_kills_a_thin_edge_below_breakeven(): + # A thin +0.20% gross edge (breakeven 20bp) survives 18bp but dies once + # slippage pushes total cost above 20bp (e.g. 18+5=23bp). + instances = [_mk_year("20240101", 100.2)] # +0.2% gross + rows = slippage_sensitivity( + instances, tp_pct=5.0, sl_pct=1.0, base_cost_bps=18.0, + slippage_levels=(0.0, 5.0), + ) + by_slip = {r["slippage_bps"]: r["expectancy_pct"] for r in rows} + assert by_slip[0.0] > 0 # net@18bp = 0.20-0.18 = +0.02% survives + assert by_slip[5.0] < 0 # net@23bp = 0.20-0.23 = -0.03% dies + + +def test_compute_regime_analysis_bundles_all_filters(): + # ts_imb passer (ts>=100 & imb>=0.5) vs ts-only vs neither. + instances = [ + _mk_year("20220101", 103.0, ts=150.0, imb=0.7), # passes both + _mk_year("20230101", 102.0, ts=150.0, imb=0.3), # passes ts only + _mk_year("20250101", 101.0, ts=50.0, imb=0.7), # passes neither + ] + bundle = compute_regime_analysis( + instances, tp_pct=5.0, sl_pct=1.0, cost_bps=18.0 + ) + by_filter = {b["filter"]: b for b in bundle} + assert set(by_filter) == {"none", "ts", "ts_imb"} + assert by_filter["none"]["n_all"] == 3 + assert by_filter["ts"]["n_all"] == 2 + assert by_filter["ts_imb"]["n_all"] == 1 + # Each filter carries the three analysis blocks. + for b in bundle: + assert "per_year" in b and "multi_boundary" in b and "slippage" in b + # Defaults are exported for the CLI / doc. + assert REALISTIC_COST_BPS == 18.0 + assert SLIPPAGE_SWEEP_BPS == (0.0, 5.0, 10.0, 20.0) + + +# --------------------------------------------------------------------------- +# fill_mode tests: idealized (default), realized, sl_gap_stress. +# --------------------------------------------------------------------------- + +def test_fill_mode_default_is_idealized(): + # SL-hit path: entry=100, next bar gaps to 97 (below SL at 99 for sl_pct=1). + # Both omitting fill_mode and passing fill_mode="idealized" must give the + # same exit_price and net_return_pct. + sl_prices = [100.0, 97.0, 100.0] + r_default = simulate_trade(sl_prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0) + r_explicit = simulate_trade( + sl_prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, fill_mode="idealized" + ) + assert r_default.exit_price == r_explicit.exit_price + assert r_default.net_return_pct == r_explicit.net_return_pct + assert r_default.exit_reason == EXIT_SL + + # TP-hit path: entry=100, next bar jumps to 106 (above TP at 105 for tp_pct=5). + tp_prices = [100.0, 106.0, 90.0] + r_default_tp = simulate_trade(tp_prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0) + r_explicit_tp = simulate_trade( + tp_prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, fill_mode="idealized" + ) + assert r_default_tp.exit_price == r_explicit_tp.exit_price + assert r_default_tp.net_return_pct == r_explicit_tp.net_return_pct + assert r_default_tp.exit_reason == EXIT_TP + + +def test_sl_gap_through_realized_worse(): + # entry=100, sl_pct=1 -> SL level=99. + # Next bar gaps to 94 (well below SL level). + # idealized: exit_price=99 (exact level), gross=-1%. + # realized: exit_price=94 (actual bar price), gross=-6%. + # sl_gap_stress: exit_price=94 (same SL logic as realized), gross=-6%. + prices = [100.0, 94.0, 100.0] + r_ideal = simulate_trade(prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + fill_mode="idealized") + r_real = simulate_trade(prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + fill_mode="realized") + r_stress = simulate_trade(prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + fill_mode="sl_gap_stress") + + # All three must recognise an SL exit. + assert r_ideal.exit_reason == EXIT_SL + assert r_real.exit_reason == EXIT_SL + assert r_stress.exit_reason == EXIT_SL + + # idealized books at the level; realized and sl_gap_stress book at bar price. + assert _approx(r_ideal.exit_price, 99.0) + assert _approx(r_real.exit_price, 94.0) + assert _approx(r_stress.exit_price, 94.0) + + # Net return ordering: idealized is best (least negative), others are worse. + assert r_ideal.net_return_pct > r_real.net_return_pct + assert _approx(r_real.net_return_pct, r_stress.net_return_pct) + + +def test_tp_overshoot_realized_better(): + # entry=100, tp_pct=5 -> TP level=105. + # Next bar jumps to 108 (overshoots TP). + # realized: exit_price=108, gross=+8%. + # idealized: exit_price=105, gross=+5%. + # sl_gap_stress: exit_price=105 (TP conservative), gross=+5%. + prices = [100.0, 108.0, 90.0] + r_ideal = simulate_trade(prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + fill_mode="idealized") + r_real = simulate_trade(prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + fill_mode="realized") + r_stress = simulate_trade(prices, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + fill_mode="sl_gap_stress") + + assert r_ideal.exit_reason == EXIT_TP + assert r_real.exit_reason == EXIT_TP + assert r_stress.exit_reason == EXIT_TP + + assert _approx(r_real.exit_price, 108.0) + assert _approx(r_ideal.exit_price, 105.0) + assert _approx(r_stress.exit_price, 105.0) + + # realized gets a better fill; idealized and sl_gap_stress are equal (conservative). + assert r_real.net_return_pct > r_ideal.net_return_pct + assert _approx(r_ideal.net_return_pct, r_stress.net_return_pct) + + +def test_invalid_fill_mode_raises(): + with pytest.raises(ValueError, match="fill_mode"): + simulate_trade([100.0, 97.0], tp_pct=5.0, sl_pct=1.0, fill_mode="bogus") + + +def test_expectancy_at_cost_threads_fill_mode(): + # Build two instances: one has an SL gap-through (bar gaps well below SL), + # one time-exits safely inside the band. + # For the gap-through SL instance: + # idealized books sl_level (less loss); sl_gap_stress books bar price (more loss). + # So expectancy under sl_gap_stress <= idealized. + gap_through = GapUpInstance( + symbol="GAP", + session="20240101", + entry_change_rate=3.0, + entry_price=100.0, + prices=(100.0, 90.0), # bar gaps to 90; SL at 99 for sl_pct=1 + seconds=(32400, 32401), + ) + safe = GapUpInstance( + symbol="SAFE", + session="20240102", + entry_change_rate=3.0, + entry_price=100.0, + prices=(100.0, 101.0), # time-exit inside band + seconds=(32400, 32401), + ) + instances = [gap_through, safe] + exp_ideal = expectancy_at_cost( + instances, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, fill_mode="idealized" + ) + exp_stress = expectancy_at_cost( + instances, tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, fill_mode="sl_gap_stress" + ) + assert exp_ideal is not None and exp_stress is not None + assert exp_stress <= exp_ideal diff --git a/tests/test_stom_rl_gap_up_dashboard_publish.py b/tests/test_stom_rl_gap_up_dashboard_publish.py new file mode 100644 index 000000000..d8644c3c8 --- /dev/null +++ b/tests/test_stom_rl_gap_up_dashboard_publish.py @@ -0,0 +1,326 @@ +"""Unit tests for stom_rl.gap_up_dashboard_publish. + +All tests use synthetic instances data (no real DB / filesystem dependencies +beyond tmp_path) and a monkeypatched RL_RUN_ROOTS so the dashboard loader +finds the test output without touching the production webui/rl_runs directory. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List + +import pytest + +from stom_rl.gap_up_dashboard_publish import ( + _apply_filter, + _build_equity_curve, + _net_at_cost, + publish_gap_up_run, +) + + +# --------------------------------------------------------------------------- +# Synthetic fixture data +# --------------------------------------------------------------------------- + +def _make_record( + *, + session: str, + symbol: str, + net_pct_25bps: float, + pass_ts: bool = True, + pass_ts_imb: bool = True, + entry_price: float = 10000.0, + entry_change_rate: float = 3.0, + entry_trade_strength: float = 120.0, + entry_bid_ask_imbalance: float = 0.6, +) -> Dict[str, Any]: + """Create a minimal synthetic instance record.""" + return { + "symbol": symbol, + "session": session, + "split": "in_sample", + "entry_change_rate": entry_change_rate, + "entry_price": entry_price, + "entry_trade_strength": entry_trade_strength, + "entry_sec_amount": 100.0, + "entry_bid_ask_imbalance": entry_bid_ask_imbalance, + "pass_ts": pass_ts, + "pass_ts_imb": pass_ts_imb, + "tp5_sl1_net_pct": net_pct_25bps, + "tp5_sl1_reason": "tp" if net_pct_25bps > 0 else "sl", + } + + +# Five synthetic records: varied net, varied filter flags +SYNTHETIC_RECORDS: List[Dict[str, Any]] = [ + # A: pass_ts=True, pass_ts_imb=True, gain + _make_record(session="20240101", symbol="000010", net_pct_25bps=2.0), + # B: pass_ts=True, pass_ts_imb=False, loss + _make_record(session="20240102", symbol="000020", net_pct_25bps=-0.5, + pass_ts=True, pass_ts_imb=False), + # C: pass_ts=True, pass_ts_imb=True, gain + _make_record(session="20240103", symbol="000030", net_pct_25bps=1.0), + # D: pass_ts=False, pass_ts_imb=False, small gain + _make_record(session="20240104", symbol="000040", net_pct_25bps=0.5, + pass_ts=False, pass_ts_imb=False), + # E: pass_ts=True, pass_ts_imb=True, gain + _make_record(session="20240105", symbol="000050", net_pct_25bps=3.0), +] + + +@pytest.fixture() +def instances_file(tmp_path: Path) -> Path: + """Write synthetic records to a BOM-prefixed UTF-8 JSON file.""" + path = tmp_path / "instances.json" + path.write_text( + json.dumps(SYNTHETIC_RECORDS, ensure_ascii=False), + encoding="utf-8-sig", + ) + return path + + +# --------------------------------------------------------------------------- +# 1. test_equity_curve_non_compounded_matches_cumsum +# --------------------------------------------------------------------------- + +def test_equity_curve_non_compounded_matches_cumsum() -> None: + """NAV at each step equals initial_cash*(1+cumsum/100) within 1e-6.""" + records = [ + _make_record(session="20240101", symbol="A", net_pct_25bps=2.0), + _make_record(session="20240102", symbol="B", net_pct_25bps=-0.5), + _make_record(session="20240103", symbol="C", net_pct_25bps=1.5), + ] + initial_cash = 1_000_000.0 + cost_bps = 25.0 # same as base → no shift + + net_series, nav_series, final_cum = _build_equity_curve( + records, + tp_sl_key="tp5_sl1", + cost_bps=cost_bps, + initial_cash=initial_cash, + ) + + assert len(net_series) == 3 + assert len(nav_series) == 3 + + # Verify each NAV step matches cumulative sum formula + cum = 0.0 + for i, (net_i, nav_i) in enumerate(zip(net_series, nav_series)): + cum += net_i + expected_nav = initial_cash * (1.0 + cum / 100.0) + assert abs(nav_i - expected_nav) < 1e-6, ( + f"Step {i}: nav={nav_i}, expected={expected_nav}" + ) + + # Final cumulative pct returned correctly + assert abs(final_cum - (2.0 - 0.5 + 1.5)) < 1e-9 + + # Final NAV + expected_final = initial_cash * (1.0 + final_cum / 100.0) + assert abs(nav_series[-1] - expected_final) < 1e-6 + + +# --------------------------------------------------------------------------- +# 2. test_cost_shift_additive +# --------------------------------------------------------------------------- + +def test_cost_shift_additive() -> None: + """Changing cost_bps 25→23 shifts each net by +0.02; final cum increases by 0.02*N.""" + records = [ + _make_record(session="20240101", symbol="A", net_pct_25bps=1.0), + _make_record(session="20240102", symbol="B", net_pct_25bps=-0.25), + _make_record(session="20240103", symbol="C", net_pct_25bps=2.0), + _make_record(session="20240104", symbol="D", net_pct_25bps=0.75), + ] + initial_cash = 500_000.0 + n = len(records) + + net_25, nav_25, cum_25 = _build_equity_curve( + records, tp_sl_key="tp5_sl1", cost_bps=25.0, initial_cash=initial_cash + ) + net_23, nav_23, cum_23 = _build_equity_curve( + records, tp_sl_key="tp5_sl1", cost_bps=23.0, initial_cash=initial_cash + ) + + # Each individual net shifted by +0.02 + for i in range(n): + assert abs(net_23[i] - net_25[i] - 0.02) < 1e-9, ( + f"Step {i}: net_23={net_23[i]}, net_25={net_25[i]}" + ) + + # Final cum increases by 0.02 * N + assert abs(cum_23 - cum_25 - 0.02 * n) < 1e-9 + + # Helper also confirms the shift + assert abs(_net_at_cost(1.0, 23.0) - 1.02) < 1e-9 + assert abs(_net_at_cost(-0.5, 23.0) - (-0.48)) < 1e-9 + + +# --------------------------------------------------------------------------- +# 3. test_filter_selection +# --------------------------------------------------------------------------- + +def test_filter_selection() -> None: + """ts_imb selects only pass_ts_imb; none selects all; ts selects pass_ts.""" + # none: all 5 + none_result = _apply_filter(SYNTHETIC_RECORDS, "none") + assert len(none_result) == 5 + + # ts: records A, B, C, E (pass_ts=True); D has pass_ts=False + ts_result = _apply_filter(SYNTHETIC_RECORDS, "ts") + assert len(ts_result) == 4 + assert all(r["pass_ts"] for r in ts_result) + + # ts_imb: records A, C, E (pass_ts_imb=True); B and D excluded + ts_imb_result = _apply_filter(SYNTHETIC_RECORDS, "ts_imb") + assert len(ts_imb_result) == 3 + assert all(r["pass_ts_imb"] for r in ts_imb_result) + + # Invalid filter raises + with pytest.raises(ValueError, match="Invalid filter"): + _apply_filter(SYNTHETIC_RECORDS, "bad_filter") + + +# --------------------------------------------------------------------------- +# 4. test_outputs_written_and_detected +# --------------------------------------------------------------------------- + +def test_outputs_written_and_detected( + instances_file: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """After publish, run dir has 3 files; dashboard load_rl_run detects it correctly.""" + output_root = tmp_path / "rl_runs" + run_name = "gap_up_ts_imb_equity" + + # Monkeypatch RL_RUN_ROOTS so dashboard finds our tmp output + import webui.rl_dashboard as dashboard + monkeypatch.setattr(dashboard, "RL_RUN_ROOTS", [output_root]) + + result = publish_gap_up_run( + instances_path=instances_file, + filter_name="ts_imb", + cost_bps=23.0, + tp_sl_key="tp5_sl1", + run_name=run_name, + output_root=output_root, + initial_cash=1_000_000.0, + ) + + run_dir = output_root / run_name + + # Three expected files present + assert (run_dir / "portfolio_paper_summary.json").is_file() + assert (run_dir / "rl_live_summary.json").is_file() + assert (run_dir / "rl_live_events.jsonl").is_file() + + # Dashboard detects correct artifact type + loaded = dashboard.load_rl_run(run_name) + assert loaded["artifact_type"] == "portfolio_paper" + assert "final_nav" in loaded["summary"] + + # JSONL has exactly 3 lines (ts_imb: records A, C, E) + lines = [ + line for line in + (run_dir / "rl_live_events.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + assert len(lines) == 3 + + # Each line has an equity field + for line in lines: + event = json.loads(line) + assert "equity" in event + assert event["equity"] is not None + + # Return value matches expected structure + assert result["artifact_type"] == "portfolio_paper" + assert result["summary"]["final_nav"] is not None + assert result["config"]["is_reinforcement_learning"] is False + + +# --------------------------------------------------------------------------- +# 5. test_event_schema_honest +# --------------------------------------------------------------------------- + +def test_event_schema_honest( + instances_file: Path, tmp_path: Path +) -> None: + """Each event algorithm starts with 'rule:'; info.note mentions 'RULE'; + config is_reinforcement_learning is False.""" + output_root = tmp_path / "rl_runs" + run_name = "gap_up_none_equity" + + result = publish_gap_up_run( + instances_path=instances_file, + filter_name="none", + cost_bps=23.0, + tp_sl_key="tp5_sl1", + run_name=run_name, + output_root=output_root, + initial_cash=1_000_000.0, + ) + + run_dir = output_root / run_name + lines = [ + line for line in + (run_dir / "rl_live_events.jsonl").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + + # All 5 records with filter=none + assert len(lines) == 5 + + for line in lines: + event = json.loads(line) + # algorithm must start with "rule:" + assert event["algorithm"].startswith("rule:"), ( + f"Expected algorithm starting with 'rule:', got {event['algorithm']!r}" + ) + # info.note mentions RULE + note = event.get("info", {}).get("note", "") + assert "RULE" in note, f"Expected 'RULE' in note, got {note!r}" + + # Config is_reinforcement_learning is False + assert result["config"]["is_reinforcement_learning"] is False + + # portfolio_paper_summary.json also has is_reinforcement_learning=False + summary_path = run_dir / "portfolio_paper_summary.json" + payload = json.loads(summary_path.read_text(encoding="utf-8-sig")) + assert payload["config"]["is_reinforcement_learning"] is False + assert payload["artifact_type"] == "portfolio_paper" + + +# --------------------------------------------------------------------------- +# 6. test_main_cli_encoding_safe +# --------------------------------------------------------------------------- + +def test_main_cli_encoding_safe( + instances_file: Path, tmp_path: Path, capsys: pytest.CaptureFixture +) -> None: + """main() returns 0 and prints valid JSON even when the strategy string + contains non-ASCII characters (em-dash, Korean) that cp949 cannot encode.""" + from stom_rl.gap_up_dashboard_publish import main + + output_root = tmp_path / "rl_runs_cli" + exit_code = main([ + "--instances", str(instances_file), + "--filter", "ts_imb", + "--cost-bps", "23.0", + "--output-root", str(output_root), + "--run-name", "cli_test_run", + ]) + + assert exit_code == 0 + + captured = capsys.readouterr() + # stdout must be non-empty and parse as valid JSON + assert captured.out.strip(), "Expected non-empty stdout" + parsed = json.loads(captured.out) + + # The em-dash in strategy must survive the round-trip + strategy = parsed["summary"]["strategy"] + assert "—" in strategy, f"em-dash missing in strategy: {strategy!r}" + assert parsed["config"]["is_reinforcement_learning"] is False diff --git a/tests/test_stom_rl_gap_up_risk_sizing.py b/tests/test_stom_rl_gap_up_risk_sizing.py new file mode 100644 index 000000000..5eaf9fb1a --- /dev/null +++ b/tests/test_stom_rl_gap_up_risk_sizing.py @@ -0,0 +1,413 @@ +"""Unit tests for gap-up RULE-strategy position sizing & risk control. + +RULE strategy, NOT reinforcement learning. These cover the pure sizing/risk +functions on KNOWN values (no DB, no I/O): per-trade notional + liquidity cap, +1R, the daily loss limit & halt, concurrency exposure, consecutive-loss + monthly +de-risking, account-MDD translation, degenerate Kelly, and the per-account plan +bundle that backs the Page A doc's worked-example table. +""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from stom_rl.gap_up_risk_sizing import ( + DEFAULT_CONSECUTIVE_LOSS_TIERS, + DEFAULT_COST_BPS, + DEFAULT_SL_PCT, + DEFAULT_TP_PCT, + IDEALIZED_EXPECTANCY_PCT, + MAX_LOSS_STREAK, + PRIMARY_FILTER, + STRATEGY_MDD_IDEALIZED_PCT, + STRATEGY_MDD_STRESS_PCT, + STRESS_EXPECTANCY_PCT, + RiskConfig, + account_mdd_estimate_pct, + consecutive_loss_scale, + daily_limit_in_r, + daily_loss_limit_won, + effective_fraction, + expected_pnl_won, + full_kelly_fraction, + max_concurrent_exposure_pct, + max_concurrent_exposure_won, + plan_for_account, + position_notional_won, + risk_per_trade_won, + risk_unit_account_pct, + should_halt_day, + worst_case_concurrent_loss_won, +) + +# Account sizes from the Page A doc's worked-example table. +ONE_CHEON = 10_000_000 # 1,000만원 +FIVE_CHEON = 50_000_000 # 5,000만원 +ONE_EOK = 100_000_000 # 1억원 + + +def _approx(value: float, expected: float, tol: float = 1e-6) -> bool: + return abs(value - expected) <= tol + + +# --------------------------------------------------------------------------- +# Locked descriptive constants (RULE strategy, ts_imb @ 23bp). +# --------------------------------------------------------------------------- +def test_locked_strategy_constants(): + assert PRIMARY_FILTER == "ts_imb" # RULE filter, NOT an RL policy + assert DEFAULT_TP_PCT == 5.0 + assert DEFAULT_SL_PCT == 1.0 + assert DEFAULT_COST_BPS == 23.0 + assert _approx(IDEALIZED_EXPECTANCY_PCT, 0.952) + assert _approx(STRESS_EXPECTANCY_PCT, 0.811) + assert MAX_LOSS_STREAK == 9 + assert STRATEGY_MDD_IDEALIZED_PCT == -15.7 + assert STRATEGY_MDD_STRESS_PCT == -20.0 + + +def test_default_config_matches_user_choices(): + c = RiskConfig() + assert _approx(c.per_trade_fraction, 0.10) # f = 10% + assert c.max_concurrent == 3 # K = 3 + assert _approx(c.daily_loss_limit_pct, 3.0) # daily -3% + assert c.consecutive_loss_tiers == DEFAULT_CONSECUTIVE_LOSS_TIERS + + +def test_config_is_immutable(): + c = RiskConfig() + with pytest.raises(FrozenInstanceError): + c.per_trade_fraction = 0.2 # type: ignore[misc] + + +# --------------------------------------------------------------------------- +# Config validation. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "kwargs", + [ + {"per_trade_fraction": 0.0}, + {"per_trade_fraction": 1.5}, + {"max_concurrent": 0}, + {"daily_loss_limit_pct": 0.0}, + {"tp_pct": 0.0}, + {"sl_pct": 0.0}, + {"cost_bps": -1.0}, + {"max_participation": 0.0}, + {"monthly_derisk_scale": 1.5}, + {"consecutive_loss_tiers": ((5, 0.5), (3, 0.25))}, # not ascending + {"consecutive_loss_tiers": ((3, 1.5),)}, # scale > 1 + {"consecutive_loss_tiers": ((0, 0.5),)}, # min_streak < 1 + ], +) +def test_config_rejects_bad_params(kwargs): + with pytest.raises(ValueError): + RiskConfig(**kwargs) + + +# --------------------------------------------------------------------------- +# Per-trade notional + liquidity cap. +# --------------------------------------------------------------------------- +def test_position_notional_base_fraction(): + c = RiskConfig() + assert _approx(position_notional_won(ONE_EOK, c), 10_000_000, tol=1e-3) + assert _approx(position_notional_won(ONE_CHEON, c), 1_000_000, tol=1e-3) + + +def test_position_notional_fraction_override(): + c = RiskConfig() + # A de-risked fraction (e.g. 0.05) shrinks the order. + assert _approx( + position_notional_won(ONE_EOK, c, fraction=0.05), 5_000_000, tol=1e-3 + ) + + +def test_position_notional_liquidity_cap_binds_for_thin_name(): + c = RiskConfig() # max_participation = 1.0 + # Base would be 10,000,000 but the entry second only traded 5,000,000 -> + # the order is capped at one second of traded value. + capped = position_notional_won( + ONE_EOK, c, entry_liquidity_won=5_000_000 + ) + assert _approx(capped, 5_000_000, tol=1e-3) + # A liquid name (50,000,000/s) does NOT bind -> full base size. + liquid = position_notional_won( + ONE_EOK, c, entry_liquidity_won=50_000_000 + ) + assert _approx(liquid, 10_000_000, tol=1e-3) + + +def test_position_notional_zero_fraction_floors_at_zero(): + # A halted entry (effective fraction 0.0) sizes to zero notional. + c = RiskConfig() + assert _approx(position_notional_won(ONE_EOK, c, fraction=0.0), 0.0, tol=1e-9) + + +def test_position_notional_rejects_bad_inputs(): + c = RiskConfig() + with pytest.raises(ValueError): + position_notional_won(-1, c) + with pytest.raises(ValueError): + position_notional_won(ONE_EOK, c, fraction=1.5) + with pytest.raises(ValueError): + position_notional_won(ONE_EOK, c, entry_liquidity_won=-1) + + +# --------------------------------------------------------------------------- +# 1R (per-trade risk). +# --------------------------------------------------------------------------- +def test_risk_per_trade_won_is_sl_plus_cost(): + c = RiskConfig() + # 1억 -> notional 1,000만 -> R = 1,000만 * (1% + 0.23%) = 123,000. + assert _approx(risk_per_trade_won(10_000_000, c), 123_000, tol=1e-3) + + +def test_risk_unit_account_pct_is_0_123(): + c = RiskConfig() + assert _approx(risk_unit_account_pct(c), 0.123) + + +def test_risk_per_trade_rejects_negative(): + with pytest.raises(ValueError): + risk_per_trade_won(-1, RiskConfig()) + + +# --------------------------------------------------------------------------- +# Daily loss limit & halt. +# --------------------------------------------------------------------------- +def test_daily_loss_limit_won(): + c = RiskConfig() + assert _approx(daily_loss_limit_won(ONE_EOK, c), 3_000_000, tol=1e-3) + assert _approx(daily_loss_limit_won(ONE_CHEON, c), 300_000, tol=1e-3) + + +def test_should_halt_day_at_or_below_negative_limit(): + c = RiskConfig() # 1억 -> limit 3,000,000 + assert should_halt_day(-3_000_000, ONE_EOK, c) is True # exactly at limit + assert should_halt_day(-3_500_000, ONE_EOK, c) is True # past limit + assert should_halt_day(-2_999_999, ONE_EOK, c) is False # not yet + assert should_halt_day(+100_000, ONE_EOK, c) is False # a winning day + # Scales with account size: 1천만 -> limit 300,000. + assert should_halt_day(-300_000, ONE_CHEON, c) is True + assert should_halt_day(-299_999, ONE_CHEON, c) is False + + +def test_should_halt_day_zero_account_halts_conservatively(): + # A zero-balance account has a zero limit, so break-even (or any loss) halts + # it — the conservative contract (no capital -> no new entries). + c = RiskConfig() + assert should_halt_day(0.0, 0, c) is True + + +def test_daily_limit_in_r_is_about_24(): + c = RiskConfig() + # 3% / 0.123% ~= 24.39R -> loose backstop, not the primary control. + assert _approx(daily_limit_in_r(c), 24.390243, tol=1e-3) + + +# --------------------------------------------------------------------------- +# Concurrency exposure. +# --------------------------------------------------------------------------- +def test_max_concurrent_exposure(): + c = RiskConfig() + assert _approx(max_concurrent_exposure_pct(c), 30.0) # f*K = 10%*3 + assert _approx( + max_concurrent_exposure_won(ONE_EOK, c), 30_000_000, tol=1e-3 + ) + + +def test_worst_case_concurrent_loss(): + c = RiskConfig() + # K * R at base notional: 3 * 123,000 = 369,000 (= 0.369% of 1억). + assert _approx( + worst_case_concurrent_loss_won(ONE_EOK, c), 369_000, tol=1e-3 + ) + + +def test_worst_case_concurrent_loss_respects_liquidity_cap(): + c = RiskConfig() # max_participation = 1.0 + # A thin name caps each entry at 5,000,000 (vs 10,000,000 base) -> per- + # position R = 5,000,000 * 0.0123 = 61,500; K=3 -> 184,500. + loss = worst_case_concurrent_loss_won( + ONE_EOK, c, entry_liquidity_won=5_000_000 + ) + assert _approx(loss, 184_500, tol=1e-3) + + +# --------------------------------------------------------------------------- +# Consecutive-loss de-risking (calibrated to max streak 9). +# --------------------------------------------------------------------------- +@pytest.mark.parametrize( + "streak,expected", + [ + (0, 1.0), + (2, 1.0), + (3, 0.5), + (4, 0.5), + (5, 0.25), + (6, 0.25), + (7, 0.0), + (9, 0.0), + ], +) +def test_consecutive_loss_scale_tiers(streak, expected): + assert _approx(consecutive_loss_scale(streak), expected) + + +def test_consecutive_loss_scale_rejects_negative_streak(): + with pytest.raises(ValueError): + consecutive_loss_scale(-1) + + +def test_consecutive_loss_scale_custom_tiers_last_match_wins(): + # Non-monotonic scales: the LAST tier whose min_streak <= streak applies, + # so the break-based selection must pick 0.8 (tier @4), not 0.3 (tier @2). + tiers = ((2, 0.3), (4, 0.8)) + assert _approx(consecutive_loss_scale(1, tiers), 1.0) # below first tier + assert _approx(consecutive_loss_scale(3, tiers), 0.3) # only @2 matches + assert _approx(consecutive_loss_scale(5, tiers), 0.8) # last match wins + + +# --------------------------------------------------------------------------- +# effective_fraction: consecutive-loss + monthly de-risk combined. +# --------------------------------------------------------------------------- +def test_effective_fraction_base_is_full_f(): + c = RiskConfig() + assert _approx(effective_fraction(c, consecutive_losses=0), 0.10) + + +def test_effective_fraction_applies_consecutive_scale(): + c = RiskConfig() + assert _approx(effective_fraction(c, consecutive_losses=3), 0.05) + assert _approx(effective_fraction(c, consecutive_losses=5), 0.025) + assert _approx(effective_fraction(c, consecutive_losses=7), 0.0) + + +def test_effective_fraction_monthly_derisk(): + c = RiskConfig() + # A losing month (<= -1.5%) halves the fraction; a mild month does not. + assert _approx( + effective_fraction(c, consecutive_losses=0, month_return_pct=-2.0), 0.05 + ) + assert _approx( + effective_fraction(c, consecutive_losses=0, month_return_pct=-1.0), 0.10 + ) + # Both de-risks compound: 3-loss streak (0.5) in a losing month (0.5). + assert _approx( + effective_fraction(c, consecutive_losses=3, month_return_pct=-2.0), + 0.025, + ) + + +def test_effective_fraction_never_exceeds_base_and_floors_at_zero(): + c = RiskConfig() + for streak in range(0, 12): + f = effective_fraction(c, consecutive_losses=streak, month_return_pct=-9.0) + assert 0.0 <= f <= c.per_trade_fraction + + +# --------------------------------------------------------------------------- +# Drawdown translation & expectancy. +# --------------------------------------------------------------------------- +def test_account_mdd_sequential_and_concurrent(): + # Sequential (factor 1.0): strategy MDD * f. + assert _approx(account_mdd_estimate_pct(-15.7, 0.10), -1.57) + assert _approx(account_mdd_estimate_pct(-20.0, 0.10), -2.0) + # Worst-case concurrency up to K=3 deepens it. + assert _approx( + account_mdd_estimate_pct(-15.7, 0.10, concurrency_factor=3.0), -4.71 + ) + + +def test_account_mdd_rejects_bad_inputs(): + with pytest.raises(ValueError): + account_mdd_estimate_pct(-15.7, 1.5) # fraction out of range + with pytest.raises(ValueError): + account_mdd_estimate_pct(-15.7, 0.10, concurrency_factor=0.0) + + +def test_expected_pnl_won(): + # 1억 -> notional 1,000만; idealized +0.952% -> +95,200; stress +0.811% -> +81,100. + assert _approx(expected_pnl_won(10_000_000, 0.952), 95_200, tol=1e-3) + assert _approx(expected_pnl_won(10_000_000, 0.811), 81_100, tol=1e-3) + + +def test_expected_pnl_rejects_negative_notional(): + with pytest.raises(ValueError): + expected_pnl_won(-1, 0.952) + + +# --------------------------------------------------------------------------- +# Kelly is degenerate for a tight-SL strategy (justifies drawdown-based sizing). +# --------------------------------------------------------------------------- +def test_full_kelly_is_degenerate_for_tight_sl(): + # p=0.42, win=+4.77% (TP-cost), loss=1.23% (SL+cost) -> f* ~= 21.99 (2,199%). + kelly = full_kelly_fraction(0.42, 4.77, 1.23) + assert _approx(kelly, 21.987, tol=1e-2) + # Chosen f=10% is far below full Kelly (ultra-conservative). + assert 0.10 < kelly + + +def test_full_kelly_rejects_bad_inputs(): + with pytest.raises(ValueError): + full_kelly_fraction(1.5, 4.77, 1.23) # win_rate out of range + with pytest.raises(ValueError): + full_kelly_fraction(0.42, 0.0, 1.23) # non-positive win return + with pytest.raises(ValueError): + full_kelly_fraction(0.42, 4.77, 0.0) # non-positive loss return + + +# --------------------------------------------------------------------------- +# plan_for_account: the doc's worked-example table (1천만 / 5천만 / 1억). +# --------------------------------------------------------------------------- +def test_plan_for_account_one_eok_matches_doc(): + c = RiskConfig() + p = plan_for_account(c, ONE_EOK) + assert _approx(p["notional_won"], 10_000_000, tol=1e-3) + assert _approx(p["risk_per_trade_won"], 123_000, tol=1e-3) + assert _approx(p["risk_unit_account_pct"], 0.123) + assert _approx(p["daily_loss_limit_won"], 3_000_000, tol=1e-3) + assert _approx(p["daily_limit_in_r"], 24.390243, tol=1e-3) + assert _approx(p["max_concurrent_exposure_won"], 30_000_000, tol=1e-3) + assert _approx(p["max_concurrent_exposure_pct"], 30.0) + assert _approx(p["worst_case_concurrent_loss_won"], 369_000, tol=1e-3) + assert _approx(p["expected_pnl_idealized_won"], 95_200, tol=1e-3) + assert _approx(p["expected_pnl_stress_won"], 81_100, tol=1e-3) + assert _approx(p["account_mdd_idealized_pct"], -1.57) + assert _approx(p["account_mdd_stress_pct"], -2.0) + + +def test_plan_for_account_one_cheon_matches_doc(): + c = RiskConfig() + p = plan_for_account(c, ONE_CHEON) + assert _approx(p["notional_won"], 1_000_000, tol=1e-3) + assert _approx(p["risk_per_trade_won"], 12_300, tol=1e-3) + assert _approx(p["daily_loss_limit_won"], 300_000, tol=1e-3) + assert _approx(p["max_concurrent_exposure_won"], 3_000_000, tol=1e-3) + assert _approx(p["worst_case_concurrent_loss_won"], 36_900, tol=1e-3) + assert _approx(p["expected_pnl_idealized_won"], 9_520, tol=1e-3) + assert _approx(p["expected_pnl_stress_won"], 8_110, tol=1e-3) + + +def test_plan_for_account_five_cheon_matches_doc(): + c = RiskConfig() + p = plan_for_account(c, FIVE_CHEON) + assert _approx(p["notional_won"], 5_000_000, tol=1e-3) + assert _approx(p["risk_per_trade_won"], 61_500, tol=1e-3) + assert _approx(p["daily_loss_limit_won"], 1_500_000, tol=1e-3) + assert _approx(p["max_concurrent_exposure_won"], 15_000_000, tol=1e-3) + assert _approx(p["worst_case_concurrent_loss_won"], 184_500, tol=1e-3) + assert _approx(p["expected_pnl_idealized_won"], 47_600, tol=1e-3) + assert _approx(p["expected_pnl_stress_won"], 40_550, tol=1e-3) + + +def test_plan_scales_linearly_with_account(): + c = RiskConfig() + p1 = plan_for_account(c, ONE_CHEON) + p10 = plan_for_account(c, ONE_EOK) + # 1억 is 10x 1천만 -> all 원 quantities scale 10x; ratios are invariant. + assert _approx(p10["notional_won"], p1["notional_won"] * 10, tol=1e-3) + assert _approx(p10["risk_per_trade_won"], p1["risk_per_trade_won"] * 10, tol=1e-3) + assert _approx(p10["risk_unit_account_pct"], p1["risk_unit_account_pct"]) + assert _approx(p10["daily_limit_in_r"], p1["daily_limit_in_r"]) diff --git a/tests/test_stom_rl_leaderboard.py b/tests/test_stom_rl_leaderboard.py new file mode 100644 index 000000000..3820e3f17 --- /dev/null +++ b/tests/test_stom_rl_leaderboard.py @@ -0,0 +1,70 @@ +import json +from pathlib import Path + +import pandas as pd + +from stom_rl.leaderboard import LeaderboardConfig, run_leaderboard + + +def _write_manifest(tmp_path: Path) -> Path: + csv_dir = tmp_path / "csv" + csv_dir.mkdir() + rows = [] + for episode_idx, trend in enumerate([1.0, -1.0], start=1): + frame = pd.DataFrame( + { + "symbol": [f"KRTEST{episode_idx}"] * 16, + "date": pd.date_range("2026-01-02 09:00:00", periods=16, freq="s"), + "open": [100 + trend * i for i in range(16)], + "high": [100 + trend * i for i in range(16)], + "low": [100 + trend * i for i in range(16)], + "close": [100 + trend * i for i in range(16)], + "volume": [1000 + i for i in range(16)], + "amount": [1000 + i * 10 for i in range(16)], + } + ) + source = csv_dir / f"KRTEST{episode_idx}_20260102.csv" + frame.to_csv(source, index=False) + rows.append( + { + "episode_id": f"KRTEST{episode_idx}_20260102", + "symbol": f"TEST{episode_idx}", + "session": "20260102", + "split": "test", + "source_csv": str(source), + } + ) + manifest = { + "mode": "stom_rl_episode_manifest", + "episodes": rows, + } + manifest_path = tmp_path / "episode_manifest.json" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8") + return manifest_path + + +def test_compact_leaderboard_writes_summary_artifacts(tmp_path): + manifest = _write_manifest(tmp_path) + output_dir = tmp_path / "leaderboard" + + payload = run_leaderboard( + LeaderboardConfig( + manifest_path=str(manifest), + output_dir=str(output_dir), + split="test", + policies=("no_trade", "buy_and_hold", "momentum"), + cost_bps_values=(0.0, 25.0), + target_cost_bps=25.0, + lookback_window=3, + reward_horizon_seconds=3, + momentum_window=2, + volume_window=2, + ) + ) + + assert payload["summary"]["episode_count"] == 2 + assert payload["summary"]["scenario_count"] == 6 + assert payload["summary"]["best_policy_at_target_cost"] in {"no_trade", "buy_and_hold", "momentum"} + assert (output_dir / "leaderboard_report.json").is_file() + assert (output_dir / "leaderboard.csv").is_file() + assert (output_dir / "target_policy_episodes.csv").is_file() diff --git a/tests/test_stom_rl_liquidity_model.py b/tests/test_stom_rl_liquidity_model.py new file mode 100644 index 000000000..b34e2200a --- /dev/null +++ b/tests/test_stom_rl_liquidity_model.py @@ -0,0 +1,173 @@ +"""Unit tests for the liquidity feasibility & slippage model (Page C). + +RULE strategy, NOT reinforcement learning. Pure-function known-value checks for +participation rate, feasibility, square-root slippage, slippage-adjusted +expectancy, the slippage-budget order cap, and the per-trade liquidity summary +(including non-positive entry_sec_amount dropping). No DB / no I/O. +""" + +from __future__ import annotations + +import math + +import pytest + +from stom_rl.liquidity_model import ( + _load_entry_sec_amounts, + is_liquidity_feasible, + max_order_for_slippage_budget_won, + participation_rate, + slippage_adjusted_expectancy_pct, + sqrt_impact_slippage_bps, + summarize_liquidity, +) + + +def _approx(value: float, expected: float, tol: float = 1e-9) -> bool: + return abs(value - expected) <= tol + + +# --------------------------------------------------------------------------- +# participation_rate / feasibility +# --------------------------------------------------------------------------- +def test_participation_rate_known(): + assert _approx(participation_rate(1_000_000, 5_000_000), 0.2) + assert _approx(participation_rate(0, 5_000_000), 0.0) + + +def test_participation_rate_rejects_bad_inputs(): + with pytest.raises(ValueError): + participation_rate(-1, 5_000_000) + with pytest.raises(ValueError): + participation_rate(1_000_000, 0) + + +def test_is_liquidity_feasible_boundary_inclusive(): + assert is_liquidity_feasible(1_000_000, 5_000_000, max_participation=0.5) is True + assert is_liquidity_feasible(3_000_000, 5_000_000, max_participation=0.5) is False + # boundary: participation == max_participation is feasible (<=). + assert is_liquidity_feasible(2_500_000, 5_000_000, max_participation=0.5) is True + with pytest.raises(ValueError): + is_liquidity_feasible(1_000_000, 5_000_000, max_participation=0.0) + + +# --------------------------------------------------------------------------- +# square-root slippage + adjusted expectancy +# --------------------------------------------------------------------------- +def test_sqrt_impact_slippage_known(): + # coef 10, participation 0.25 -> 10 * sqrt(0.25) = 10 * 0.5 = 5 bp. + assert _approx(sqrt_impact_slippage_bps(0.25, impact_coef_bps=10.0), 5.0) + assert _approx(sqrt_impact_slippage_bps(0.0, impact_coef_bps=10.0), 0.0) + + +def test_sqrt_impact_rejects_bad_inputs(): + with pytest.raises(ValueError): + sqrt_impact_slippage_bps(-0.1, impact_coef_bps=10.0) + with pytest.raises(ValueError): + sqrt_impact_slippage_bps(0.25, impact_coef_bps=-1.0) + + +def test_slippage_adjusted_expectancy_known(): + # gross 0.98%, base 23bp + slip 5bp = 28bp = 0.28% -> 0.70%. + assert _approx(slippage_adjusted_expectancy_pct(0.98, 23.0, 5.0), 0.70) + + +def test_slippage_adjusted_rejects_negative_costs(): + with pytest.raises(ValueError): + slippage_adjusted_expectancy_pct(0.98, -1.0, 5.0) + with pytest.raises(ValueError): + slippage_adjusted_expectancy_pct(0.98, 23.0, -1.0) + + +# --------------------------------------------------------------------------- +# slippage-budget order cap (inverse of sqrt impact) +# --------------------------------------------------------------------------- +def test_max_order_for_budget_equals_sec_amount_when_budget_equals_coef(): + # budget == coef -> p_max = 1 -> order_max = entry_sec_amount. + assert _approx( + max_order_for_slippage_budget_won(10_000_000, slippage_budget_bps=10.0, impact_coef_bps=10.0), + 10_000_000.0, + ) + + +def test_max_order_for_budget_squares_the_ratio(): + # budget 50, coef 10 -> p_max = 25 -> order = 250,000,000. + assert _approx( + max_order_for_slippage_budget_won(10_000_000, slippage_budget_bps=50.0, impact_coef_bps=10.0), + 250_000_000.0, + ) + + +def test_max_order_rejects_bad_inputs(): + with pytest.raises(ValueError): + max_order_for_slippage_budget_won(10_000_000, slippage_budget_bps=-1.0) + with pytest.raises(ValueError): + max_order_for_slippage_budget_won(10_000_000, slippage_budget_bps=10.0, impact_coef_bps=0.0) + with pytest.raises(ValueError): + max_order_for_slippage_budget_won(0, slippage_budget_bps=10.0) + + +# --------------------------------------------------------------------------- +# summarize_liquidity +# --------------------------------------------------------------------------- +def test_summarize_liquidity_known_values(): + # order 1,000,000 over sec_amounts -> participations [0.1,0.2,0.5,1.0]. + sec = [10_000_000, 5_000_000, 2_000_000, 1_000_000] + s = summarize_liquidity( + sec, order_won=1_000_000, base_cost_bps=23.0, gross_expectancy_pct=0.98, + impact_coef_bps=10.0, + ) + assert s["n"] == 4 and s["n_dropped"] == 0 + assert _approx(s["median_participation"], 0.35) # mean(0.2, 0.5) + assert _approx(s["frac_feasible"], 1.0) # all <= 1x + assert _approx(s["frac_strict"], 0.25) # only 0.1 <= 0.1x + assert _approx(s["median_slippage_bps"], 10.0 * math.sqrt(0.35)) + assert _approx( + s["slippage_adj_expectancy_pct_at_median"], + 0.98 - (23.0 + 10.0 * math.sqrt(0.35)) / 100.0, + ) + + +def test_summarize_liquidity_drops_non_positive_sec_amount(): + s = summarize_liquidity( + [None, 0, -5, 10_000_000], order_won=1_000_000, impact_coef_bps=10.0 + ) + assert s["n"] == 1 and s["n_dropped"] == 3 + assert _approx(s["median_participation"], 0.1) + + +def test_summarize_liquidity_empty_is_safe(): + s = summarize_liquidity([], order_won=1_000_000) + assert s["n"] == 0 + assert s["median_participation"] is None + assert s["slippage_adj_expectancy_pct_at_median"] is None + + +def test_load_entry_sec_amounts_scales_and_filters(tmp_path): + # 초당거래대금 stored in 백만원 -> loader converts to won (x1e6); ts_imb filter; + # None dropped. + import json + + p = tmp_path / "inst.json" + p.write_text( + json.dumps( + [ + {"pass_ts_imb": True, "entry_sec_amount": 622.0}, + {"pass_ts_imb": False, "entry_sec_amount": 100.0}, # filtered (not ts_imb) + {"pass_ts_imb": True, "entry_sec_amount": None}, # dropped (None) + {"pass_ts_imb": True, "entry_sec_amount": 2.0}, + ] + ), + encoding="utf-8", + ) + vals = _load_entry_sec_amounts(str(p), only_ts_imb=True, unit_won=1_000_000.0) + assert vals == [622_000_000.0, 2_000_000.0] + + +def test_summarize_liquidity_larger_account_raises_participation(): + sec = [10_000_000, 5_000_000, 2_000_000, 1_000_000] + small = summarize_liquidity(sec, order_won=1_000_000) + big = summarize_liquidity(sec, order_won=10_000_000) + # 10x the order -> 10x the participation, so feasibility can only drop. + assert big["median_participation"] > small["median_participation"] + assert big["frac_feasible"] <= small["frac_feasible"] diff --git a/tests/test_stom_rl_liquidity_recon.py b/tests/test_stom_rl_liquidity_recon.py new file mode 100644 index 000000000..dc34f839e --- /dev/null +++ b/tests/test_stom_rl_liquidity_recon.py @@ -0,0 +1,131 @@ +"""Unit tests for clean per-second liquidity reconstruction (experiment 2). + +RULE strategy, NOT reinforcement learning. Pure-function known-value checks for +the clean per-second flow, the auction-bar-excluded entry proxy, the cumulative +first-difference cross-check, and capacity summarisation (including that a clean +denominator yields HIGHER participation than the inflated one). No DB / no I/O. +""" + +from __future__ import annotations + +import math + +import pytest + +from stom_rl.liquidity_recon import ( + clean_per_second_values, + entry_liquidity_proxy, + reconstruct_from_cumulative, + summarize_capacity, +) + + +def _approx(v: float, e: float, tol: float = 1e-9) -> bool: + return abs(v - e) <= tol + + +# --------------------------------------------------------------------------- +# clean_per_second_values +# --------------------------------------------------------------------------- +def test_clean_per_second_values_sums_buy_and_sell(): + out = clean_per_second_values([1_000_000, None, 0], [500_000, 2_000_000, None]) + assert out == [1_500_000.0, 2_000_000.0, 0.0] + + +def test_clean_per_second_values_length_mismatch_raises(): + with pytest.raises(ValueError): + clean_per_second_values([1.0, 2.0], [1.0]) + + +# --------------------------------------------------------------------------- +# entry_liquidity_proxy (auction bar excluded; median of positive flow) +# --------------------------------------------------------------------------- +def test_entry_proxy_skips_first_and_medians_positive(): + # First value is the contaminated auction bar (huge); it must be skipped. + psv = [999_999_999.0, 10.0, 20.0, 30.0, 40.0, 50.0] + # window after skipping index0: [10,20,30,40,50] -> median 30 + assert _approx( + entry_liquidity_proxy(psv, window_bars=60, skip_first=True, min_bars=5), 30.0 + ) + + +def test_entry_proxy_excludes_zeros_and_respects_min_bars(): + psv = [9e9, 0.0, 0.0, 10.0, 0.0, 20.0] # only two positive after skip + assert entry_liquidity_proxy(psv, min_bars=5) is None # too thin + assert _approx(entry_liquidity_proxy(psv, min_bars=2), 15.0) # median(10,20) + + +def test_entry_proxy_window_truncation(): + psv = [9e9] + [100.0] * 3 + [1.0] * 100 # only first 3 continuous bars count + # window_bars=3 after skip uses [100,100,100] -> median 100 + assert _approx(entry_liquidity_proxy(psv, window_bars=3, min_bars=3), 100.0) + + +def test_entry_proxy_rejects_bad_window(): + with pytest.raises(ValueError): + entry_liquidity_proxy([1.0, 2.0], window_bars=0) + + +# --------------------------------------------------------------------------- +# reconstruct_from_cumulative (positive first-difference; drops auction print) +# --------------------------------------------------------------------------- +def test_reconstruct_from_cumulative_differences(): + # cumulative won: auction 5_000_000 then +1M, +2M, (reset/dip -> 0), +3M + cum = [5_000_000, 6_000_000, 8_000_000, 7_000_000, 10_000_000] + out = reconstruct_from_cumulative(cum) + assert out == [1_000_000.0, 2_000_000.0, 0.0, 3_000_000.0] + + +def test_reconstruct_handles_none(): + cum = [None, 1_000_000, None, 4_000_000] + # diffs only across consecutive non-None: (1M -> None skipped) then 4M-1M=3M + out = reconstruct_from_cumulative(cum) + assert out == [3_000_000.0] + + +# --------------------------------------------------------------------------- +# summarize_capacity +# --------------------------------------------------------------------------- +def test_summarize_capacity_clean_denominator_known_values(): + # clean per-second values (won) for 4 instances. + clean = [10_000_000, 5_000_000, 2_000_000, 1_000_000] + cap = summarize_capacity( + clean, + accounts_won=[10_000_000], # order = 0.1 * 10M = 1,000,000 + per_trade_fraction=0.10, + base_cost_bps=23.0, + gross_expectancy_pct=0.98, + impact_coefs_bps=[10.0], + ) + acct = cap["by_account"][0] + # participations for order 1M: [0.1, 0.2, 0.5, 1.0] -> median 0.35 + assert _approx(acct["median_participation"], 0.35) + assert _approx(acct["frac_feasible_le_1x"], 1.0) + assert _approx(acct["frac_strict_le_0.1x"], 0.25) + slip = acct["coef_sweep"][0]["median_slippage_bps"] + assert _approx(slip, 10.0 * math.sqrt(0.35)) + # breakeven account where median order hits 1x of median clean value (3.5M): + # median(clean)=3,500,000 ; /f=0.1 -> 35,000,000 + assert _approx(cap["median_clean_entry_value_won"], 3_500_000.0) + assert _approx(cap["breakeven_1x_account_won_median"], 35_000_000.0) + + +def test_summarize_capacity_clean_raises_participation_vs_inflated(): + # Same order, but a CLEAN (smaller) denominator must give HIGHER participation + # than an INFLATED one — the whole point of experiment 2. + inflated = [100_000_000.0] # contaminated, big + clean = [2_000_000.0] # clean, small + order_acct = [10_000_000] # order 1,000,000 + cap_inf = summarize_capacity(inflated, accounts_won=order_acct, impact_coefs_bps=[10.0]) + cap_cln = summarize_capacity(clean, accounts_won=order_acct, impact_coefs_bps=[10.0]) + assert ( + cap_cln["by_account"][0]["median_participation"] + > cap_inf["by_account"][0]["median_participation"] + ) + + +def test_summarize_capacity_drops_non_positive(): + cap = summarize_capacity( + [0.0, -5.0, None, 4_000_000.0], accounts_won=[10_000_000], impact_coefs_bps=[10.0] + ) + assert cap["n_usable"] == 1 and cap["n_dropped"] == 3 diff --git a/tests/test_stom_rl_live_events.py b/tests/test_stom_rl_live_events.py new file mode 100644 index 000000000..a89b22519 --- /dev/null +++ b/tests/test_stom_rl_live_events.py @@ -0,0 +1,59 @@ +import json +from pathlib import Path + +from stom_rl.rl_events import RlLiveEvent, RlLiveEventWriter, read_live_events, summarize_live_event_file + + +def test_live_event_writer_roundtrips_jsonl_tail(tmp_path: Path): + path = tmp_path / "events.jsonl" + writer = RlLiveEventWriter(path, run_id="run-a") + writer.reset() + writer.write_step( + algorithm="dqn", + phase="train", + global_step=1, + action=1, + reward=0.25, + episode_id="ep-1", + timestamp="2025-01-03T09:00:01", + price=101.0, + position=1, + equity=None, + info={"invalid_action": False}, + ) + writer.write( + RlLiveEvent( + run_id="run-a", + algorithm="dqn", + phase="eval", + global_step=2, + action=2, + reward=float("nan"), + equity=1.01, + ) + ) + path.write_text(path.read_text(encoding="utf-8") + "not-json\\n", encoding="utf-8") + rows, truncated = read_live_events(path, limit=1) + assert truncated is True + assert rows[0]["phase"] == "eval" + assert rows[0]["reward"] is None + assert rows[0]["action_name"] == "sell" + + summary = summarize_live_event_file(path) + assert summary["event_count"] == 2 + assert summary["phases"] == {"eval": 1, "train": 1} + assert summary["latest_equity"] == 1.01 + + +def test_live_event_payload_is_json_serializable(): + payload = RlLiveEvent( + run_id="run-b", + algorithm="ppo", + phase="train", + global_step=3, + action=0, + reward=0.0, + ).to_dict() + assert payload["schema_version"] == "stom_rl_live_event.v1" + assert payload["action_name"] == "hold" + json.dumps(payload) diff --git a/tests/test_stom_rl_market_participant_studies.py b/tests/test_stom_rl_market_participant_studies.py new file mode 100644 index 000000000..6a1b5d0f7 --- /dev/null +++ b/tests/test_stom_rl_market_participant_studies.py @@ -0,0 +1,65 @@ +import json + +from stom_rl.market_participant_studies import ( + build_market_participant_studies, +) +from stom_rl.participant_pressure_features import COL_TRANSACTION_VALUE +from tests.fixtures.stom_opening_rl import opening_orderbook_frame + + +def _frame(symbol: str, session: str, *, amount_scale: float = 1.0): + frame = opening_orderbook_frame(symbol=symbol, session=session) + frame[COL_TRANSACTION_VALUE] = frame[COL_TRANSACTION_VALUE].astype(float) * amount_scale + return frame + + +def test_value_surge_study_writes_group_counts_and_forward_returns(tmp_path): + frames = [ + _frame("000250", "20250103", amount_scale=1.0), + _frame("000250", "20250106", amount_scale=10000.0), + _frame("000250", "20250107", amount_scale=1.5), + ] + + payload = build_market_participant_studies(frames, output_dir=tmp_path / "study") + + assert payload["artifact_type"] == "market_participant_study" + assert payload["strategy_context"]["is_reinforcement_learning"] is False + assert payload["strategy_context"]["is_live_ready"] is False + assert payload["strategy_context"]["is_profit_model"] is False + assert payload["config"]["absolute_amount_threshold_krw"] == 100_000_000_000.0 + assert payload["config"]["amount_multiples"] == [2.0, 3.0, 5.0, 10.0] + assert payload["summary"]["forward_return_columns"] == [ + "forward_1_session_return_pct", + "forward_3_session_return_pct", + "forward_5_session_return_pct", + "forward_20_session_return_pct", + ] + assert payload["surge_groups"]["absolute_ge_100b_krw"]["episode_count"] == 1 + assert "baseline_policy" in payload["surge_groups"]["absolute_ge_100b_krw"] + assert (tmp_path / "study" / "market_participant_study_summary.json").is_file() + assert (tmp_path / "study" / "market_participant_study_episodes.csv").is_file() + + saved = json.loads((tmp_path / "study" / "market_participant_study_summary.json").read_text(encoding="utf-8")) + assert saved["surge_groups"]["amount_multiple_ge_2x"]["episode_count"] >= 1 + + +def test_upper_wick_study_marks_missing_flow_proxies_without_zero_fill(tmp_path): + frame = _frame("000250", "20250103") + future_changed = frame.copy() + frame.loc[1, "현재가"] = 1120.0 + frame.loc[3, "현재가"] = 1002.0 + future_changed.loc[1, "현재가"] = 1120.0 + future_changed.loc[3, "현재가"] = 1002.0 + future_changed.loc[future_changed.index > 3, "현재가"] = 1600.0 + + original = build_market_participant_studies([frame], output_dir=tmp_path / "original", decision_second=3) + changed = build_market_participant_studies([future_changed], output_dir=tmp_path / "changed", decision_second=3) + + assert original["upper_wick_study"]["upper_wick_rule"] == "upper_wick > body * 2" + assert original["upper_wick_study"]["upper_wick_count"] == 1 + assert changed["upper_wick_study"] == original["upper_wick_study"] + for row in original["proxy_strata"]: + assert row["status"] == "proxy_unavailable" + assert row["episode_count"] == original["summary"]["episode_count"] + assert row["net_buy_sum"] is None + assert original["verdict"] == "NO-GO_SAMPLE" diff --git a/tests/test_stom_rl_marketable_fill.py b/tests/test_stom_rl_marketable_fill.py new file mode 100644 index 000000000..53c7f74c3 --- /dev/null +++ b/tests/test_stom_rl_marketable_fill.py @@ -0,0 +1,95 @@ +"""Unit tests for de-idealized marketable fills + rule-from-entry (Page P1b). + +RULE strategy, NOT reinforcement learning. Known-value checks: marketable buy +pays the ask and sell hits the bid (spread paid twice), TP/SL/time exits, the +entry cost, and entry from an arbitrary bar. No DB / no I/O. +""" + +from __future__ import annotations + +import pytest + +from stom_rl.marketable_fill import ( + EXIT_SL, + EXIT_TIME, + EXIT_TP, + marketable_entry_price, + marketable_exit_price, + simulate_rule_from_entry, +) + + +def _approx(v, e, tol=1e-6): + return abs(v - e) <= tol + + +def test_marketable_prices_cross_the_spread(): + assert _approx(marketable_entry_price(99.0, 101.0, 100.0), 101.0) # buy pays ask + assert _approx(marketable_exit_price(99.0, 101.0, 100.0), 99.0) # sell hits bid + # slippage on top + assert _approx(marketable_entry_price(99.0, 101.0, 100.0, slippage_bps=100.0), 101.0 * 1.01) + assert _approx(marketable_exit_price(99.0, 101.0, 100.0, slippage_bps=100.0), 99.0 * 0.99) + + +def test_marketable_prices_fall_back_to_price_when_quote_missing(): + assert _approx(marketable_entry_price(None, None, 100.0), 100.0) + assert _approx(marketable_exit_price(None, None, 100.0), 100.0) + assert _approx(marketable_entry_price(99.0, 0.0, 100.0), 100.0) # non-positive ask -> price + + +def test_rule_from_entry_tp_no_spread(): + net, reason = simulate_rule_from_entry( + [100.0, 110.0], [100.0, 110.0], [100.0, 110.0], [0, 1], 0, + tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + ) + assert reason == EXIT_TP + assert _approx(net, 10.0) + + +def test_rule_from_entry_spread_eats_return(): + # entry fills at ask 101, exit hits bid 109 -> +7.92% (vs +10% mid move). + net, reason = simulate_rule_from_entry( + [100.0, 110.0], [99.0, 109.0], [101.0, 111.0], [0, 1], 0, + tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + ) + assert reason == EXIT_TP + assert _approx(net, (109.0 / 101.0 - 1.0) * 100.0) # ~7.921 + + +def test_rule_from_entry_cost_subtracted(): + net, _ = simulate_rule_from_entry( + [100.0, 110.0], [100.0, 110.0], [100.0, 110.0], [0, 1], 0, + tp_pct=5.0, sl_pct=1.0, cost_bps=23.0, + ) + assert _approx(net, 10.0 - 0.23) + + +def test_rule_from_entry_sl_and_time(): + sl_net, sl_r = simulate_rule_from_entry( + [100.0, 98.0], [100.0, 98.0], [100.0, 98.0], [0, 1], 0, + tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + ) + assert sl_r == EXIT_SL and _approx(sl_net, -2.0) + t_net, t_r = simulate_rule_from_entry( + [100.0, 101.0, 102.0], [100.0, 101.0, 102.0], [100.0, 101.0, 102.0], [0, 1, 1500], 0, + tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + ) + assert t_r == EXIT_TIME and _approx(t_net, 2.0) + + +def test_rule_from_arbitrary_entry_index(): + # Enter at bar 1 (price 100), TP at +5% hit by bar 2 (110). + net, reason = simulate_rule_from_entry( + [100.0, 100.0, 110.0], [100.0, 100.0, 110.0], [100.0, 100.0, 110.0], [0, 1, 2], 1, + tp_pct=5.0, sl_pct=1.0, cost_bps=0.0, + ) + assert reason == EXIT_TP and _approx(net, 10.0) + + +def test_rule_from_entry_rejects_bad_inputs(): + with pytest.raises(ValueError): + simulate_rule_from_entry([100.0], [100.0], [100.0], [0], 5) # idx out of range + with pytest.raises(ValueError): + simulate_rule_from_entry([100.0, 101.0], [100.0, 101.0], [100.0, 101.0], [0, 1], 0, tp_pct=0.0) + with pytest.raises(ValueError): + marketable_entry_price(99.0, 101.0, 100.0, slippage_bps=-1.0) diff --git a/tests/test_stom_rl_microstructure_features.py b/tests/test_stom_rl_microstructure_features.py new file mode 100644 index 000000000..365f43d4b --- /dev/null +++ b/tests/test_stom_rl_microstructure_features.py @@ -0,0 +1,112 @@ +"""Unit tests for causal microstructure features (Page R-P1). + +RULE strategy, NOT reinforcement learning. Known-value checks for the pure +helpers and the causal feature vector, plus a structural causality check (the +vector at the decision row never depends on future rows). No DB / no I/O. +""" + +from __future__ import annotations + +import math + +import pytest + +from stom_rl.microstructure_features import ( + FEATURE_NAMES, + causal_feature_vector, + depth_ofi, + imbalance, + linfit_slope, + microprice, + pct_return, + signed_flow, +) + + +def _approx(value, expected, tol=1e-9): + return abs(value - expected) <= tol + + +def test_pct_return(): + assert _approx(pct_return(100.0, 105.0), 5.0) + assert _approx(pct_return(100.0, 100.0), 0.0) + assert pct_return(0.0, 5.0) is None + assert pct_return(None, 5.0) is None + + +def test_imbalance(): + assert _approx(imbalance(60.0, 40.0), 0.6) + assert imbalance(0.0, 0.0) is None + assert imbalance(None, 40.0) is None + + +def test_signed_flow(): + assert _approx(signed_flow(100.0, 30.0), 70.0) + assert _approx(signed_flow(None, 30.0), -30.0) + assert _approx(signed_flow(50.0, None), 50.0) + + +def test_linfit_slope(): + assert _approx(linfit_slope([1.0, 2.0, 3.0, 4.0]), 1.0) # perfect slope 1 + assert _approx(linfit_slope([5.0, 5.0, 5.0]), 0.0) # flat + assert _approx(linfit_slope([3.0]), 0.0) # < 2 points + assert _approx(linfit_slope([]), 0.0) + + +def test_microprice_pulled_toward_thin_side(): + # bid=100 ask=102, ask has MORE resting size (30 vs 10) -> price likely to + # fall -> microprice below mid (101). + mp = microprice(100.0, 102.0, 10.0, 30.0) + assert _approx(mp, (100.0 * 30.0 + 102.0 * 10.0) / 40.0) # = 100.5 + assert mp < 101.0 + assert microprice(None, 102.0, 10.0, 30.0) is None + assert microprice(100.0, 102.0, 0.0, 0.0) is None + + +def test_depth_ofi(): + # Δbid_total +20, Δask_total -10 -> OFI = 20 - (-10) = 30 (buy pressure). + assert _approx(depth_ofi(100.0, 100.0, 120.0, 90.0), 30.0) + assert _approx(depth_ofi(None, None, 50.0, 50.0), 0.0) + + +def _win(): + return [ + {"sec": 0, "price": 100.0, "buy_val": 0, "sell_val": 0, "ts": 100, + "bid_tot": 1000, "ask_tot": 1000, "bid1": 99, "ask1": 101, "bidq1": 50, "askq1": 50}, + {"sec": 1, "price": 101.0, "buy_val": 200, "sell_val": 100, "ts": 110, + "bid_tot": 1100, "ask_tot": 1000, "bid1": 100, "ask1": 102, "bidq1": 60, "askq1": 40}, + {"sec": 2, "price": 102.0, "buy_val": 300, "sell_val": 50, "ts": 130, + "bid_tot": 1200, "ask_tot": 900, "bid1": 101, "ask1": 103, "bidq1": 70, "askq1": 30}, + ] + + +def test_causal_feature_vector_known_values(): + f = causal_feature_vector(_win()) + assert _approx(f["t_sec"], 2.0) + assert _approx(f["ret_open"], 2.0) # 100 -> 102 + assert _approx(f["ts_level"], 130.0) + assert _approx(f["book_imb_l1"], 0.7) # 70/(70+30) + assert _approx(f["book_imb_tot"], 1200.0 / 2100.0) + assert _approx(f["sflow_val_5"], 350.0) # 0 + 100 + 250 + # microprice dev: mp=(101*30+103*70)/100=102.4, mid=102 -> +0.392% + assert _approx(f["micro_dev"], (102.4 - 102.0) / 102.0 * 100.0, tol=1e-6) + + +def test_causal_feature_vector_uses_only_past_rows(): + w = _win() + # The decision-at-index-1 vector must be independent of any future row 2..N. + base = causal_feature_vector(w[:2]) + future_added = causal_feature_vector((w + [{"sec": 3, "price": 999.0}])[:2]) + assert base == future_added + + +def test_causal_feature_vector_empty_raises(): + with pytest.raises(ValueError): + causal_feature_vector([]) + + +def test_feature_names_stable_and_dense(): + f = causal_feature_vector(_win()) + assert set(f.keys()) == set(FEATURE_NAMES) + assert len(FEATURE_NAMES) >= 20 + assert all(isinstance(v, float) and math.isfinite(v) for v in f.values()) diff --git a/tests/test_stom_rl_opening_action_contract.py b/tests/test_stom_rl_opening_action_contract.py new file mode 100644 index 000000000..3b019d835 --- /dev/null +++ b/tests/test_stom_rl_opening_action_contract.py @@ -0,0 +1,19 @@ +from stom_rl.opening_30m_rl_candidates import opening_action_contract + + +def test_ppo_dqn_share_discrete_opening_actions(): + contract = opening_action_contract() + + assert contract["space"] == "Discrete(2)" + assert contract["actions"] == ["HOLD_AFTER_FIXED_ENTRY", "EXIT_LONG_MARKETABLE"] + assert contract["policy_action_names"] == ["hold", "exit"] + assert contract["environment_mode"] == "fixed_entry_exit_only" + assert contract["live_order_actions"] is False + assert contract["continuous_sizing"] is False + + +def test_opening_action_contract_rejects_live_or_continuous_actions(): + contract = opening_action_contract() + + assert "BROKER_ORDER" not in contract["actions"] + assert contract["continuous_sizing"] is False diff --git a/tests/test_stom_rl_opening_artifacts.py b/tests/test_stom_rl_opening_artifacts.py new file mode 100644 index 000000000..c4fd818bb --- /dev/null +++ b/tests/test_stom_rl_opening_artifacts.py @@ -0,0 +1,81 @@ +import json +from pathlib import Path + +from stom_rl.opening_30m_rl_artifacts import write_opening_eval_artifacts +from stom_rl.opening_30m_rl_baselines import OpeningBaselineConfig, evaluate_opening_baselines +from tests.fixtures.stom_opening_rl import build_opening_fixture_frames + + +def _training_payload(tmp_path): + return { + "artifact_type": "opening_30m_training_stage", + "status": "skipped_sb3_unavailable", + "algorithm": "DQN", + "seed": 123, + "total_timesteps": 8, + "fixed_entry_exit_only": True, + "train_episode_ids": ["000250_20250103", "005930_20250106"], + "eval_episode_ids": ["000660_20250107"], + "model_files": {}, + "evaluation": [ + {"episode_id": "000660_20250107", "reward": 0.0, "steps": 0, "trade_count": 0}, + ], + "artifacts": {"summary_json": str(tmp_path / "training_summary.json")}, + "strategy_context": {"is_live_ready": False, "is_profit_model": False}, + } + + +def test_opening_eval_writes_dashboard_compatible_artifacts(tmp_path): + baseline = evaluate_opening_baselines(build_opening_fixture_frames(), OpeningBaselineConfig()) + + payload = write_opening_eval_artifacts( + output_dir=tmp_path / "eval", + training_payload=_training_payload(tmp_path), + baseline_payload=baseline, + source_manifest_path=Path("manifest.json"), + seed=123, + cost_bps=23.0, + ) + + assert payload["artifact_type"] == "opening_30m_eval_artifacts" + for name in [ + "summary_json", + "summary_csv", + "actions_csv", + "trades_csv", + "episodes_csv", + "baseline_csv", + "diagnostics_json", + "live_events_jsonl", + ]: + assert Path(payload["artifacts"][name]).is_file() + summary = json.loads(Path(payload["artifacts"]["summary_json"]).read_text(encoding="utf-8")) + assert summary["summary"]["seed"] == 123 + assert summary["summary"]["cost_bps"] == 23.0 + assert summary["summary"]["train_episode_ids"] == ["000250_20250103", "005930_20250106"] + assert summary["summary"]["eval_episode_ids"] == ["000660_20250107"] + + +def test_opening_artifacts_do_not_emit_profit_claim_fields(tmp_path): + payload = write_opening_eval_artifacts( + output_dir=tmp_path / "eval", + training_payload=_training_payload(tmp_path), + baseline_payload=evaluate_opening_baselines(build_opening_fixture_frames(), OpeningBaselineConfig()), + source_manifest_path=Path("manifest.json"), + seed=123, + cost_bps=23.0, + ) + + raw = json.loads(Path(payload["artifacts"]["summary_json"]).read_text(encoding="utf-8")) + + def walk_keys(value): + if isinstance(value, dict): # noqa: IF_VARIANT_OK - recursive JSON tree walker + for key, nested in value.items(): + yield key + yield from walk_keys(nested) + elif isinstance(value, list): + for item in value: + yield from walk_keys(item) + + forbidden = [key for key in walk_keys(raw) if "profit" in key.lower() and key != "is_profit_model"] + assert forbidden == [] diff --git a/tests/test_stom_rl_opening_baselines.py b/tests/test_stom_rl_opening_baselines.py new file mode 100644 index 000000000..ba3243dc6 --- /dev/null +++ b/tests/test_stom_rl_opening_baselines.py @@ -0,0 +1,45 @@ +import pytest + +from stom_rl.opening_30m_rl_baselines import ( + OpeningBaselineConfig, + evaluate_opening_baselines, +) +from tests.fixtures.stom_opening_rl import opening_orderbook_frame + + +def test_same_decision_ts_imb_baseline_uses_marketable_fills_and_23bp(): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + + payload = evaluate_opening_baselines( + [frame], + OpeningBaselineConfig(decision_index=0, cost_bps=23.0), + ) + + ts_imb = next(row for row in payload["rows"] if row["policy"] == "ts_imb_same_decision_tp5_sl1_time") + assert ts_imb["is_reinforcement_learning"] is False + assert ts_imb["cost_bps"] == 23.0 + assert ts_imb["entry_price"] == pytest.approx(frame["매도호가1"].iloc[0]) + assert ts_imb["exit_price"] == pytest.approx(frame["매수호가1"].iloc[-1]) + expected_net = (frame["매수호가1"].iloc[-1] / frame["매도호가1"].iloc[0] - 1.0) * 100.0 - 0.23 + assert ts_imb["net_return_pct"] == pytest.approx(expected_net) + assert payload["summary"]["baseline_delta_inputs"]["no_trade"] == 0.0 + assert "buy_and_hold" in payload["summary"]["baseline_delta_inputs"] + + +def test_opening_rule_baseline_is_not_reinforcement_learning(): + payload = evaluate_opening_baselines( + [opening_orderbook_frame(symbol="000250", session="20250103")], + OpeningBaselineConfig(), + ) + + assert payload["artifact_type"] == "opening_30m_baseline_comparator" + assert payload["strategy_context"]["label"] == "RULE BASELINE" + assert payload["strategy_context"]["is_reinforcement_learning"] is False + assert payload["strategy_context"]["is_live_ready"] is False + assert payload["strategy_context"]["is_profit_model"] is False + assert {row["policy"] for row in payload["rows"]} == { + "no_trade", + "buy_and_hold", + "ts_imb_same_decision_tp5_sl1_time", + } + assert all(row["is_reinforcement_learning"] is False for row in payload["rows"]) diff --git a/tests/test_stom_rl_opening_candidate_ablation.py b/tests/test_stom_rl_opening_candidate_ablation.py new file mode 100644 index 000000000..87924e3ee --- /dev/null +++ b/tests/test_stom_rl_opening_candidate_ablation.py @@ -0,0 +1,172 @@ +from stom_rl.opening_30m_rl_candidate_diagnostics import ablation_rows +from stom_rl.opening_30m_rl_candidate_gate import REQUIRED_ABLATIONS, build_ablation_artifact +from stom_rl.opening_30m_rl_candidate_smoke import _ablation_configs +from stom_rl.opening_30m_rl_candidates import default_candidate_configs +from stom_rl.opening_30m_rl_candidate_training import feature_mask_details + + +def test_feature_ablation_artifact_compares_required_feature_groups(): + rows = [ + {"feature_set_id": "full", "removed_feature_groups": [], "oos_net_return_pct": 3.0, "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": 0.0}, + {"feature_set_id": "no_participant", "removed_feature_groups": ["participant"], "oos_net_return_pct": 2.0, "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0}, + {"feature_set_id": "no_orderbook", "removed_feature_groups": ["hoga"], "oos_net_return_pct": 1.5, "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.5}, + {"feature_set_id": "no_overheat", "removed_feature_groups": ["overheat"], "oos_net_return_pct": 1.4, "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.6}, + {"feature_set_id": "minimal_price_volume", "removed_feature_groups": ["participant", "hoga"], "oos_net_return_pct": 0.5, "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -2.5}, + {"feature_set_id": "shuffled_participant_context", "removed_feature_groups": ["participant_pressure"], "oos_net_return_pct": 0.3, "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -2.7, "shuffled_feature_names": ["participant_pressure_score"]}, + ] + + artifact = build_ablation_artifact(rows, split_hash="split123", candidate_id="dqn") + + assert artifact["feature_ablation_passed"] is True + assert {row["feature_set_id"] for row in artifact["ablations"]} >= {"full_context", "no_orderbook_imbalance"} + assert {row["candidate_id"] for row in artifact["ablations"]} == {"dqn"} + + + +def test_ablation_artifact_normalizes_legacy_rows_to_canonical_ids(): + artifact = build_ablation_artifact( + [ + {"feature_set_id": "full", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": 0.0}, + {"feature_set_id": "no_participant", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0}, + {"feature_set_id": "no_orderbook", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0}, + {"feature_set_id": "no_overheat", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0}, + {"feature_set_id": "minimal_price_volume", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0}, + {"feature_set_id": "shuffled_participant_context", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0, "shuffled_feature_names": ["participant_pressure_score"]}, + ], + split_hash="split123", + ) + + assert tuple(row["feature_set_id"] for row in artifact["ablations"]) == REQUIRED_ABLATIONS + assert artifact["feature_ablation_passed"] is True + +def test_missing_required_ablation_blocks_go_candidate(): + artifact = build_ablation_artifact([{"feature_set_id": "full", "passed": True}], split_hash="split123") + + assert artifact["feature_ablation_passed"] is False + assert artifact["verdict"] == "INCONCLUSIVE" + + +def test_candidate_ablation_filters_other_candidate_rows(): + artifact = build_ablation_artifact( + [ + {"candidate_id": "other", "feature_set_id": "full", "passed": True}, + {"candidate_id": "dqn", "feature_set_id": "full", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": 0.0}, + ], + split_hash="split123", + candidate_id="dqn", + ) + + full = next(row for row in artifact["ablations"] if row["feature_set_id"] == "full_context") + assert full["candidate_id"] == "dqn" + assert artifact["feature_ablation_passed"] is False + + +def test_feature_absent_ablation_is_explicit_not_applicable(): + artifact = build_ablation_artifact( + [ + { + "candidate_id": "dqn", + "feature_set_id": "full", + "passed": True, + "comparison_status": "compared_to_full", + "evaluation_source": "trained_feature_mask_candidate", + "delta_vs_full_oos_pct": 0.0, + }, + { + "candidate_id": "dqn", + "feature_set_id": "no_participant", + "passed": True, + "comparison_status": "not_applicable_feature_absent", + "applicable": False, + "unavailable_feature_groups": ["participant"], + "evaluation_source": "trained_feature_mask_candidate", + }, + {"candidate_id": "dqn", "feature_set_id": "no_orderbook", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0}, + {"candidate_id": "dqn", "feature_set_id": "no_overheat", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0}, + {"candidate_id": "dqn", "feature_set_id": "minimal_price_volume", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0}, + {"candidate_id": "dqn", "feature_set_id": "shuffled_participant_context", "passed": True, "evaluation_source": "trained_feature_mask_candidate", "comparison_status": "compared_to_full", "delta_vs_full_oos_pct": -1.0, "shuffled_feature_names": ["participant_pressure_score"]}, + ], + split_hash="split123", + candidate_id="dqn", + ) + + row = next(row for row in artifact["ablations"] if row["feature_set_id"] == "no_participant_pressure") + assert row["applicable"] is False + assert row["comparison_status"] == "not_applicable_feature_absent" + assert artifact["feature_ablation_passed"] is True + + +def test_feature_mask_details_identify_absent_participant_group(): + details = feature_mask_details(["ret_open", "book_imb_l1"], "no_participant") + + assert details["feature_set_id"] == "no_participant_pressure" + assert details["removed_feature_groups"] == ["participant_pressure"] + assert details["zeroed_feature_names"] == [] + assert details["unavailable_feature_groups"] == ["participant_pressure"] + + +def test_ablation_rows_without_comparison_metadata_do_not_pass(): + artifact = build_ablation_artifact( + [ + {"feature_set_id": "full", "passed": True}, + {"feature_set_id": "no_participant", "passed": True}, + {"feature_set_id": "no_orderbook", "passed": True}, + {"feature_set_id": "no_overheat", "passed": True}, + {"feature_set_id": "minimal_price_volume", "passed": True}, + ], + split_hash="split123", + ) + + assert artifact["feature_ablation_passed"] is False + assert all(row["passed"] is False for row in artifact["ablations"]) + + + + + + +def test_ablation_configs_use_canonical_execution_feature_sets(): + base = default_candidate_configs("split123", feature_set_id="full_context")[0] + configs = _ablation_configs([base], {"candidate_id": base.candidate_id}) + + assert tuple(config.feature_set_id for config in configs) == REQUIRED_ABLATIONS + + +def test_ablation_rows_compare_against_canonical_full_context(): + rows = ablation_rows( + {"candidate_id": "dqn"}, + { + "candidates": [ + {"candidate_id": "dqn_full", "feature_set_id": "full_context", "status": "trained", "oos_net_return_pct": 1.0, "model_path": "model.zip"}, + {"candidate_id": "dqn_no_ob", "feature_set_id": "no_orderbook_imbalance", "status": "trained", "oos_net_return_pct": 0.5, "model_path": "model.zip"}, + ] + }, + ) + + full = next(row for row in rows if row["feature_set_id"] == "full_context") + no_ob = next(row for row in rows if row["feature_set_id"] == "no_orderbook_imbalance") + assert full["delta_vs_full_oos_pct"] == 0.0 + assert no_ob["delta_vs_full_oos_pct"] == -0.5 + +def test_shuffled_participant_context_must_not_outperform_full_context(): + base = { + "candidate_id": "dqn", + "passed": True, + "evaluation_source": "trained_feature_mask_candidate", + "comparison_status": "compared_to_full", + } + rows = [ + {**base, "feature_set_id": "full_context", "oos_net_return_pct": 1.0, "delta_vs_full_oos_pct": 0.0}, + {**base, "feature_set_id": "no_participant_pressure", "oos_net_return_pct": 0.8, "delta_vs_full_oos_pct": -0.2, "zeroed_feature_names": ["participant_pressure_score"]}, + {**base, "feature_set_id": "no_orderbook_imbalance", "oos_net_return_pct": 0.8, "delta_vs_full_oos_pct": -0.2, "zeroed_feature_names": ["proxy_available_bid_depth_imbalance"]}, + {**base, "feature_set_id": "no_orderbook_persistence", "oos_net_return_pct": 0.8, "delta_vs_full_oos_pct": -0.2, "zeroed_feature_names": ["orderbook_persistence_score"]}, + {**base, "feature_set_id": "no_overheat_upper_wick", "oos_net_return_pct": 0.8, "delta_vs_full_oos_pct": -0.2, "zeroed_feature_names": ["overheat_score"]}, + {**base, "feature_set_id": "minimal_price_volume", "oos_net_return_pct": 0.8, "delta_vs_full_oos_pct": -0.2, "zeroed_feature_names": ["participant_pressure_score", "orderbook_persistence_score"]}, + {**base, "feature_set_id": "shuffled_participant_context", "oos_net_return_pct": 1.2, "delta_vs_full_oos_pct": 0.2, "shuffled_feature_names": ["participant_pressure_score"]}, + ] + + artifact = build_ablation_artifact(rows, split_hash="split123", candidate_id="dqn") + + shuffled = next(row for row in artifact["ablations"] if row["feature_set_id"] == "shuffled_participant_context") + assert shuffled["passed"] is False + assert artifact["feature_ablation_passed"] is False diff --git a/tests/test_stom_rl_opening_candidate_config.py b/tests/test_stom_rl_opening_candidate_config.py new file mode 100644 index 000000000..1c719349b --- /dev/null +++ b/tests/test_stom_rl_opening_candidate_config.py @@ -0,0 +1,37 @@ +from stom_rl.opening_30m_rl_candidates import ( + CandidateConfig, + CandidateConfigError, + CandidateAlgorithm, + candidate_config_artifact, + default_candidate_configs, + validate_candidate_config, +) + + +def test_candidate_config_registry_has_dqn_and_ppo_defaults(): + configs = default_candidate_configs("split123") + artifact = candidate_config_artifact(configs) + + algorithms = {row["algorithm"] for row in artifact["candidates"]} + assert algorithms == {"dqn", "ppo"} + assert artifact["candidates"][0]["split_hash"] == "split123" + assert artifact["candidates"][0]["cost_bps"] == 23.0 + + +def test_candidate_config_rejects_oos_tuning(): + config = CandidateConfig( + "bad", + CandidateAlgorithm.DQN, + 100, + 64, + "split123", + "full", + use_oos_for_selection=True, + ) + + try: + validate_candidate_config(config) + except CandidateConfigError as exc: + assert "OOS" in str(exc) + else: + raise AssertionError("OOS tuning should fail") diff --git a/tests/test_stom_rl_opening_candidate_controls.py b/tests/test_stom_rl_opening_candidate_controls.py new file mode 100644 index 000000000..65a4c1c2a --- /dev/null +++ b/tests/test_stom_rl_opening_candidate_controls.py @@ -0,0 +1,52 @@ +from stom_rl.opening_30m_rl_candidate_gate import build_control_artifact + + +def test_candidate_controls_use_same_split_and_23bp_cost(): + artifact = build_control_artifact( + [ + {"control_type": "no_trade", "verdict": "NO-GO", "evaluation_source": "baseline_same_split"}, + {"control_type": "buy_and_hold", "verdict": "NO-GO", "evaluation_source": "baseline_same_split"}, + {"control_type": "ts_imb_rule", "verdict": "NO-GO", "evaluation_source": "baseline_same_split"}, + {"control_type": "random_policy", "verdict": "NO-GO", "evaluation_source": "policy_eval_oos", "eval_log_path": "random.json"}, + {"control_type": "label_shuffle", "verdict": "NO-GO", "evaluation_source": "label_shuffle_eval_oos", "eval_log_path": "label.json"}, + { + "control_type": "time_session_shuffle", + "verdict": "NO-GO", + "evaluation_source": "time_session_shuffle_eval_oos", + "eval_log_path": "time.json", + }, + ], + split_hash="split123", + cost_bps=23.0, + ) + + assert artifact["negative_control_passed"] is True + assert {row["split_hash"] for row in artifact["controls"]} == {"split123"} + assert {row["cost_bps"] for row in artifact["controls"]} == {23.0} + + +def test_missing_or_failed_shuffle_controls_block_candidate(): + artifact = build_control_artifact( + [{"control_type": "label_shuffle", "verdict": "GO_CANDIDATE"}], + split_hash="split123", + ) + + assert artifact["negative_control_passed"] is False + assert artifact["verdict"] == "INCONCLUSIVE" + + +def test_proxy_generated_control_rows_do_not_pass(): + artifact = build_control_artifact( + [ + { + "control_type": "random_policy", + "verdict": "NO-GO", + "evaluation_source": "same_split_baseline_or_shuffle_proxy", + } + ], + split_hash="split123", + ) + + random_policy = next(row for row in artifact["controls"] if row["control_type"] == "random_policy") + assert random_policy["passed"] is False + assert artifact["negative_control_passed"] is False diff --git a/tests/test_stom_rl_opening_candidate_dashboard_api.py b/tests/test_stom_rl_opening_candidate_dashboard_api.py new file mode 100644 index 000000000..0cfbb2965 --- /dev/null +++ b/tests/test_stom_rl_opening_candidate_dashboard_api.py @@ -0,0 +1,113 @@ +import json +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from webui import rl_dashboard # noqa: E402 + + +def _write_candidate_run(root: Path) -> None: + run = root / "opening_30m_rl_oos_candidate_smoke" + run.mkdir() + payload = { + "artifact_type": "opening_30m_rl_workflow", + "run_id": "opening_30m_rl_oos_candidate_smoke", + "verdict": "INCONCLUSIVE", + "candidate_verdict": "INCONCLUSIVE", + "config": {"cost_bps": 23.0, "time_start": "090000", "time_end": "093000"}, + "guardrails": {"baseline": "ts_imb RULE baseline"}, + "candidate_history": [{"candidate_id": "dqn_default_seed100", "algorithm": "dqn", "split_hash": "split123"}], + "candidate_lifecycle": { + "split_manifest": {"split_hash": "split123", "split_sessions": {"train": ["20250102"], "validation": ["20250103"], "oos": ["20250106"]}}, + "context_features": {"feature_names": ["participant_pressure_score"], "status": "available", "sample": {"decision_second": 3, "vector": [0.5]}}, + "training": {"candidates": [{"candidate_id": "dqn_default_seed100", "algorithm": "dqn", "status": "skipped_sb3_unavailable", "split_hash": "split123"}]}, + "controls": {"controls": [{"control_type": "label_shuffle", "verdict": "NO-GO", "split_hash": "split123", "cost_bps": 23.0}]}, + "ablations": {"ablations": [{"feature_set_id": "no_orderbook_imbalance", "available": True, "passed": False, "split_hash": "split123"}]}, + "promotion_gate": { + "verdict": "INCONCLUSIVE", + "blocking_reasons": ["missing_or_failed_controls"], + "equity_curve": [{"step": 0, "net_return_pct": 0.0}, {"step": 1, "net_return_pct": -1.0}], + "time_bucket_performance": [{"bucket": "0900-0910", "trade_count": 1, "net_return_pct": -1.0}], + }, + }, + "stages": [{"name": "cost_gate", "status": "blocked"}, {"name": "feature_ablation", "status": "blocked"}], + } + (run / "opening_30m_rl_workflow_summary.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + + +def test_dashboard_api_exposes_candidate_lifecycle_tables(tmp_path, monkeypatch): + _write_candidate_run(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + detail = rl_dashboard.load_rl_run("opening_30m_rl_oos_candidate_smoke") + candidates = rl_dashboard.load_rl_table("opening_30m_rl_oos_candidate_smoke", "candidate_lifecycle", limit=10) + splits = rl_dashboard.load_rl_table("opening_30m_rl_oos_candidate_smoke", "candidate_splits", limit=10) + equity = rl_dashboard.load_rl_table("opening_30m_rl_oos_candidate_smoke", "candidate_equity_curve", limit=10) + buckets = rl_dashboard.load_rl_table("opening_30m_rl_oos_candidate_smoke", "candidate_time_buckets", limit=10) + controls = rl_dashboard.load_rl_table("opening_30m_rl_oos_candidate_smoke", "candidate_controls", limit=10) + ablations = rl_dashboard.load_rl_table("opening_30m_rl_oos_candidate_smoke", "candidate_ablations", limit=10) + reasons = rl_dashboard.load_rl_table("opening_30m_rl_oos_candidate_smoke", "candidate_failure_reasons", limit=10) + context = rl_dashboard.load_rl_table("opening_30m_rl_oos_candidate_smoke", "context_feature_sample", limit=10) + + assert detail["summary"]["candidate_count"] == 1 + assert detail["summary"]["split_hash"] == "split123" + assert detail["summary"]["blocked_stage_count"] == 2 + assert candidates["rows"][0]["candidate_id"] == "dqn_default_seed100" + assert {row["split"] for row in splits["rows"]} == {"train", "validation", "oos"} + assert equity["rows"][-1]["net_return_pct"] == -1.0 + assert buckets["rows"][0]["bucket"] == "0900-0910" + assert controls["rows"][0]["control_type"] == "label_shuffle" + assert ablations["rows"][0]["feature_set_id"] == "no_orderbook_imbalance" + assert reasons["rows"][0]["reason"] == "missing_or_failed_controls" + assert context["rows"][0]["feature_name"] == "participant_pressure_score" + + +def test_candidate_dashboard_tables_reject_path_traversal(tmp_path, monkeypatch): + _write_candidate_run(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + try: + rl_dashboard.load_rl_table("../opening_30m_rl_oos_candidate_smoke", "candidate_lifecycle", limit=10) + except Exception as exc: + assert "Invalid" in str(exc) or "not found" in str(exc) + else: + raise AssertionError("path traversal should fail") + + +def test_dashboard_api_discovers_nested_candidate_run(tmp_path, monkeypatch): + category = tmp_path / "opening_30m_rl_oos_validation" + category.mkdir() + _write_candidate_run(category) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + runs = rl_dashboard.list_rl_runs(limit=10) + assert any(run["name"] == "opening_30m_rl_oos_candidate_smoke" for run in runs) + detail = rl_dashboard.load_rl_run("opening_30m_rl_oos_candidate_smoke") + assert detail["summary"]["split_hash"] == "split123" + + +def test_dashboard_api_skips_stale_parent_category_artifact(tmp_path, monkeypatch): + category = tmp_path / "opening_30m_rl_oos_validation" + category.mkdir() + stale_parent = { + "artifact_type": "opening_30m_rl_workflow", + "run_id": "opening_30m_rl_oos_validation", + "verdict": "INCONCLUSIVE", + "candidate_verdict": "INCONCLUSIVE", + "config": {"cost_bps": 23.0}, + "guardrails": {"baseline": "ts_imb RULE baseline"}, + "candidate_history": [], + "candidate_lifecycle": {}, + "stages": [], + } + (category / "opening_30m_rl_workflow_summary.json").write_text(json.dumps(stale_parent), encoding="utf-8") + _write_candidate_run(category) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + runs = rl_dashboard.list_rl_runs(limit=10) + + assert "opening_30m_rl_oos_validation" not in {run["name"] for run in runs} + assert any(run["name"] == "opening_30m_rl_oos_candidate_smoke" for run in runs) diff --git a/tests/test_stom_rl_opening_candidate_dashboard_tab.py b/tests/test_stom_rl_opening_candidate_dashboard_tab.py new file mode 100644 index 000000000..492916b98 --- /dev/null +++ b/tests/test_stom_rl_opening_candidate_dashboard_tab.py @@ -0,0 +1,20 @@ +from pathlib import Path + + +def test_rl_tab_renders_candidate_history_guardrails(): + source = Path("webui/v2_src/src/tabs/rlTrading/OpeningWorkflowCard.svelte").read_text(encoding="utf-8") + + assert "OPENING 30M RL CANDIDATES" in source + assert "OOS" in source + assert "negative controls" in source + assert "feature ablation" in source + assert "FEATURE ABLATION" in source + assert "OOS BASELINE" in source + assert "CONTEXT FEATURE SAMPLE" in source + assert "FAILURE REASONS" in source + assert "NO-GO" in source + assert "23bp" in source + assert "not live-ready" in source + assert "ts_imb RULE baseline" in source + assert "cumulative equity curve" in source + assert "time-bucket" in source diff --git a/tests/test_stom_rl_opening_candidate_gate.py b/tests/test_stom_rl_opening_candidate_gate.py new file mode 100644 index 000000000..b2ed9553c --- /dev/null +++ b/tests/test_stom_rl_opening_candidate_gate.py @@ -0,0 +1,86 @@ +from stom_rl.opening_30m_rl_candidate_gate import CandidateGateInput, evaluate_candidate_gate + + +def test_candidate_promotion_requires_oos_controls_ablation_and_baseline(): + passed = evaluate_candidate_gate( + CandidateGateInput( + candidate_id="dqn", + split_hash="split123", + validation_net_return_pct=1.0, + oos_net_return_pct=3.0, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.0, + ts_imb_rule_net_return_pct=2.0, + cost_bps=23.0, + controls_passed=True, + ablations_passed=True, + trade_count=4, + max_drawdown_pct=-1.0, + ) + ) + failed = evaluate_candidate_gate( + CandidateGateInput( + candidate_id="dqn", + split_hash="split123", + validation_net_return_pct=1.0, + oos_net_return_pct=1.0, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.0, + ts_imb_rule_net_return_pct=2.0, + cost_bps=23.0, + controls_passed=True, + ablations_passed=True, + trade_count=4, + max_drawdown_pct=-1.0, + ) + ) + + assert passed["verdict"] == "GO_CANDIDATE" + assert failed["verdict"] == "NO-GO_BASELINE" + assert "failed_baseline:ts_imb_rule" in failed["blocking_reasons"] + + +def test_candidate_gate_writes_equity_curve_and_time_bucket_metrics(): + artifact = evaluate_candidate_gate( + CandidateGateInput( + candidate_id="ppo", + split_hash="split123", + validation_net_return_pct=2.0, + oos_net_return_pct=3.0, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.0, + ts_imb_rule_net_return_pct=2.0, + cost_bps=23.0, + controls_passed=True, + ablations_passed=True, + trade_count=3, + max_drawdown_pct=-0.5, + ) + ) + + assert artifact["equity_curve"][-1]["net_return_pct"] == 3.0 + assert artifact["time_bucket_performance"][0]["trade_count"] == 3 + assert artifact["metrics"]["oos_validation_gap_pct"] == 1.0 + + +def test_candidate_promotion_blocks_unacceptable_drawdown(): + artifact = evaluate_candidate_gate( + CandidateGateInput( + candidate_id="dqn", + split_hash="split123", + validation_net_return_pct=1.0, + oos_net_return_pct=3.0, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.0, + ts_imb_rule_net_return_pct=2.0, + cost_bps=23.0, + controls_passed=True, + ablations_passed=True, + trade_count=4, + max_drawdown_pct=-20.0, + max_drawdown_limit_pct=-10.0, + ) + ) + + assert artifact["verdict"] == "NO-GO_BASELINE" + assert "failed_mdd" in artifact["blocking_reasons"] diff --git a/tests/test_stom_rl_opening_candidate_training.py b/tests/test_stom_rl_opening_candidate_training.py new file mode 100644 index 000000000..1d2d13f4a --- /dev/null +++ b/tests/test_stom_rl_opening_candidate_training.py @@ -0,0 +1,82 @@ +from stom_rl.opening_30m_rl_candidate_training import _make_env +from stom_rl.opening_30m_rl_candidates import default_candidate_configs, train_candidate_artifacts +from stom_rl.opening_30m_rl_context import OPENING_RL_CONTEXT_FEATURE_NAMES +from stom_rl.opening_30m_rl_feature_mask import feature_mask_details +from stom_rl.orderbook_sb3_adapter import OrderbookEpisode +from tests.fixtures.stom_opening_rl import opening_orderbook_frame + + +def test_candidate_training_does_not_fake_model_paths_when_sb3_exists(): + configs = default_candidate_configs("split123") + artifact = train_candidate_artifacts(configs, sb3_available=True, validation_score_by_candidate={"dqn_default_seed100": 1.2}) + + rows = artifact["candidates"] + assert artifact["selection_split"] == "validation" + assert artifact["oos_is_final_only"] is True + assert {row["algorithm"] for row in rows} == {"dqn", "ppo"} + assert {row["split_hash"] for row in rows} == {"split123"} + assert {row["status"] for row in rows} == {"not_trained"} + assert {row["model_path"] for row in rows} == {""} + + +def test_candidate_training_records_sb3_unavailable_without_fake_success(): + artifact = train_candidate_artifacts(default_candidate_configs("split123"), sb3_available=False) + + row = artifact["candidates"][0] + assert row["status"] == "skipped_sb3_unavailable" + assert row["model_path"] == "" + + +def test_candidate_training_env_appends_opening_context_features(): + config = default_candidate_configs("split123", feature_set_id="full_context")[0] + frame = opening_orderbook_frame(symbol="000250", session="20250103") + env = _make_env([OrderbookEpisode("000250_20250103", "000250", "20250103", frame)], config) + + observation, info = env.reset(seed=100) + + assert observation.shape[0] == len(info["feature_columns"]) + assert list(info["context_feature_names"]) == list(OPENING_RL_CONTEXT_FEATURE_NAMES) + assert list(info["feature_columns"][-len(OPENING_RL_CONTEXT_FEATURE_NAMES) :]) == list(OPENING_RL_CONTEXT_FEATURE_NAMES) + assert info["context_feature_status"] == "available" + +def test_candidate_training_ablation_zeroes_appended_context_features(): + config = default_candidate_configs("split123", feature_set_id="no_participant_pressure")[0] + frame = opening_orderbook_frame(symbol="000250", session="20250103") + env = _make_env([OrderbookEpisode("000250_20250103", "000250", "20250103", frame)], config) + + observation, info = env.reset(seed=100) + + feature_columns = list(info["feature_columns"]) + for name in ("participant_pressure_score", "proxy_available_trade_strength"): + assert name in info["zeroed_feature_names"] + assert observation[feature_columns.index(name)] == 0.0 + assert "participant_pressure" not in info["unavailable_feature_groups"] + + +def test_candidate_training_shuffled_context_is_not_zero_ablation(): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + episode = OrderbookEpisode("000250_20250103", "000250", "20250103", frame) + full_config = default_candidate_configs("split123", feature_set_id="full_context")[0] + shuffled_config = default_candidate_configs("split123", feature_set_id="shuffled_participant_context")[0] + full_env = _make_env([episode], full_config) + shuffled_env = _make_env([episode], shuffled_config) + + full_observation, _full_info = full_env.reset(seed=100) + shuffled_observation, shuffled_info = shuffled_env.reset(seed=100) + + feature_columns = list(shuffled_info["feature_columns"]) + participant_indices = [feature_columns.index("participant_pressure_score"), feature_columns.index("proxy_available_trade_strength")] + orderbook_index = feature_columns.index("orderbook_persistence_score") + assert shuffled_info["zeroed_feature_names"] == [] + assert shuffled_info["shuffled_feature_names"] == ["participant_pressure_score", "proxy_available_trade_strength"] + assert shuffled_observation[participant_indices].tolist() != full_observation[participant_indices].tolist() + assert shuffled_observation[orderbook_index] == full_observation[orderbook_index] + + +def test_feature_mask_details_include_context_feature_groups(): + details = feature_mask_details(OPENING_RL_CONTEXT_FEATURE_NAMES, "minimal_price_volume") + + assert "participant_pressure_score" in details["zeroed_feature_names"] + assert "orderbook_persistence_score" in details["zeroed_feature_names"] + assert "overheat_score" in details["zeroed_feature_names"] + assert "proxy_available_foreign_net_buy" in details["zeroed_feature_names"] diff --git a/tests/test_stom_rl_opening_controls.py b/tests/test_stom_rl_opening_controls.py new file mode 100644 index 000000000..77af99a08 --- /dev/null +++ b/tests/test_stom_rl_opening_controls.py @@ -0,0 +1,46 @@ +import json + +from stom_rl.opening_30m_rl_controls import apply_opening_negative_controls, write_opening_controls_artifact + + +def test_opening_negative_control_blocks_primary_go(tmp_path): + payload = write_opening_controls_artifact( + output_dir=tmp_path / "controls", + primary_verdict="GO_CANDIDATE", + controls=[ + {"control_type": "shuffled_participant_context", "verdict": "GO_CANDIDATE", "seed": 7}, + {"control_type": "random_policy", "verdict": "NO-GO", "seed": 7}, + ], + seed=7, + ) + + assert payload["artifact_type"] == "opening_30m_negative_controls" + assert payload["primary_verdict_before_controls"] == "GO_CANDIDATE" + assert payload["final_verdict"] == "NO-GO" + assert payload["negative_control_blocked_go"] is True + assert payload["blocked_reason"] == "negative_control_not_no_go" + assert payload["controls"][0]["seed"] == 7 + saved = json.loads((tmp_path / "controls" / "opening_negative_controls_summary.json").read_text(encoding="utf-8")) + assert saved == payload + + +def test_opening_negative_controls_preserve_candidate_when_all_no_go(): + payload = apply_opening_negative_controls( + primary_verdict="GO_CANDIDATE", + controls=[ + {"control_type": "shuffled_features", "verdict": "NO-GO", "seed": 11}, + {"control_type": "hold_policy", "verdict": "NO-GO", "seed": 11}, + {"control_type": "ts_imb_rule_baseline", "verdict": "NO-GO", "seed": 11}, + ], + seed=11, + ) + + assert payload["final_verdict"] == "GO_CANDIDATE" + assert payload["negative_control_passed"] is True + assert payload["negative_control_blocked_go"] is False + assert payload["blocked_reason"] == "" + assert {row["control_type"] for row in payload["controls"]} == { + "shuffled_features", + "hold_policy", + "ts_imb_rule_baseline", + } diff --git a/tests/test_stom_rl_opening_dashboard_api.py b/tests/test_stom_rl_opening_dashboard_api.py new file mode 100644 index 000000000..3ff24e588 --- /dev/null +++ b/tests/test_stom_rl_opening_dashboard_api.py @@ -0,0 +1,114 @@ +import json +import sys +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from webui import rl_dashboard # noqa: E402 +from webui.app import app as flask_app # noqa: E402 + + +def _write_csv(path: Path, header: str, rows: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join([header, *rows]) + "\n", encoding="utf-8-sig") + + +def _write_opening_workflow_fixture(root: Path) -> None: + run = root / "opening_workflow_run" + run.mkdir() + (run / "opening_30m_rl_workflow_summary.json").write_text( + json.dumps( + { + "artifact_type": "opening_30m_rl_workflow", + "mode": "opening_30m_rl_workflow", + "run_id": "opening_workflow_run", + "verdict": "NO-GO", + "config": {"cost_bps": 23.0, "time_start": "090000", "time_end": "093000"}, + "strategy_context": { + "label": "RL EXPERIMENT", + "is_live_ready": False, + "is_profit_model": False, + }, + "stages": [ + {"name": "contract", "status": "passed", "evidence": "contract"}, + {"name": "controls", "status": "passed", "evidence": "controls"}, + ], + "guardrails": {"baseline": "ts_imb RULE baseline"}, + } + ), + encoding="utf-8-sig", + ) + (run / "opening_negative_controls_summary.json").write_text( + json.dumps( + { + "artifact_type": "opening_30m_negative_controls", + "final_verdict": "NO-GO", + "negative_control_blocked_go": True, + "controls": [ + { + "control_type": "shuffled_participant_context", + "verdict": "GO_CANDIDATE", + "seed": 100, + } + ], + } + ), + encoding="utf-8-sig", + ) + _write_csv( + run / "opening_leaderboard.csv", + "run_id,baseline_policy,passes_cost_gate,decision", + ["opening_workflow_run,ts_imb_same_decision_tp5_sl1_time,False,NO-GO"], + ) + _write_csv(run / "actions.csv", "episode_id,policy_action_space", ["000250_20250103,fixed_entry_exit_only"]) + _write_csv(run / "trades.csv", "episode_id,trade_count,cost_model", ["000250_20250103,0,23bp_marketable_fill"]) + _write_csv(run / "episodes.csv", "episode_id,split", ["000250_20250103,eval"]) + + +def test_dashboard_loads_opening_workflow_run_read_only(tmp_path, monkeypatch): + _write_opening_workflow_fixture(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + detail = rl_dashboard.load_rl_run("opening_workflow_run") + client = flask_app.test_client() + response = client.get("/api/rl/runs/opening_workflow_run") + + assert response.status_code == 200 + payload = response.get_json() + assert detail["artifact_type"] == "opening_30m_rl_workflow" + assert payload["artifact_type"] == "opening_30m_rl_workflow" + assert payload["strategy_context"]["label"] == "RL EXPERIMENT" + assert payload["strategy_context"]["is_live_ready"] is False + assert payload["strategy_context"]["is_profit_model"] is False + assert payload["summary"]["cost_bps"] == 23.0 + + stages = rl_dashboard.load_rl_table("opening_workflow_run", "stages", limit=10) + controls = rl_dashboard.load_rl_table("opening_workflow_run", "controls", limit=10) + leaderboard = rl_dashboard.load_rl_table("opening_workflow_run", "leaderboard", limit=10) + actions = rl_dashboard.load_rl_table("opening_workflow_run", "actions", limit=10) + trades = rl_dashboard.load_rl_table("opening_workflow_run", "trades", limit=10) + episodes = rl_dashboard.load_rl_table("opening_workflow_run", "episodes", limit=10) + + assert stages["rows"][0]["name"] == "contract" + assert controls["rows"][0]["control_type"] == "shuffled_participant_context" + assert leaderboard["rows"][0]["baseline_policy"] == "ts_imb_same_decision_tp5_sl1_time" + assert actions["rows"][0]["episode_id"] == "000250_20250103" + assert trades["rows"][0]["cost_model"] == "23bp_marketable_fill" + assert episodes["rows"][0]["split"] == "eval" + + +def test_dashboard_rejects_opening_workflow_path_traversal(tmp_path, monkeypatch): + _write_opening_workflow_fixture(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + with pytest.raises(ValueError, match="direct child|Invalid"): + rl_dashboard.load_rl_run("../opening_workflow_run") + + client = flask_app.test_client() + response = client.get("/api/rl/runs/..%5Copening_workflow_run") + assert response.status_code in {400, 404} diff --git a/tests/test_stom_rl_opening_dashboard_tab.py b/tests/test_stom_rl_opening_dashboard_tab.py new file mode 100644 index 000000000..1cd8a16e6 --- /dev/null +++ b/tests/test_stom_rl_opening_dashboard_tab.py @@ -0,0 +1,55 @@ +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DASHBOARD_SRC = REPO_ROOT / "webui" / "v2_src" / "src" +RL_COMPONENT_DIR = DASHBOARD_SRC / "tabs" / "rlTrading" + + +def _opening_source_text() -> str: + files = [ + DASHBOARD_SRC / "lib" / "rlApi.ts", + DASHBOARD_SRC / "tabs" / "RLTradingTab.svelte", + ] + files.extend(sorted(RL_COMPONENT_DIR.glob("OpeningWorkflowCard.svelte"))) + return "\n".join(path.read_text(encoding="utf-8") for path in files if path.is_file()) + + +def test_rl_tab_contains_opening_workflow_guardrails(): + source = _opening_source_text() + + for marker in [ + "opening_30m_rl_workflow", + "OPENING 30M RL WORKFLOW", + "RL EXPERIMENT", + "NO-GO", + "not live-ready", + "23bp", + "ts_imb RULE baseline", + "CUMULATIVE REWARD EVIDENCE", + "data-rl-opening-workflow-card", + ]: + assert marker in source + for forbidden in ["Cumulative profit curve", "start training", "broker action", "live order"]: + assert forbidden not in source + + +def test_rl_tab_bundle_contains_opening_workflow_guardrails_when_built(): + dist = REPO_ROOT / "webui" / "static" / "v2" / "dist" + if not dist.is_dir(): + return + + bundle_text = "\n".join( + path.read_text(encoding="utf-8", errors="ignore") + for path in (dist / "assets").glob("index-*.js") + ) + for marker in [ + "OPENING 30M RL WORKFLOW", + "RL EXPERIMENT", + "NO-GO", + "not live-ready", + "23bp", + "ts_imb RULE baseline", + ]: + assert marker in bundle_text + assert "Cumulative profit curve" not in bundle_text diff --git a/tests/test_stom_rl_opening_env_contract.py b/tests/test_stom_rl_opening_env_contract.py new file mode 100644 index 000000000..3c3baead2 --- /dev/null +++ b/tests/test_stom_rl_opening_env_contract.py @@ -0,0 +1,50 @@ +import importlib + +from stom_rl.opening_30m_rl_workflow import ( + OpeningWorkflowConfig, + build_opening_env_contract_stage, + build_opening_workflow_manifest, + record_workflow_stage, +) +from stom_rl.orderbook_rl_env import ORDERBOOK_FEATURE_NAMES +from tests.fixtures.stom_opening_rl import build_opening_fixture_frames + + +def test_opening_env_contract_records_action_and_observation_space(): + result = build_opening_env_contract_stage( + build_opening_fixture_frames(), + fixed_entry_exit_only=True, + ) + + assert result["stage"] == "readiness_env" + assert result["environment"] == "StomOrderbookGymEnv" + assert result["observation_shape"] == [len(ORDERBOOK_FEATURE_NAMES)] + assert result["action_space"] == {"0": "hold", "1": "exit"} + assert result["constraint_mode"] == "fixed_entry_exit_only" + assert result["fixed_entry_exit_only"] is True + assert "check_env_passed" in result + assert result["check_env_status"] in {"passed", "skipped_sb3_unavailable"} + + manifest = build_opening_workflow_manifest(OpeningWorkflowConfig(run_id="env_contract_fixture")) + updated = record_workflow_stage(manifest, "readiness_env", result) + stage = next(item for item in updated["stages"] if item["name"] == "readiness_env") + assert stage["status"] == "complete" + assert stage["evidence"] == "env_contract" + assert updated["stage_results"]["readiness_env"] == result + + +def test_opening_env_contract_records_sb3_unavailable(monkeypatch): + original_import_module = importlib.import_module + + def fake_import_module(name): + if name == "stable_baselines3.common.env_checker": + raise ModuleNotFoundError(name) + return original_import_module(name) + + monkeypatch.setattr(importlib, "import_module", fake_import_module) + + result = build_opening_env_contract_stage(build_opening_fixture_frames()) + + assert result["check_env_passed"] is False + assert result["check_env_status"] == "skipped_sb3_unavailable" + assert result["check_env_message"] diff --git a/tests/test_stom_rl_opening_episode_manifest.py b/tests/test_stom_rl_opening_episode_manifest.py new file mode 100644 index 000000000..64c40eb43 --- /dev/null +++ b/tests/test_stom_rl_opening_episode_manifest.py @@ -0,0 +1,67 @@ +import json + +import pytest + +from stom_rl.opening_30m_rl_manifest import ( + OpeningEpisodeManifestConfig, + build_opening_episode_manifest, +) +from tests.fixtures.stom_opening_rl import build_opening_fixture_frames + + +def test_opening_manifest_writes_train_val_test_artifacts(tmp_path): + frames = build_opening_fixture_frames() + out_dir = tmp_path / "opening_manifest" + + payload = build_opening_episode_manifest( + frames, + OpeningEpisodeManifestConfig( + output_dir=out_dir, + split_sessions={ + "train": ("20250103",), + "val": ("20250106",), + "test": ("20250107",), + }, + ), + ) + + first = payload["episodes"][0] + assert payload["artifact_type"] == "opening_30m_episode_manifest" + assert payload["summary"]["episode_count"] == 3 + assert payload["summary"]["split_validation"]["overlap_count"] == 0 + assert payload["summary"]["split_validation"]["chronological_train_val_test"] is True + assert first["symbol"] == "000250" + assert first["session"] == "20250103" + assert first["episode_id"] == "000250_20250103" + assert first["split"] == "train" + assert first["time_start"] == "090000" + assert first["time_end"] == "093000" + assert first["row_count"] == 6 + assert first["quote_coverage"] == 1.0 + assert first["stage_evidence_json"].endswith("opening_episode_manifest_summary.json") + assert first["source_csv"].endswith("opening_episode_manifest.csv") + assert (out_dir / "opening_episode_manifest.json").is_file() + assert (out_dir / "opening_episode_manifest.csv").is_file() + assert (out_dir / "opening_episode_manifest_summary.json").is_file() + saved = json.loads((out_dir / "opening_episode_manifest.json").read_text(encoding="utf-8-sig")) + assert saved["episodes"][2]["symbol"] == "000660" + assert saved["episodes"][2]["quote_coverage"] < 1.0 + + +def test_opening_manifest_refuses_overlapping_sessions(tmp_path): + out_dir = tmp_path / "overlap_manifest" + + with pytest.raises(ValueError, match="overlap"): + build_opening_episode_manifest( + build_opening_fixture_frames(), + OpeningEpisodeManifestConfig( + output_dir=out_dir, + split_sessions={ + "train": ("20250103",), + "val": ("20250103",), + "test": ("20250107",), + }, + ), + ) + + assert not out_dir.exists() diff --git a/tests/test_stom_rl_opening_fixtures.py b/tests/test_stom_rl_opening_fixtures.py new file mode 100644 index 000000000..0b154c93b --- /dev/null +++ b/tests/test_stom_rl_opening_fixtures.py @@ -0,0 +1,46 @@ +import codecs + +import pandas as pd # noqa: PANDAS_OK - verifies pandas-compatible STOM fixture frames + +from tests.fixtures.stom_opening_rl import ( + build_opening_fixture_frames, + opening_orderbook_frame, + quote_coverage_report, + write_opening_fixture_csv, +) + + +def test_opening_fixture_preserves_codes_sessions_and_utf8(tmp_path): + frames = build_opening_fixture_frames() + combined = pd.concat(frames, ignore_index=True) + sessions = combined["session"].drop_duplicates().tolist() + output_path = tmp_path / "opening_fixture.csv" + + write_opening_fixture_csv(output_path, combined) + + raw = output_path.read_bytes() + loaded = pd.read_csv(output_path, dtype={"symbol": str, "session": str}, encoding="utf-8-sig") + assert raw.startswith(codecs.BOM_UTF8) + assert any(symbol.startswith("000") for symbol in loaded["symbol"].unique()) + assert sessions == sorted(sessions) + assert loaded["session"].drop_duplicates().tolist() == sessions + assert "체결강도" in loaded.columns + assert "매수총잔량" in loaded.columns + assert loaded["symbol"].iloc[0] == "000250" + + +def test_opening_fixture_can_model_missing_quote_coverage(): + full_quote = opening_orderbook_frame(symbol="000250", session="20250103") + missing_quote = opening_orderbook_frame( + symbol="000250", + session="20250106", + missing_quote=True, + ) + + full_report = quote_coverage_report(full_quote) + missing_report = quote_coverage_report(missing_quote) + assert full_report["quote_coverage"] == 1.0 + assert full_report["missing_quote_rows"] == 0 + assert 0.0 < missing_report["quote_coverage"] < 1.0 + assert missing_report["missing_quote_rows"] > 0 + assert set(full_quote.columns) == set(missing_quote.columns) diff --git a/tests/test_stom_rl_opening_leaderboard.py b/tests/test_stom_rl_opening_leaderboard.py new file mode 100644 index 000000000..16b7582d6 --- /dev/null +++ b/tests/test_stom_rl_opening_leaderboard.py @@ -0,0 +1,36 @@ +from stom_rl.opening_30m_rl_leaderboard import evaluate_opening_workflow_leaderboard_row + + +def test_opening_workflow_cost_gate_uses_23bp_and_negative_control_status(): + row = evaluate_opening_workflow_leaderboard_row( + run_id="opening_fixture", + rl_mean_return_pct=0.8, + baseline_delta_inputs={"no_trade": 0.0, "buy_and_hold": 0.7, "ts_imb_same_decision_tp5_sl1_time": 0.9}, + controls_payload={"final_verdict": "NO-GO", "negative_control_blocked_go": True}, + target_cost_bps=23.0, + trade_count=3, + max_drawdown_pct=-1.0, + ) + + assert row["target_cost_bps"] == 23.0 + assert row["negative_control_blocked_go"] is True + assert row["passes_cost_gate"] is False + assert row["decision"] == "NO-GO" + assert row["below_rule_baseline"] is True + + +def test_opening_leaderboard_keeps_underperforming_rl_below_rule_baseline(): + row = evaluate_opening_workflow_leaderboard_row( + run_id="opening_underperformer", + rl_mean_return_pct=0.2, + baseline_delta_inputs={"no_trade": 0.0, "buy_and_hold": 0.4, "ts_imb_same_decision_tp5_sl1_time": 0.6}, + controls_payload={"final_verdict": "GO_CANDIDATE", "negative_control_blocked_go": False}, + target_cost_bps=23.0, + trade_count=4, + max_drawdown_pct=-1.0, + ) + + assert row["baseline_policy"] == "ts_imb_same_decision_tp5_sl1_time" + assert row["baseline_delta_pct"] < 0.0 + assert row["passes_cost_gate"] is False + assert row["decision"] == "below_baseline" diff --git a/tests/test_stom_rl_opening_progress.py b/tests/test_stom_rl_opening_progress.py new file mode 100644 index 000000000..e6e6e55c4 --- /dev/null +++ b/tests/test_stom_rl_opening_progress.py @@ -0,0 +1,87 @@ +import json +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from webui import rl_dashboard # noqa: E402 +from webui.app import app as flask_app # noqa: E402 + + +def _write_opening_progress_run(root: Path, *, controls_status: str = "passed", cost_status: str = "passed") -> None: + run = root / "opening_progress_run" + run.mkdir() + stages = [ + {"name": "contract", "status": "passed", "evidence": "contract"}, + {"name": "manifest", "status": "passed", "evidence": "episodes/opening_episode_manifest_summary.json"}, + {"name": "readiness_env", "status": "passed", "evidence": "env_contract"}, + {"name": "baseline", "status": "passed", "evidence": "baseline/opening_baseline_summary.json"}, + {"name": "training", "status": "passed", "evidence": "training/opening_training_summary.json"}, + {"name": "evaluation", "status": "passed", "evidence": "summary.json"}, + {"name": "controls", "status": controls_status, "evidence": "opening_negative_controls_summary.json", "reason": "NO-GO"}, + {"name": "cost_gate", "status": cost_status, "evidence": "opening_leaderboard.csv", "reason": "below_baseline"}, + {"name": "dashboard", "status": "passed", "evidence": "dashboard"}, + ] + (run / "opening_30m_rl_workflow_summary.json").write_text( + json.dumps( + { + "artifact_type": "opening_30m_rl_workflow", + "mode": "opening_30m_rl_workflow", + "run_id": "opening_progress_run", + "verdict": "NO-GO" if controls_status != "passed" else "GO_CANDIDATE", + "config": {"cost_bps": 23.0, "time_start": "090000", "time_end": "093000"}, + "stages": stages, + }, + ensure_ascii=False, + ), + encoding="utf-8-sig", + ) + + +def _opening_page(progress: dict): + return next(page for page in progress["pages"] if page["page"] == "Opening 30M RL Workflow") + + +def test_progress_api_summarizes_opening_workflow_stages(tmp_path, monkeypatch): + _write_opening_progress_run(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + progress = rl_dashboard.load_rl_progress() + response = flask_app.test_client().get("/api/rl/progress") + page = _opening_page(progress) + labels = {row["label"] for row in page["criteria"]} + + assert response.status_code == 200 + assert { + "opening contract", + "opening manifest", + "opening env/readiness", + "opening baseline", + "opening training", + "opening evaluation", + "opening controls", + "opening cost gate", + "opening leaderboard", + "opening dashboard", + }.issubset(labels) + assert page["progress_pct"] == 100 + assert page["status"] == "complete" + + +def test_progress_api_marks_opening_workflow_incomplete_when_controls_fail(tmp_path, monkeypatch): + _write_opening_progress_run(tmp_path, controls_status="failed", cost_status="blocked") + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + page = _opening_page(rl_dashboard.load_rl_progress()) + controls = next(row for row in page["criteria"] if row["label"] == "opening controls") + cost_gate = next(row for row in page["criteria"] if row["label"] == "opening cost gate") + + assert page["progress_pct"] < 100 + assert page["status"] == "in_progress" + assert controls["passed"] is False + assert cost_gate["passed"] is False + assert "NO-GO" in controls["evidence"] + assert "below_baseline" in cost_gate["evidence"] diff --git a/tests/test_stom_rl_opening_realdata_adapter.py b/tests/test_stom_rl_opening_realdata_adapter.py new file mode 100644 index 000000000..75ec21ab8 --- /dev/null +++ b/tests/test_stom_rl_opening_realdata_adapter.py @@ -0,0 +1,150 @@ +import json +import sqlite3 +import subprocess +import sys + +import pytest + +from stom_rl.opening_30m_rl_realdata_adapter import ( + RealdataAdapterConfig, + RealdataNoGoDataError, + load_opening_realdata_frames, +) +from stom_rl.opening_30m_rl_runner import run_opening_workflow_stages +from stom_rl.opening_30m_rl_workflow import OpeningWorkflowConfig +from tests.fixtures.stom_opening_rl import opening_orderbook_frame + + +def _write_fixture_db(path): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + with sqlite3.connect(path) as conn: + frame.drop(columns=["symbol", "session"]).to_sql("000250", conn, index=False) + conn.execute('CREATE TABLE "ABC001" ("index" INTEGER)') + + +def test_realdata_adapter_feeds_opening_workflow_frames(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + output_dir = tmp_path / "adapter" + _write_fixture_db(db_path) + + result = load_opening_realdata_frames( + RealdataAdapterConfig( + db_path=db_path, + output_dir=output_dir, + max_tables=1, + max_sessions_per_table=1, + max_rows_per_session=6, + min_rows_per_session=4, + ) + ) + workflow = run_opening_workflow_stages( + result.frames, + OpeningWorkflowConfig(run_id="realdata_fixture", output_dir=tmp_path / "workflow", mode="realdata_smoke"), + ) + + assert result.frames[0]["symbol"].iloc[0] == "000250" + assert result.frames[0]["session"].iloc[0] == "20250103" + assert result.summary["sampled_tables"][0]["symbol"] == "000250" + assert result.summary["sampled_tables"][0]["sessions"][0]["row_count"] == 6 + assert result.summary["bounds"]["time_start"] == "090000" + assert workflow["artifact_type"] == "opening_30m_rl_workflow" + assert workflow["verdict"] in {"NO-GO_DATA", "PENDING_TRAINING", "PENDING"} + assert (output_dir / "realdata_adapter_summary.json").is_file() + + +def test_realdata_adapter_reports_no_go_data_for_missing_quotes(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + output_dir = tmp_path / "adapter_bad" + frame = opening_orderbook_frame(symbol="000250", session="20250103") + with sqlite3.connect(db_path) as conn: + frame.drop(columns=["symbol", "session", "매수호가1"]).to_sql("000250", conn, index=False) + + with pytest.raises(RealdataNoGoDataError, match="NO-GO_DATA"): + load_opening_realdata_frames( + RealdataAdapterConfig( + db_path=db_path, + output_dir=output_dir, + max_tables=1, + max_sessions_per_table=1, + max_rows_per_session=6, + min_rows_per_session=4, + ) + ) + + saved = json.loads((output_dir / "realdata_adapter_summary.json").read_text(encoding="utf-8-sig")) + assert saved["verdict"] == "NO-GO_DATA" + assert saved["sampled_tables"][0]["exclusion_reason"].startswith("missing required columns") + + +def test_realdata_cli_writes_bounded_smoke_summary(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + output_dir = tmp_path / "realdata_smoke" + _write_fixture_db(db_path) + + result = subprocess.run( + [ + sys.executable, + "-m", + "stom_rl.opening_30m_rl_realdata", + "--db", + str(db_path), + "--run-id", + "fixture_realdata_smoke", + "--output-dir", + str(output_dir), + "--max-tables", + "1", + "--max-sessions-per-table", + "1", + "--max-rows-per-session", + "6", + "--time-start", + "090000", + "--time-end", + "093000", + ], + check=False, + capture_output=True, + text=True, + ) + + summary_path = output_dir / "opening_30m_rl_workflow_summary.json" + assert result.returncode == 0, result.stderr + assert summary_path.is_file() + payload = json.loads(summary_path.read_text(encoding="utf-8")) + assert payload["artifact_type"] == "opening_30m_rl_workflow" + assert payload["verdict"] in {"NO-GO_DATA", "NO-GO", "INCONCLUSIVE", "GO_CANDIDATE"} + assert payload["config"]["cost_bps"] == 23.0 + assert payload["realdata"]["bounds"]["max_tables"] == 1 + assert payload["realdata"]["sampled_tables"][0]["symbol"] == "000250" + assert payload["realdata"]["training_status"] in { + "skipped_sb3_unavailable", + "skipped_training_not_requested", + "available_not_requested", + } + assert payload["realdata"]["model_status"] == "no_model_trained" + + +def test_realdata_cli_refuses_missing_db(tmp_path): + result = subprocess.run( + [ + sys.executable, + "-m", + "stom_rl.opening_30m_rl_realdata", + "--db", + str(tmp_path / "missing.db"), + "--run-id", + "missing_db", + "--output-dir", + str(tmp_path / "missing_out"), + "--max-tables", + "1", + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode != 0 + assert "SQLite DB not found" in result.stderr + assert not (tmp_path / "missing_out" / "opening_30m_rl_workflow_summary.json").exists() diff --git a/tests/test_stom_rl_opening_realdata_dashboard.py b/tests/test_stom_rl_opening_realdata_dashboard.py new file mode 100644 index 000000000..6afff82ad --- /dev/null +++ b/tests/test_stom_rl_opening_realdata_dashboard.py @@ -0,0 +1,97 @@ +import json +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from webui import rl_dashboard # noqa: E402 +from webui.app import app as flask_app # noqa: E402 + + +def _write_realdata_run(root: Path) -> None: + run = root / "opening_30m_rl_realdata_smoke" + run.mkdir() + payload = { + "artifact_type": "opening_30m_rl_workflow", + "mode": "opening_30m_rl_workflow", + "run_id": "opening_30m_rl_realdata_smoke", + "verdict": "INCONCLUSIVE", + "config": {"cost_bps": 23.0, "time_start": "090000", "time_end": "093000"}, + "strategy_context": { + "label": "RL EXPERIMENT", + "is_live_ready": False, + "is_profit_model": False, + }, + "guardrails": { + "not_live_ready": True, + "not_profit_model": True, + "baseline": "ts_imb RULE baseline", + "cost_bps": 23.0, + }, + "stages": [ + {"name": "contract", "status": "passed", "evidence": "contract"}, + {"name": "dashboard", "status": "blocked", "evidence": ""}, + ], + "realdata": { + "bounds": {"max_tables": 5, "time_start": "090000", "time_end": "093000"}, + "sampled_tables": [{"symbol": "000020", "leading_zero_preserved": True}], + "training_status": "available_not_requested", + "model_status": "no_model_trained", + "guardrail": "RL EXPERIMENT; bounded real-data smoke; not live-ready.", + }, + "realdata_validation_gate": { + "verdict": "INCONCLUSIVE", + "can_show_go_candidate": False, + "feature_ablation_results": { + "participant_pressure": {"passed": False, "required_for_go_candidate": True}, + "orderbook_persistence": {"passed": False, "required_for_go_candidate": True}, + }, + "blocking_reasons": ["missing_oos_split"], + }, + } + (run / "opening_30m_rl_workflow_summary.json").write_text( + json.dumps(payload, ensure_ascii=False), + encoding="utf-8", + ) + + +def test_dashboard_loads_realdata_opening_workflow_read_only(tmp_path, monkeypatch): + _write_realdata_run(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + detail = rl_dashboard.load_rl_run("opening_30m_rl_realdata_smoke") + feature_ablations = rl_dashboard.load_rl_table("opening_30m_rl_realdata_smoke", "feature_ablations", limit=10) + client = flask_app.test_client() + response = client.get("/api/rl/runs/opening_30m_rl_realdata_smoke") + + assert response.status_code == 200 + payload = response.get_json() + assert payload["artifact_type"] == "opening_30m_rl_workflow" + assert payload["summary"]["realdata_sampled_table_count"] == 1 + assert payload["summary"]["realdata_time_start"] == "090000" + assert payload["summary"]["model_status"] == "no_model_trained" + assert payload["summary"]["validation_verdict"] == "INCONCLUSIVE" + assert detail["detail"]["realdata"]["sampled_tables"][0]["symbol"] == "000020" + assert feature_ablations["rows"][0]["feature_ablation"] in { + "orderbook_persistence", + "participant_pressure", + } + + +def test_dashboard_realdata_copy_rejects_profit_live_claims(tmp_path, monkeypatch): + _write_realdata_run(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + payload = rl_dashboard.load_rl_run("opening_30m_rl_realdata_smoke") + encoded = json.dumps(payload, ensure_ascii=False).lower() + + assert payload["strategy_context"]["label"] == "RL EXPERIMENT" + assert payload["strategy_context"]["is_live_ready"] is False + assert payload["strategy_context"]["is_profit_model"] is False + assert payload["summary"]["cost_bps"] == 23.0 + assert payload["summary"]["baseline"] == "ts_imb RULE baseline" + assert "profitable" not in encoded + assert "broker-ready" not in encoded diff --git a/tests/test_stom_rl_opening_realdata_dataset.py b/tests/test_stom_rl_opening_realdata_dataset.py new file mode 100644 index 000000000..aaf70d6c6 --- /dev/null +++ b/tests/test_stom_rl_opening_realdata_dataset.py @@ -0,0 +1,40 @@ +from stom_rl.opening_30m_rl_dataset import build_dataset_artifact +from stom_rl.opening_30m_rl_oos_split import build_oos_split_manifest + + +def _split_manifest(): + return build_oos_split_manifest( + {"train": ["20250102"], "validation": ["20250103"], "oos": ["20250106"]} + ) + + +def test_dataset_artifact_records_features_splits_and_availability(): + artifact = build_dataset_artifact( + [ + {"symbol": "000020", "session_date": "20250102", "row_count": 30}, + {"symbol": "000040", "session_date": "20250106", "row_count": 40}, + ], + split_manifest=_split_manifest(), + features=["trade_strength", "orderbook_imbalance"], + participant_proxy_availability={"foreign_net_buy": False}, + orderbook_feature_availability={"매수호가1": True}, + ) + + assert artifact["row_count"] == 2 + assert artifact["rows"][0]["split"] == "train" + assert artifact["rows"][1]["split"] == "oos" + assert artifact["features"] == ["trade_strength", "orderbook_imbalance"] + + +def test_dataset_artifact_reports_missing_proxy_without_zero_fill(): + artifact = build_dataset_artifact( + [{"symbol": "000020", "session_date": "20250102", "row_count": 30}], + split_manifest=_split_manifest(), + features=["trade_strength"], + participant_proxy_availability={"foreign_net_buy": False}, + orderbook_feature_availability={"매수호가1": True}, + ) + + proxy = artifact["participant_proxy_availability"]["foreign_net_buy"] + assert proxy["available"] is False + assert proxy["filled_with_zero"] is False diff --git a/tests/test_stom_rl_opening_realdata_oos_cli.py b/tests/test_stom_rl_opening_realdata_oos_cli.py new file mode 100644 index 000000000..d7c8d8416 --- /dev/null +++ b/tests/test_stom_rl_opening_realdata_oos_cli.py @@ -0,0 +1,118 @@ +import json +import sqlite3 +from pathlib import Path + +from stom_rl.opening_30m_rl_oos_split import build_oos_split_manifest +from stom_rl.opening_30m_rl_candidate_gate import REQUIRED_ABLATIONS +from stom_rl.opening_30m_rl_candidate_diagnostics import oos_baseline_inputs +from stom_rl.opening_30m_rl_context import OPENING_RL_CONTEXT_FEATURE_NAMES +from stom_rl.opening_30m_rl_realdata_cli import main + + +def _empty_sqlite(path: Path) -> None: + conn = sqlite3.connect(path) + conn.close() + + +def test_oos_cli_writes_bounded_split_artifacts(tmp_path): + db_path = tmp_path / "empty.db" + _empty_sqlite(db_path) + output_dir = tmp_path / "run" + + exit_code = main( + [ + "--db", + str(db_path), + "--run-id", + "opening_30m_rl_oos_candidate_smoke", + "--output-dir", + str(output_dir), + "--max-tables", + "1", + "--max-sessions-per-table", + "1", + "--create-split", + "--candidate-algos", + "dqn,ppo", + "--tiny-train", + ] + ) + + run_dir = output_dir / "opening_30m_rl_oos_candidate_smoke" + lifecycle = json.loads((run_dir / "opening_candidate_lifecycle.json").read_text(encoding="utf-8")) + summary = json.loads((run_dir / "opening_30m_rl_workflow_summary.json").read_text(encoding="utf-8")) + assert exit_code == 0 + assert lifecycle["split_manifest"]["split_hash"] + assert lifecycle["promotion_gate"]["verdict"] in {"INCONCLUSIVE", "NO-GO_CONTROL", "NO-GO_ABLATION", "NO-GO_BASELINE"} + assert lifecycle["context_features"]["feature_names"] == list(OPENING_RL_CONTEXT_FEATURE_NAMES) + assert tuple(row["feature_set_id"] for row in lifecycle["ablations"]["ablations"]) == REQUIRED_ABLATIONS + assert summary["candidate_verdict"] == lifecycle["promotion_gate"]["verdict"] + assert summary["verdict"] == lifecycle["promotion_gate"]["verdict"] + + +def test_oos_cli_requires_existing_split_or_create_flag(tmp_path): + db_path = tmp_path / "empty.db" + _empty_sqlite(db_path) + + exit_code = main( + [ + "--db", + str(db_path), + "--run-id", + "missing_split", + "--output-dir", + str(tmp_path / "run"), + "--candidate-algos", + "dqn", + ] + ) + + assert exit_code == 2 + + +def test_oos_cli_uses_provided_split_manifest(tmp_path): + db_path = tmp_path / "empty.db" + _empty_sqlite(db_path) + split_path = tmp_path / "split.json" + manifest = build_oos_split_manifest( + {"train": ["20240102"], "validation": ["20240103"], "oos": ["20240104"]}, + output_path=split_path, + ) + + exit_code = main( + [ + "--db", + str(db_path), + "--run-id", + "custom_split", + "--output-dir", + str(tmp_path / "runs"), + "--candidate-algos", + "dqn", + "--split-manifest", + str(split_path), + ] + ) + + lifecycle = json.loads((tmp_path / "runs" / "custom_split" / "opening_candidate_lifecycle.json").read_text(encoding="utf-8")) + assert exit_code == 0 + assert lifecycle["split_manifest"]["split_hash"] == manifest["split_hash"] + assert lifecycle["dataset"]["split_hash"] == manifest["split_hash"] + + +def test_oos_baseline_inputs_filter_to_frozen_oos_sessions(tmp_path): + run_dir = tmp_path / "run" + baseline_dir = run_dir / "baseline" + baseline_dir.mkdir(parents=True) + manifest = build_oos_split_manifest({"train": ["20240102"], "validation": ["20240103"], "oos": ["20240104"]}) + rows = [ + {"session": "20240102", "policy": "buy_and_hold", "net_return_pct": 99.0}, + {"session": "20240104", "policy": "buy_and_hold", "net_return_pct": 1.5}, + {"session": "20240104", "policy": "no_trade", "net_return_pct": 0.0}, + {"session": "20240104", "policy": "ts_imb_same_decision_tp5_sl1_time", "net_return_pct": -0.25}, + ] + (baseline_dir / "opening_baseline_summary.json").write_text(json.dumps({"rows": rows}), encoding="utf-8") + + values = oos_baseline_inputs(run_dir, manifest["split_sessions"]["oos"], manifest["split_hash"]) + + assert values == {"no_trade": 0.0, "buy_and_hold": 1.5, "ts_imb_rule": -0.25} diff --git a/tests/test_stom_rl_opening_realdata_oos_split.py b/tests/test_stom_rl_opening_realdata_oos_split.py new file mode 100644 index 000000000..79d9a12c4 --- /dev/null +++ b/tests/test_stom_rl_opening_realdata_oos_split.py @@ -0,0 +1,46 @@ +from stom_rl.opening_30m_rl_oos_split import ( + OosSplitError, + build_oos_split_manifest, + validate_oos_split_manifest, +) + + +def test_oos_split_manifest_is_chronological_and_hashable(): + manifest = build_oos_split_manifest( + { + "train": ["20250102", "20250103"], + "validation": ["20250106"], + "oos": ["20250107"], + }, + symbol_sessions={"000020": ["20250102", "20250107"]}, + ) + + validate_oos_split_manifest(manifest) + + assert manifest["split_ranges"]["oos"]["start"] == "20250107" + assert manifest["symbol_session_counts"]["000020"] == 2 + assert isinstance(manifest["split_hash"], str) + + +def test_oos_split_rejects_overlap_and_row_level_shuffle(): + try: + build_oos_split_manifest( + { + "train": ["20250102", "20250106"], + "validation": ["20250106"], + "oos": ["20250107"], + } + ) + except OosSplitError as exc: + assert "overlaps" in str(exc) + else: + raise AssertionError("overlapping split should fail") + + try: + build_oos_split_manifest( + {"train": ["20250107"], "validation": ["20250106"], "oos": ["20250105"]} + ) + except OosSplitError as exc: + assert "chronological" in str(exc) + else: + raise AssertionError("non-chronological split should fail") diff --git a/tests/test_stom_rl_opening_realdata_sampler.py b/tests/test_stom_rl_opening_realdata_sampler.py new file mode 100644 index 000000000..96bf67ab9 --- /dev/null +++ b/tests/test_stom_rl_opening_realdata_sampler.py @@ -0,0 +1,74 @@ +import json +import sqlite3 + +import pytest + +from stom_rl.opening_30m_rl_realdata import ( + RealdataSamplerBoundsError, + RealdataSamplerConfig, + classify_staging_path, + sample_opening_realdata_readiness, +) +from tests.fixtures.stom_opening_rl import opening_orderbook_frame + + +def _write_fixture_db(path): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + with sqlite3.connect(path) as conn: + frame.drop(columns=["symbol", "session"]).to_sql("000250", conn, index=False) + conn.execute('CREATE TABLE "ABC001" ("index" INTEGER)') + + +def test_realdata_sampler_records_readonly_bounds_and_symbols(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + output_path = tmp_path / "readiness.json" + _write_fixture_db(db_path) + + payload = sample_opening_realdata_readiness( + RealdataSamplerConfig( + db_path=db_path, + output_path=output_path, + max_tables=2, + max_rows_per_table=3, + time_start="090000", + time_end="093000", + ) + ) + + saved = json.loads(output_path.read_text(encoding="utf-8-sig")) + assert payload["artifact_type"] == "opening_30m_realdata_readiness" + assert saved["sqlite_uri_mode"] == "ro" + assert saved["query_only"] is True + assert saved["bounds"]["max_tables"] == 2 + assert saved["bounds"]["max_rows_per_table"] == 3 + assert saved["bounds"]["time_start"] == "090000" + assert saved["bounds"]["time_end"] == "093000" + assert saved["sampled_tables"][0]["symbol"] == "000250" + assert isinstance(saved["sampled_tables"][0]["symbol"], str) + assert saved["sampled_tables"][0]["leading_zero_preserved"] is True + assert saved["sampled_tables"][0]["required_columns_available"] is True + assert saved["optional_participant_flow"]["foreign_net_buy"]["available"] is False + assert saved["optional_participant_flow"]["foreign_net_buy"]["filled_with_zero"] is False + + +def test_realdata_sampler_rejects_unbounded_scan(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + _write_fixture_db(db_path) + + with pytest.raises(RealdataSamplerBoundsError, match="max_tables"): + sample_opening_realdata_readiness( + RealdataSamplerConfig( + db_path=db_path, + output_path=tmp_path / "readiness.json", + max_tables=0, + max_rows_per_table=3, + ) + ) + + +def test_staging_policy_excludes_generated_paths(): + assert classify_staging_path(".omc/sessions/abc.json") == "exclude_from_commit" + assert classify_staging_path(".codegraph/index.db") == "exclude_from_commit" + assert classify_staging_path("_database/stock_tick_back.db") == "exclude_from_commit" + assert classify_staging_path("pkg/__pycache__/x.pyc") == "exclude_from_commit" + assert classify_staging_path("webui/static/v2/dist/index.html") == "frontend_dist" diff --git a/tests/test_stom_rl_opening_realdata_validation.py b/tests/test_stom_rl_opening_realdata_validation.py new file mode 100644 index 000000000..4d047917c --- /dev/null +++ b/tests/test_stom_rl_opening_realdata_validation.py @@ -0,0 +1,77 @@ +from stom_rl.opening_30m_rl_realdata_validation import ( + RealdataGateInput, + evaluate_realdata_candidate_gate, +) + + +def test_missing_oos_blocks_realdata_candidate(): + result = evaluate_realdata_candidate_gate( + RealdataGateInput( + candidate_net_return_pct=3.0, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.0, + ts_imb_rule_net_return_pct=2.0, + cost_bps=23.0, + has_oos_split=False, + has_negative_controls=True, + negative_controls_passed=True, + ablations={ + "participant_pressure": True, + "orderbook_persistence": True, + "overheat_penalty": True, + }, + ) + ) + + assert result["verdict"] == "INCONCLUSIVE" + assert "missing_oos_split" in result["blocking_reasons"] + assert result["can_show_go_candidate"] is False + + +def test_failed_ablation_blocks_go_candidate(): + result = evaluate_realdata_candidate_gate( + RealdataGateInput( + candidate_net_return_pct=4.0, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.0, + ts_imb_rule_net_return_pct=2.0, + cost_bps=23.0, + has_oos_split=True, + has_negative_controls=True, + negative_controls_passed=True, + ablations={ + "participant_pressure": True, + "orderbook_persistence": False, + "overheat_penalty": True, + }, + ) + ) + + assert result["verdict"] == "NO-GO" + assert result["feature_ablation_results"]["orderbook_persistence"]["passed"] is False + assert "failed_ablation:orderbook_persistence" in result["blocking_reasons"] + + +def test_candidate_must_beat_all_baselines_after_23bp(): + result = evaluate_realdata_candidate_gate( + RealdataGateInput( + candidate_net_return_pct=1.5, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.8, + ts_imb_rule_net_return_pct=1.2, + cost_bps=23.0, + has_oos_split=True, + has_negative_controls=True, + negative_controls_passed=True, + ablations={ + "participant_pressure": True, + "orderbook_persistence": True, + "overheat_penalty": True, + }, + ) + ) + + assert result["verdict"] == "NO-GO" + assert result["cost_bps"] == 23.0 + assert "failed_baseline:buy_and_hold" in result["blocking_reasons"] + assert result["baseline_results"]["ts_imb_rule"]["is_reinforcement_learning"] is False diff --git a/tests/test_stom_rl_opening_rule_filter_ablations.py b/tests/test_stom_rl_opening_rule_filter_ablations.py new file mode 100644 index 000000000..81b5af636 --- /dev/null +++ b/tests/test_stom_rl_opening_rule_filter_ablations.py @@ -0,0 +1,49 @@ +from stom_rl.opening_30m_rule_filter_ablations import build_rule_filter_ablation_artifact + + +def test_rule_filter_ablations_emit_required_rows(): + artifact = build_rule_filter_ablation_artifact( + full_context_return_pct=2.0, + ablation_returns={ + "no_participant_pressure": 1.0, + "no_orderbook_imbalance": 1.1, + "no_orderbook_persistence": 1.2, + "no_overheat_upper_wick": 1.3, + "no_time_bucket": 1.4, + "context_only": 1.5, + "tick_only": 0.8, + "shuffled_participant_context": 0.5, + }, + split_hash="split123", + ) + + ids = {row["feature_set_id"] for row in artifact["ablations"]} + assert "no_participant_pressure" in ids + assert "no_time_bucket" in ids + assert artifact["feature_ablation_passed"] is True + assert artifact["table_alias"] == "rule_filter_ablations" + + +def test_shuffled_context_outperformance_fails_ablation(): + artifact = build_rule_filter_ablation_artifact( + full_context_return_pct=2.0, + ablation_returns={"shuffled_participant_context": 3.0}, + split_hash="split123", + ) + + shuffled = next(row for row in artifact["ablations"] if row["feature_set_id"] == "shuffled_participant_context") + assert shuffled["shuffled_context"] is True + assert artifact["feature_ablation_passed"] is False + + +def test_missing_required_ablation_blocks_artifact(): + artifact = build_rule_filter_ablation_artifact( + full_context_return_pct=2.0, + ablation_returns={"shuffled_participant_context": 1.0}, + split_hash="split123", + ) + + missing = next(row for row in artifact["ablations"] if row["feature_set_id"] == "no_participant_pressure") + assert missing["comparison_status"] == "missing_required_ablation" + assert missing["passed"] is False + assert artifact["feature_ablation_passed"] is False diff --git a/tests/test_stom_rl_opening_rule_filter_artifacts.py b/tests/test_stom_rl_opening_rule_filter_artifacts.py new file mode 100644 index 000000000..dd0bce939 --- /dev/null +++ b/tests/test_stom_rl_opening_rule_filter_artifacts.py @@ -0,0 +1,38 @@ +import json + +from stom_rl.opening_30m_rule_filter_artifacts import write_rule_filter_artifacts +from stom_rl.opening_30m_rule_filter_gate import RuleFilterGateInput, evaluate_rule_filter_gate + + +def test_rule_filter_artifact_writer_includes_opportunity_cost(tmp_path): + gate = evaluate_rule_filter_gate( + RuleFilterGateInput( + split_hash="split123", + cost_bps=23.0, + validation_net_return_pct=2.0, + oos_net_return_pct=3.0, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.0, + ts_imb_rule_net_return_pct=1.5, + controls_passed=True, + ablations_passed=True, + oos_take_count=4, + max_drawdown_pct=0.5, + skipped_opportunity_cost_pct=0.2, + ) + ) + payload = write_rule_filter_artifacts( + output_dir=tmp_path, + split_manifest={"split_hash": "split123", "split_sessions": {"train": ["20250103"], "validation": ["20250106"], "oos": ["20250107"]}}, + policy={"policy_id": "p1", "actions_by_episode": {}}, + controls={"controls": []}, + ablations={"ablations": []}, + gate=gate, + dataset_rows=[], + ) + + assert (tmp_path / "opening_rule_filter_lifecycle.json").is_file() + assert (tmp_path / "opening_rule_filter_gate.json").is_file() + loaded = json.loads((tmp_path / "opening_rule_filter_lifecycle.json").read_text(encoding="utf-8")) + assert loaded["promotion_gate"]["opportunity_cost_curve"] + assert payload["strategy_context"]["is_live_ready"] is False diff --git a/tests/test_stom_rl_opening_rule_filter_cli.py b/tests/test_stom_rl_opening_rule_filter_cli.py new file mode 100644 index 000000000..61669000c --- /dev/null +++ b/tests/test_stom_rl_opening_rule_filter_cli.py @@ -0,0 +1,148 @@ +import json +import sqlite3 + +import pandas as pd +import pytest + +from stom_rl.opening_30m_rule_filter_cli import main +from tests.fixtures.stom_opening_rl import build_opening_fixture_frames, opening_orderbook_frame + + +def _write_db(path, *, extra_sessions=()): + frames = [ + *build_opening_fixture_frames(), + *(opening_orderbook_frame(symbol="000250", session=session) for session in extra_sessions), + ] + combined = pd.concat(frames, ignore_index=True).drop(columns=["symbol", "session"]) + with sqlite3.connect(path) as conn: + combined.to_sql("000250", conn, index=False) + + +def test_rule_filter_cli_writes_bounded_smoke_artifacts(tmp_path, capsys): + db = tmp_path / "ticks.sqlite" + out = tmp_path / "runs" + _write_db(db) + + rc = main([ + "--db", str(db), + "--output-dir", str(out), + "--run-id", "opening_30m_rule_filter_smoke", + "--create-split", + "--max-tables", "1", + "--max-sessions-per-table", "3", + "--min-rows-per-session", "4", + ]) + + stdout = json.loads(capsys.readouterr().out) + run_dir = out / "opening_30m_rule_filter_smoke" + assert rc == 0 + assert stdout["artifact_type"] == "opening_30m_rule_filter" + assert stdout["run_id"] == "opening_30m_rule_filter_smoke" + assert stdout["split_hash"] + summary = json.loads((run_dir / "opening_rule_filter_summary.json").read_text(encoding="utf-8")) + assert summary["feature_set_id"] == "full_context" + assert summary["baseline_semantics"]["artifact_ts_imb_rule"].startswith("RULE baseline") + assert (run_dir / "opening_rule_filter_lifecycle.json").is_file() + assert (run_dir / "opening_rule_filter_gate.json").is_file() + + +def test_rule_filter_cli_rejects_missing_split_manifest(tmp_path): + db = tmp_path / "ticks.sqlite" + _write_db(db) + + rc = main(["--db", str(db), "--output-dir", str(tmp_path / "runs"), "--split-manifest", str(tmp_path / "missing.json")]) + + assert rc == 2 + + +@pytest.mark.parametrize("run_id", ["../escape", "..\\escape", "/absolute", "C:\\escape", "..", "."]) +def test_rule_filter_cli_rejects_unsafe_run_id_before_writing(tmp_path, run_id): + db = tmp_path / "ticks.sqlite" + out = tmp_path / "runs" + _write_db(db) + + rc = main([ + "--db", str(db), + "--output-dir", str(out), + "--run-id", run_id, + "--create-split", + "--max-tables", "1", + "--max-sessions-per-table", "3", + "--min-rows-per-session", "4", + ]) + + assert rc == 2 + assert not (tmp_path / "escape").exists() + assert not (out / "absolute").exists() + + +def test_rule_filter_cli_rejects_existing_run_dir_before_writing(tmp_path): + db = tmp_path / "ticks.sqlite" + out = tmp_path / "runs" + run_dir = out / "opening_30m_rule_filter_smoke" + run_dir.mkdir(parents=True) + sentinel = run_dir / "sentinel.txt" + sentinel.write_text("do not overwrite", encoding="utf-8") + _write_db(db) + + rc = main([ + "--db", str(db), + "--output-dir", str(out), + "--run-id", "opening_30m_rule_filter_smoke", + "--create-split", + "--max-tables", "1", + "--max-sessions-per-table", "3", + "--min-rows-per-session", "4", + ]) + + assert rc == 2 + assert sentinel.read_text(encoding="utf-8") == "do not overwrite" + assert not (run_dir / "opening_rule_filter_summary.json").exists() + + +def test_rule_filter_cli_auto_split_covers_all_loaded_sessions(tmp_path, capsys): + db = tmp_path / "ticks.sqlite" + out = tmp_path / "runs" + _write_db(db, extra_sessions=("20250108",)) + + rc = main([ + "--db", str(db), + "--output-dir", str(out), + "--run-id", "opening_30m_rule_filter_smoke", + "--create-split", + "--max-tables", "1", + "--max-sessions-per-table", "4", + "--min-rows-per-session", "4", + ]) + + json.loads(capsys.readouterr().out) + manifest = json.loads((out / "opening_30m_rule_filter_smoke" / "opening_rule_filter_split_manifest.json").read_text(encoding="utf-8")) + split_sessions = manifest["split_sessions"] + all_sessions = [session for sessions in split_sessions.values() for session in sessions] + assert rc == 0 + assert all_sessions == ["20250103", "20250106", "20250107", "20250108"] + + +def test_rule_filter_cli_records_feature_set_id(tmp_path, capsys): + db = tmp_path / "ticks.sqlite" + out = tmp_path / "runs" + _write_db(db) + + rc = main([ + "--db", str(db), + "--output-dir", str(out), + "--run-id", "opening_30m_rule_filter_minimal", + "--create-split", + "--feature-set", "minimal_ts_imb", + "--max-tables", "1", + "--max-sessions-per-table", "3", + "--min-rows-per-session", "4", + ]) + + json.loads(capsys.readouterr().out) + summary = json.loads((out / "opening_30m_rule_filter_minimal" / "opening_rule_filter_summary.json").read_text(encoding="utf-8")) + policy = summary["rule_filter_lifecycle"]["policy"] + assert rc == 0 + assert summary["feature_set_id"] == "minimal_ts_imb" + assert summary["baseline_semantics"]["guardrail"] == "Do not report artifact baseline equality as independent outperformance." + assert policy["feature_set_id"] == "minimal_ts_imb" diff --git a/tests/test_stom_rl_opening_rule_filter_contract.py b/tests/test_stom_rl_opening_rule_filter_contract.py new file mode 100644 index 000000000..c70e71945 --- /dev/null +++ b/tests/test_stom_rl_opening_rule_filter_contract.py @@ -0,0 +1,23 @@ +from stom_rl.opening_30m_rule_filter_contract import ( + ACTION_SKIP, + ACTION_TAKE, + RULE_FILTER_TABLE_ALIASES, + VERDICT_GO_RULE_FILTER, + RuleFilterConfig, + rule_filter_strategy_context, +) + + +def test_rule_filter_contract_uses_rule_specific_labels(): + config = RuleFilterConfig() + context = rule_filter_strategy_context() + + assert ACTION_TAKE == "TAKE" + assert ACTION_SKIP == "SKIP" + assert VERDICT_GO_RULE_FILTER == "GO_RULE_FILTER" + assert config.cost_bps == 23.0 + assert config.min_oos_take_trades == 3 + assert config.primary_baseline == "ts_imb RULE baseline" + assert context["is_reinforcement_learning"] is False + assert context["is_live_ready"] is False + assert "rule_filter_opportunity_cost" in RULE_FILTER_TABLE_ALIASES diff --git a/tests/test_stom_rl_opening_rule_filter_controls.py b/tests/test_stom_rl_opening_rule_filter_controls.py new file mode 100644 index 000000000..de57f7337 --- /dev/null +++ b/tests/test_stom_rl_opening_rule_filter_controls.py @@ -0,0 +1,34 @@ +from stom_rl.opening_30m_rule_filter_contract import VERDICT_NO_GO_CONTROL +from stom_rl.opening_30m_rule_filter_controls import build_rule_filter_control_artifact + + +def test_rule_filter_controls_require_same_split_and_cost(): + artifact = build_rule_filter_control_artifact( + filter_oos_net_return_pct=2.0, + baseline_returns={"no_trade": 0.0, "buy_and_hold": 0.5, "ts_imb_rule": 1.0}, + split_hash="split123", + cost_bps=23.0, + shuffled_label_return_pct=-1.0, + time_session_shuffle_return_pct=-0.5, + randomized_feature_return_pct=-0.1, + ) + + assert artifact["negative_control_passed"] is True + assert {row["split_hash"] for row in artifact["controls"]} == {"split123"} + assert {row["cost_bps"] for row in artifact["controls"]} == {23.0} + assert artifact["table_alias"] == "rule_filter_controls" + + +def test_shuffled_control_blocks_rule_filter(): + artifact = build_rule_filter_control_artifact( + filter_oos_net_return_pct=2.0, + baseline_returns={"no_trade": 0.0, "buy_and_hold": 0.5, "ts_imb_rule": 1.0}, + split_hash="split123", + cost_bps=23.0, + shuffled_label_return_pct=3.0, + time_session_shuffle_return_pct=-0.5, + randomized_feature_return_pct=-0.1, + ) + + assert artifact["negative_control_passed"] is False + assert artifact["blocking_verdict"] == VERDICT_NO_GO_CONTROL diff --git a/tests/test_stom_rl_opening_rule_filter_dashboard.py b/tests/test_stom_rl_opening_rule_filter_dashboard.py new file mode 100644 index 000000000..0263b2649 --- /dev/null +++ b/tests/test_stom_rl_opening_rule_filter_dashboard.py @@ -0,0 +1,56 @@ +from stom_rl.opening_30m_rule_filter_artifacts import write_rule_filter_artifacts +from stom_rl.opening_30m_rule_filter_gate import RuleFilterGateInput, evaluate_rule_filter_gate +from webui.rl_dashboard_opening_tables import load_opening_json_table + + +def _write_run(path): + gate = evaluate_rule_filter_gate( + RuleFilterGateInput( + split_hash="split123", + cost_bps=23.0, + validation_net_return_pct=2.0, + oos_net_return_pct=3.0, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.0, + ts_imb_rule_net_return_pct=1.5, + controls_passed=True, + ablations_passed=True, + oos_take_count=4, + max_drawdown_pct=0.5, + skipped_opportunity_cost_pct=0.2, + ) + ) + write_rule_filter_artifacts( + output_dir=path, + split_manifest={"split_hash": "split123", "split_sessions": {"train": ["20250103"], "validation": ["20250106"], "oos": ["20250107"]}}, + policy={"policy_id": "p1", "actions_by_episode": {"a": "TAKE"}}, + controls={"controls": [{"control_type": "no_trade", "passed": True}]}, + ablations={"ablations": [{"feature_set_id": "no_participant_pressure", "passed": True}]}, + gate=gate, + dataset_rows=[{"episode_id": "a", "split": "oos", "symbol": "000250", "feature_values": {"participant_pressure_score": 0.8}}], + ) + + +def test_dashboard_loads_rule_filter_tables(tmp_path): + _write_run(tmp_path) + for table in [ + "rule_filter_lifecycle", + "rule_filter_splits", + "rule_filter_controls", + "rule_filter_ablations", + "rule_filter_equity_curve", + "rule_filter_time_buckets", + "rule_filter_failure_reasons", + "rule_filter_opportunity_cost", + "rule_filter_context_sample", + "rule_filter_proxy_availability", + "rule_filter_orderbook_persistence", + ]: + payload = load_opening_json_table("run", tmp_path, "opening_rule_filter", table, limit=100) + assert payload is not None, table + assert payload["table"] == table + + +def test_dashboard_unknown_rule_filter_table_returns_none(tmp_path): + _write_run(tmp_path) + assert load_opening_json_table("run", tmp_path, "opening_rule_filter", "rule_filter_missing", limit=100) is None diff --git a/tests/test_stom_rl_opening_rule_filter_dataset.py b/tests/test_stom_rl_opening_rule_filter_dataset.py new file mode 100644 index 000000000..11a9f6ee9 --- /dev/null +++ b/tests/test_stom_rl_opening_rule_filter_dataset.py @@ -0,0 +1,32 @@ +import pytest + +from stom_rl.opening_30m_rl_oos_split import OosSplitError, build_oos_split_manifest +from stom_rl.opening_30m_rule_filter_contract import ACTION_TAKE, RuleFilterConfig +from stom_rl.opening_30m_rule_filter_dataset import build_rule_filter_dataset +from tests.fixtures.stom_opening_rl import build_opening_fixture_frames + + +def _split(): + return build_oos_split_manifest({"train": ["20250103"], "validation": ["20250106"], "oos": ["20250107"]}) + + +def test_rule_filter_dataset_is_causal_and_preserves_symbols(): + frames = build_opening_fixture_frames() + split = _split() + original = build_rule_filter_dataset(frames, split_manifest=split, config=RuleFilterConfig(decision_second=2)) + changed = [frame.copy(deep=True) for frame in frames] + changed[0].loc[4:, "泥닿껐媛뺣룄"] = 1.0 + changed[0].loc[4:, "留ㅼ닔珥앹옍??"] = 1.0 + mutated = build_rule_filter_dataset(changed, split_manifest=split, config=RuleFilterConfig(decision_second=2)) + + assert original["rows"][0]["symbol"] == "000250" + assert original["rows"][0]["base_action"] == ACTION_TAKE + assert original["rows"][0]["feature_values"] == mutated["rows"][0]["feature_values"] + assert "skipped_opportunity_net_return_pct" in original["rows"][0] + assert original["split_hash"] == split["split_hash"] + + +def test_rule_filter_dataset_rejects_bad_split_hash(): + split = _split() | {"split_hash": "bad"} + with pytest.raises(OosSplitError): + build_rule_filter_dataset(build_opening_fixture_frames(), split_manifest=split, config=RuleFilterConfig()) diff --git a/tests/test_stom_rl_opening_rule_filter_gate.py b/tests/test_stom_rl_opening_rule_filter_gate.py new file mode 100644 index 000000000..7626e7183 --- /dev/null +++ b/tests/test_stom_rl_opening_rule_filter_gate.py @@ -0,0 +1,59 @@ +from stom_rl.opening_30m_rule_filter_contract import ( + VERDICT_GO_RULE_FILTER, + VERDICT_NO_GO_ABLATION, + VERDICT_NO_GO_BASELINE, + VERDICT_NO_GO_CONTROL, +) +from stom_rl.opening_30m_rule_filter_gate import RuleFilterGateInput, evaluate_rule_filter_gate + + +def _gate(**overrides): + base = dict( + split_hash="split123", + cost_bps=23.0, + validation_net_return_pct=2.0, + oos_net_return_pct=3.0, + no_trade_net_return_pct=0.0, + buy_and_hold_net_return_pct=1.0, + ts_imb_rule_net_return_pct=1.5, + controls_passed=True, + ablations_passed=True, + oos_take_count=4, + min_oos_take_count=3, + max_drawdown_pct=0.5, + max_allowed_drawdown_pct=5.0, + skipped_opportunity_cost_pct=0.2, + ) + base.update(overrides) + return RuleFilterGateInput(**base) + + +def test_rule_filter_gate_verdicts_are_explicit(): + assert evaluate_rule_filter_gate(_gate())["verdict"] == VERDICT_GO_RULE_FILTER + assert evaluate_rule_filter_gate(_gate(oos_net_return_pct=1.0))["verdict"] == VERDICT_NO_GO_BASELINE + assert evaluate_rule_filter_gate(_gate(controls_passed=False))["verdict"] == VERDICT_NO_GO_CONTROL + assert evaluate_rule_filter_gate(_gate(ablations_passed=False))["verdict"] == VERDICT_NO_GO_ABLATION + + +def test_rule_filter_gate_blocks_non_default_cost(): + artifact = evaluate_rule_filter_gate(_gate(cost_bps=0.0)) + + assert artifact["verdict"] == VERDICT_NO_GO_CONTROL + assert "failed_cost:expected_23bp" in artifact["blocking_reasons"] + + +def test_rule_filter_gate_writes_opportunity_cost_curve(): + artifact = evaluate_rule_filter_gate(_gate()) + assert artifact["equity_curve"][-1]["net_return_pct"] == 3.0 + assert artifact["opportunity_cost_curve"][-1]["skipped_opportunity_cost_pct"] == 0.2 + assert artifact["strategy_context"]["is_reinforcement_learning"] is False + + +def test_rule_filter_gate_blocks_insufficient_oos_trades_and_drawdown(): + low_trade = evaluate_rule_filter_gate(_gate(oos_take_count=1, min_oos_take_count=3)) + drawdown = evaluate_rule_filter_gate(_gate(max_drawdown_pct=6.0, max_allowed_drawdown_pct=5.0)) + + assert low_trade["verdict"] != VERDICT_GO_RULE_FILTER + assert "insufficient_oos_take_trades" in low_trade["blocking_reasons"] + assert drawdown["verdict"] != VERDICT_GO_RULE_FILTER + assert "failed_risk:max_drawdown" in drawdown["blocking_reasons"] diff --git a/tests/test_stom_rl_opening_rule_filter_policy.py b/tests/test_stom_rl_opening_rule_filter_policy.py new file mode 100644 index 000000000..c0e66b14d --- /dev/null +++ b/tests/test_stom_rl_opening_rule_filter_policy.py @@ -0,0 +1,89 @@ +from stom_rl.opening_30m_rule_filter_contract import ACTION_SKIP, ACTION_TAKE, RuleFilterConfig +from stom_rl.opening_30m_rule_filter_policy import select_rule_filter_policy + + +def _rows(): + base = { + "base_action": ACTION_TAKE, + "cost_bps": 23.0, + "split_hash": "split123", + "feature_values": {"participant_pressure_score": 0.8, "orderbook_persistence_score": 0.8, "overheat_score": 0.1, "time_bucket_0_10": 1.0}, + } + return [ + base | {"episode_id": "a", "session": "20250103", "split": "train", "base_net_return_pct": 1.0, "meta_label_take": 1}, + base | {"episode_id": "b", "session": "20250106", "split": "validation", "base_net_return_pct": 1.2, "meta_label_take": 1}, + base | {"episode_id": "c", "session": "20250107", "split": "oos", "base_net_return_pct": -9.0, "meta_label_take": 0, "feature_values": {"participant_pressure_score": 0.1, "orderbook_persistence_score": 0.1, "overheat_score": 0.9, "time_bucket_0_10": 1.0}}, + ] + + +def test_policy_selection_uses_validation_not_oos(): + selected = select_rule_filter_policy(_rows(), config=RuleFilterConfig(min_oos_take_trades=0), split_hash="split123") + changed = _rows() + changed[2]["base_net_return_pct"] = 99.0 + changed_selected = select_rule_filter_policy(changed, config=RuleFilterConfig(min_oos_take_trades=0), split_hash="split123") + + assert selected["selected_thresholds"] == changed_selected["selected_thresholds"] + assert selected["actions_by_episode"]["c"] == ACTION_SKIP + assert selected["strategy_context"]["is_reinforcement_learning"] is False + + +def test_policy_marks_empty_oos_take_set_inconclusive(): + rows = _rows() + rows[2]["base_action"] = ACTION_SKIP + selected = select_rule_filter_policy(rows, config=RuleFilterConfig(min_oos_take_trades=1), split_hash="split123") + + assert selected["oos_metrics"]["take_count"] == 0 + assert selected["verdict_hint"] == "INCONCLUSIVE" + + +def test_minimal_ts_imb_feature_set_ignores_context_scores(): + rows = _rows() + rows[1]["feature_values"] = { + "participant_pressure_score": 0.0, + "orderbook_persistence_score": 0.0, + "overheat_score": 1.0, + "time_bucket_0_10": 0.0, + } + rows[2]["feature_values"] = { + "participant_pressure_score": 1.0, + "orderbook_persistence_score": 1.0, + "overheat_score": 0.0, + "time_bucket_0_10": 1.0, + } + + selected = select_rule_filter_policy( + rows, + config=RuleFilterConfig(feature_set_id="minimal_ts_imb", min_oos_take_trades=0), + split_hash="split123", + ) + + assert selected["feature_set_id"] == "minimal_ts_imb" + assert selected["actions_by_episode"]["b"] == ACTION_TAKE + assert selected["actions_by_episode"]["c"] == ACTION_TAKE + + +def test_time_bucket_only_feature_set_ignores_participant_orderbook_and_overheat(): + rows = _rows() + rows[1]["base_net_return_pct"] = -1.2 + rows[1]["feature_values"] = { + "participant_pressure_score": 1.0, + "orderbook_persistence_score": 1.0, + "overheat_score": 0.0, + "time_bucket_0_10": 0.0, + } + rows[2]["feature_values"] = { + "participant_pressure_score": 0.0, + "orderbook_persistence_score": 0.0, + "overheat_score": 1.0, + "time_bucket_0_10": 1.0, + } + + selected = select_rule_filter_policy( + rows, + config=RuleFilterConfig(feature_set_id="time_bucket_only", min_oos_take_trades=0), + split_hash="split123", + ) + + assert selected["feature_set_id"] == "time_bucket_only" + assert selected["actions_by_episode"]["b"] == ACTION_SKIP + assert selected["actions_by_episode"]["c"] == ACTION_TAKE diff --git a/tests/test_stom_rl_opening_training.py b/tests/test_stom_rl_opening_training.py new file mode 100644 index 000000000..8c1fbd5a6 --- /dev/null +++ b/tests/test_stom_rl_opening_training.py @@ -0,0 +1,52 @@ +import json + +import pytest + +from stom_rl.opening_30m_rl_train import ( + OpeningTrainingConfig, + OpeningTrainingError, + run_opening_training_stage, +) +from tests.fixtures.stom_opening_rl import build_opening_fixture_frames + + +def test_opening_training_stage_runs_tiny_dqn_fixed_entry_exit(tmp_path): + frames = build_opening_fixture_frames() + + payload = run_opening_training_stage( + frames, + train_episode_ids=("000250_20250103", "005930_20250106"), + eval_episode_ids=("000660_20250107",), + config=OpeningTrainingConfig(output_dir=tmp_path / "train", total_timesteps=8, seed=123), + ) + + assert payload["artifact_type"] == "opening_30m_training_stage" + assert payload["status"] in {"passed", "skipped_sb3_unavailable"} + assert payload["algorithm"] == "DQN" + assert payload["fixed_entry_exit_only"] is True + assert payload["seed"] == 123 + assert payload["train_episode_count"] == 2 + assert payload["eval_episode_count"] == 1 + assert set(payload["train_episode_ids"]).isdisjoint(payload["eval_episode_ids"]) + assert payload["strategy_context"]["is_live_ready"] is False + assert payload["strategy_context"]["is_profit_model"] is False + assert "not live-ready" in payload["safety_note"] + saved = json.loads((tmp_path / "train" / "opening_training_summary.json").read_text(encoding="utf-8")) + if payload["status"] == "passed": + assert (tmp_path / "train" / "dqn_model.zip").is_file() + assert saved["model_files"]["dqn"].endswith("dqn_model.zip") + else: + assert payload["sb3_status"] == "skipped_sb3_unavailable" + assert saved["model_files"] == {} + + +def test_opening_training_rejects_overlapping_train_eval_episode_ids(tmp_path): + with pytest.raises(OpeningTrainingError, match="overlap"): + run_opening_training_stage( + build_opening_fixture_frames(), + train_episode_ids=("000250_20250103",), + eval_episode_ids=("000250_20250103",), + config=OpeningTrainingConfig(output_dir=tmp_path / "overlap", total_timesteps=1), + ) + + assert not (tmp_path / "overlap").exists() diff --git a/tests/test_stom_rl_opening_workflow_contract.py b/tests/test_stom_rl_opening_workflow_contract.py new file mode 100644 index 000000000..d97316f54 --- /dev/null +++ b/tests/test_stom_rl_opening_workflow_contract.py @@ -0,0 +1,83 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +from stom_rl.opening_30m_rl_workflow import ( + OpeningWorkflowConfig, + UnsafeWorkflowClaimError, + build_opening_workflow_manifest, + write_opening_workflow_manifest, +) + + +def test_opening_workflow_contract_writes_minimal_manifest(tmp_path): + output_dir = tmp_path / "run" + + payload = write_opening_workflow_manifest( + OpeningWorkflowConfig(run_id="fixture_opening_run", output_dir=output_dir) + ) + + summary_path = output_dir / "opening_30m_rl_workflow_summary.json" + assert summary_path.is_file() + saved = json.loads(summary_path.read_text(encoding="utf-8-sig")) + assert saved == payload + assert payload["artifact_type"] == "opening_30m_rl_workflow" + assert payload["mode"] == "opening_30m_rl_workflow" + assert payload["run_id"] == "fixture_opening_run" + assert payload["config"]["cost_bps"] == 23.0 + assert payload["config"]["time_start"] == "090000" + assert payload["config"]["time_end"] == "093000" + assert payload["strategy_context"]["label"] == "RL EXPERIMENT" + assert payload["strategy_context"]["is_live_ready"] is False + assert payload["strategy_context"]["is_profit_model"] is False + assert payload["guardrails"]["not_live_ready"] is True + assert payload["guardrails"]["not_profit_model"] is True + assert payload["stages"][0]["name"] == "contract" + assert payload["stages"][0]["status"] == "complete" + + +def test_opening_workflow_rejects_live_ready_or_profit_flags(tmp_path): + with pytest.raises(UnsafeWorkflowClaimError): + build_opening_workflow_manifest( + OpeningWorkflowConfig( + output_dir=tmp_path / "live", + is_live_ready=True, + ) + ) + + with pytest.raises(UnsafeWorkflowClaimError): + build_opening_workflow_manifest( + OpeningWorkflowConfig( + output_dir=tmp_path / "profit", + is_profit_model=True, + ) + ) + + +def test_opening_workflow_cli_no_write_does_not_touch_output_dir(tmp_path): + output_dir = tmp_path / "cli-run" + + result = subprocess.run( + [ + sys.executable, + "-m", + "stom_rl.opening_30m_rl_workflow", + "--run-id", + "cli_fixture", + "--output-dir", + str(output_dir), + "--no-write", + ], + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["run_id"] == "cli_fixture" + assert payload["artifacts"]["summary_json"] == str(output_dir / "opening_30m_rl_workflow_summary.json") + assert not output_dir.exists() diff --git a/tests/test_stom_rl_opening_workflow_e2e.py b/tests/test_stom_rl_opening_workflow_e2e.py new file mode 100644 index 000000000..2a2070372 --- /dev/null +++ b/tests/test_stom_rl_opening_workflow_e2e.py @@ -0,0 +1,160 @@ +import csv +import json +from pathlib import Path + +from stom_rl.market_participant_studies import build_market_participant_studies +from stom_rl.opening_30m_rl_artifacts import write_opening_eval_artifacts +from stom_rl.opening_30m_rl_baselines import OpeningBaselineConfig, evaluate_opening_baselines +from stom_rl.opening_30m_rl_controls import write_opening_controls_artifact +from stom_rl.opening_30m_rl_leaderboard import evaluate_opening_workflow_leaderboard_row +from stom_rl.opening_30m_rl_train import OpeningTrainingConfig, run_opening_training_stage +from stom_rl.opening_30m_rl_workflow import OpeningWorkflowConfig, record_workflow_stage +from stom_rl.opening_30m_rl_runner import run_opening_workflow_stages +from stom_rl.orderbook_persistence import write_orderbook_persistence_artifact +from stom_rl.participant_pressure_features import build_participant_pressure_readiness +from tests.fixtures.stom_opening_rl import build_opening_fixture_frames +from webui import rl_dashboard +from webui.app import app as flask_app + + +def _episode_ids() -> tuple[tuple[str, ...], tuple[str, ...]]: + return ("000250_20250103", "005930_20250106"), ("000660_20250107",) + + +def _mean_training_reward(training_payload): + rows = training_payload.get("evaluation", []) + if not rows: + return -1.0, 0 + rewards = [float(row.get("reward", 0.0)) for row in rows] + trades = sum(int(row.get("trade_count", 0)) for row in rows) + return sum(rewards) / len(rewards), trades + + +def _write_leaderboard(path: Path, row) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fields = [ + "run_id", + "baseline_policy", + "target_cost_bps", + "passes_cost_gate", + "decision", + "baseline_delta_pct", + "trade_count", + ] + with path.open("w", encoding="utf-8-sig", newline="") as handle: + writer = csv.DictWriter(handle, fieldnames=fields, extrasaction="ignore") + writer.writeheader() + writer.writerow(row) + + +def _write_workflow_summary(path: Path, payload) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def test_opening_workflow_fixture_e2e_reaches_dashboard_evidence(tmp_path, monkeypatch): + frames = build_opening_fixture_frames() + output_dir = tmp_path / "opening_e2e_run" + train_episode_ids, eval_episode_ids = _episode_ids() + + workflow = run_opening_workflow_stages( + frames, + OpeningWorkflowConfig(run_id="opening_e2e_run", output_dir=output_dir), + request_training=True, + ) + training = run_opening_training_stage( + frames, + train_episode_ids=train_episode_ids, + eval_episode_ids=eval_episode_ids, + config=OpeningTrainingConfig(output_dir=output_dir / "training", total_timesteps=8, seed=404), + ) + baseline = evaluate_opening_baselines(frames, OpeningBaselineConfig(cost_bps=23.0)) + eval_artifacts = write_opening_eval_artifacts( + output_dir=output_dir, + training_payload=training, + baseline_payload=baseline, + source_manifest_path=output_dir / "episodes" / "opening_episode_manifest_summary.json", + seed=404, + cost_bps=23.0, + ) + controls = write_opening_controls_artifact( + output_dir=output_dir, + primary_verdict="GO_CANDIDATE", + controls=({"control_type": "shuffled_participant_context", "verdict": "GO_CANDIDATE", "metric": "reward"},), + seed=404, + ) + build_participant_pressure_readiness(frames[0], output_dir=output_dir, decision_second=3) + build_market_participant_studies(frames, output_dir=output_dir, decision_second=3) + write_orderbook_persistence_artifact(frames[0], output_dir=output_dir, decision_second=3) + rl_mean_reward, trade_count = _mean_training_reward(training) + leaderboard_row = evaluate_opening_workflow_leaderboard_row( + run_id="opening_e2e_run", + rl_mean_return_pct=rl_mean_reward, + baseline_delta_inputs=baseline["summary"]["baseline_delta_inputs"], + controls_payload=controls, + trade_count=trade_count, + max_drawdown_pct=-1.0, + ) + leaderboard_path = output_dir / "opening_leaderboard.csv" + _write_leaderboard(leaderboard_path, leaderboard_row) + + workflow["feature_ablation_results"] = { + "no_participant_pressure": {"verdict": "NO-GO", "baseline_delta_pct": -0.25} + } + workflow = record_workflow_stage( + workflow, + "training", + {"status": training["status"], "evidence": training["artifacts"]["summary_json"]}, + ) + workflow = record_workflow_stage( + workflow, + "evaluation", + {"status": "passed", "evidence": eval_artifacts["artifacts"]["summary_json"]}, + ) + workflow = record_workflow_stage( + workflow, + "controls", + {"status": "passed", "evidence": controls["artifacts"]["summary_json"]}, + ) + workflow = record_workflow_stage( + workflow, + "cost_gate", + {"status": "passed", "evidence": str(leaderboard_path)}, + ) + workflow = record_workflow_stage( + workflow, + "dashboard", + {"status": "passed", "evidence": "dashboard loader/API/table smoke"}, + ) + workflow["verdict"] = "NO-GO" + _write_workflow_summary(output_dir / "opening_30m_rl_workflow_summary.json", workflow) + + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + client = flask_app.test_client() + + detail = rl_dashboard.load_rl_run("opening_e2e_run") + response = client.get("/api/rl/runs/opening_e2e_run") + progress = client.get("/api/rl/progress").get_json() + stages = rl_dashboard.load_rl_table("opening_e2e_run", "stages", limit=20) + controls_table = rl_dashboard.load_rl_table("opening_e2e_run", "controls", limit=20) + leaderboard = rl_dashboard.load_rl_table("opening_e2e_run", "leaderboard", limit=20) + proxy = rl_dashboard.load_rl_table("opening_e2e_run", "proxy_availability", limit=20) + participant = rl_dashboard.load_rl_table("opening_e2e_run", "participant_study_groups", limit=20) + orderbook = rl_dashboard.load_rl_table("opening_e2e_run", "orderbook_persistence", limit=20) + episodes = rl_dashboard.load_rl_table("opening_e2e_run", "episodes", limit=20) + + assert response.status_code == 200 + assert detail["artifact_type"] == "opening_30m_rl_workflow" + assert detail["summary"]["verdict"] == "NO-GO" + assert detail["strategy_context"]["is_live_ready"] is False + assert detail["strategy_context"]["is_profit_model"] is False + assert response.get_json()["strategy_context"]["label"] == "RL EXPERIMENT" + assert stages["row_count"] >= 10 + assert controls_table["rows"][0]["control_type"] == "shuffled_participant_context" + assert leaderboard["rows"][0]["baseline_policy"] == "ts_imb_same_decision_tp5_sl1_time" + assert leaderboard["rows"][0]["passes_cost_gate"] is False + assert proxy["row_count"] > 0 + assert participant["row_count"] > 0 + assert orderbook["row_count"] > 0 + assert episodes["row_count"] == 3 + assert progress["evidence"]["latest_opening_workflow_run"] == "opening_e2e_run" + assert any(page["page"] == "Opening 30M RL Workflow" for page in progress["pages"]) diff --git a/tests/test_stom_rl_opening_workflow_runner.py b/tests/test_stom_rl_opening_workflow_runner.py new file mode 100644 index 000000000..49f147fd9 --- /dev/null +++ b/tests/test_stom_rl_opening_workflow_runner.py @@ -0,0 +1,65 @@ +import json + +from stom_rl.opening_30m_rl_workflow import OpeningWorkflowConfig +from stom_rl.opening_30m_rl_runner import run_opening_workflow_stages +from tests.fixtures.stom_opening_rl import build_opening_fixture_frames, opening_orderbook_frame +from webui.rl_dashboard_opening_tables import load_opening_json_table + + +def _statuses(payload): + return {stage["name"]: stage["status"] for stage in payload["stages"]} + + +def test_opening_workflow_dry_run_writes_stage_statuses(tmp_path): + output_dir = tmp_path / "dry_run" + + payload = run_opening_workflow_stages( + build_opening_fixture_frames(), + OpeningWorkflowConfig(run_id="dry_run", output_dir=output_dir), + ) + + summary_path = output_dir / "opening_30m_rl_workflow_summary.json" + saved = json.loads(summary_path.read_text(encoding="utf-8")) + statuses = _statuses(saved) + + assert saved == payload + assert statuses["contract"] == "passed" + assert statuses["manifest"] == "passed" + assert statuses["participant_pressure"] == "passed" + assert statuses["readiness_env"] == "passed" + assert statuses["baseline"] == "passed" + assert statuses["training"] == "skipped" + assert saved["stage_results"]["training"]["reason"] == "training flag not set" + assert saved["stage_results"]["baseline"]["artifact_type"] == "opening_30m_baseline_comparator" + assert saved["feature_groups"] == [ + "price_volume", + "participant_pressure", + "orderbook_imbalance", + "orderbook_persistence", + "overheat_upper_wick", + "optional_investor_flow", + ] + assert saved["proxy_availability"]["foreign_net_buy"] == "missing" + assert saved["stage_results"]["orderbook_persistence"]["artifact_type"] == "orderbook_persistence_score" + assert saved["participant_study_artifacts"]["participant_pressure_readiness_summary_json"] + assert (output_dir / "episodes" / "opening_episode_manifest_summary.json").is_file() + assert (output_dir / "baseline" / "opening_baseline_summary.json").is_file() + assert load_opening_json_table("dry_run", output_dir, "opening_30m_rl_workflow", "proxy_availability", limit=10)["rows"] + assert load_opening_json_table("dry_run", output_dir, "opening_30m_rl_workflow", "orderbook_persistence", limit=10)["rows"] + + +def test_opening_workflow_blocks_training_when_readiness_fails(tmp_path): + bad_frame = opening_orderbook_frame(symbol="000250", session="20250103").iloc[:3].copy() + + payload = run_opening_workflow_stages( + [bad_frame], + OpeningWorkflowConfig(run_id="blocked_run", output_dir=tmp_path / "blocked"), + ) + + statuses = _statuses(payload) + assert statuses["readiness_env"] == "failed" + assert statuses["training"] == "blocked" + assert statuses["evaluation"] == "blocked" + assert statuses["cost_gate"] == "blocked" + assert payload["stage_results"]["training"]["reason"].startswith("NO-GO_DATA") + assert payload["verdict"] == "NO-GO_DATA" diff --git a/tests/test_stom_rl_orderbook_env.py b/tests/test_stom_rl_orderbook_env.py new file mode 100644 index 000000000..5ef376671 --- /dev/null +++ b/tests/test_stom_rl_orderbook_env.py @@ -0,0 +1,246 @@ +import json +import sqlite3 +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from stom_rl.orderbook_rl_env import ( + ACTION_HOLD, + ACTION_MARKET_BUY, + ACTION_MARKET_EXIT, + ORDERBOOK_FEATURE_NAMES, + OrderbookRlReadinessConfig, + StomOrderbookRlEnv, + StomOrderbookRlEnvConfig, + assess_orderbook_rl_readiness, + normalize_orderbook_frame, + seconds_since_open, +) + + +def _frame(*, future_price: float = 110.0) -> pd.DataFrame: + prices = [100.0, 102.0, 106.0, 107.0, future_price, future_price + 1.0] + rows = [] + for sec, price in enumerate(prices): + rows.append( + { + "sec": sec, + "price": price, + "ts": 120.0 + sec, + "buy_val": 1000.0 + sec * 100.0, + "sell_val": 900.0, + "buy_qty": 10.0 + sec, + "sell_qty": 9.0, + "bid_tot": 600.0 + sec * 10.0, + "ask_tot": 400.0, + "bid1": price - 1.0, + "ask1": price + 1.0, + "bidq1": 60.0, + "askq1": 40.0, + } + ) + return pd.DataFrame(rows) + + +def _env(frame: pd.DataFrame | None = None, **overrides): + return StomOrderbookRlEnv( + StomOrderbookRlEnvConfig( + lookback_window=2, + cost_bps=0.0, + slippage_bps=0.0, + invalid_action_penalty=0.01, + **overrides, + ), + frame=_frame() if frame is None else frame, + ) + + +def test_seconds_since_open_accepts_stom_index_and_timestamp(): + assert seconds_since_open(20250103090005) == 5 + assert seconds_since_open("2025-01-03 09:00:12") == 12 + + +def test_normalize_orderbook_frame_maps_korean_columns(): + raw = pd.DataFrame( + { + "index": [20250103090000, 20250103090001], + "현재가": [100.0, 101.0], + "체결강도": [120.0, 121.0], + "초당매수금액": [1000.0, 1100.0], + "초당매도금액": [900.0, 950.0], + "초당매수수량": [10.0, 11.0], + "초당매도수량": [9.0, 9.5], + "매수총잔량": [600.0, 610.0], + "매도총잔량": [400.0, 410.0], + "매수호가1": [99.0, 100.0], + "매도호가1": [101.0, 102.0], + "매수잔량1": [60.0, 61.0], + "매도잔량1": [40.0, 41.0], + } + ) + + frame = normalize_orderbook_frame(raw) + + assert frame["sec"].tolist() == [0, 1] + assert frame["price"].tolist() == [100.0, 101.0] + assert frame["bid1"].tolist() == [99.0, 100.0] + assert frame["ask1"].tolist() == [101.0, 102.0] + + +def test_orderbook_env_reset_observation_is_causal_and_fixed_shape(): + env = _env() + obs, info = env.reset(seed=7) + + assert obs.shape == (len(ORDERBOOK_FEATURE_NAMES),) + assert env.observation_space.contains(obs) + assert info["current_idx"] == 1 + assert info["no_future_observation"] is True + assert info["action_space"][ACTION_MARKET_BUY] == "market_buy" + assert "spread_rel" in info["feature_columns"] + + +def test_orderbook_env_market_buy_hold_exit_uses_marketable_fills(): + env = _env() + env.reset(seed=7) + + _, buy_reward, terminated, truncated, buy_info = env.step(ACTION_MARKET_BUY) + assert buy_reward > 0.0 # buy at ask=103, mark at next bid=105 + assert buy_info["position_after"] == 1 + assert buy_info["fill_price"] == pytest.approx(103.0) + assert terminated is False + assert truncated is False + + _, hold_reward, _, _, hold_info = env.step(ACTION_HOLD) + assert hold_info["position_after"] == 1 + assert np.isfinite(hold_reward) + + _, exit_reward, _, _, exit_info = env.step(ACTION_MARKET_EXIT) + assert exit_info["position_after"] == 0 + assert exit_info["fill_price"] == pytest.approx(106.0) + assert exit_info["realized_trade_return"] == pytest.approx(106.0 / 103.0 - 1.0) + assert np.isfinite(exit_reward) + + +def test_orderbook_env_invalid_actions_are_penalized(): + env = _env() + env.reset(seed=7) + + _, reward, _, _, info = env.step(ACTION_MARKET_EXIT) + + assert reward == pytest.approx(-0.01) + assert info["invalid_action"] is True + assert info["invalid_action_count"] == 1 + assert info["position_after"] == 0 + + +def test_orderbook_env_force_closes_open_position_on_terminal_step(): + env = _env(max_episode_steps=1) + env.reset(seed=7) + + _, _, terminated, _, info = env.step(ACTION_MARKET_BUY) + + assert terminated is True + assert info["force_closed"] is True + assert info["position_after"] == 0 + assert info["trade_count"] == 2 + + +def test_observation_at_decision_time_does_not_depend_on_future_rows(): + base = _env(_frame(future_price=110.0)) + changed_future = _env(_frame(future_price=999.0)) + + obs_a, _ = base.reset(seed=123) + obs_b, _ = changed_future.reset(seed=123) + + assert np.allclose(obs_a, obs_b) + + +def _create_readiness_db(path: Path, *, missing_columns: bool = False) -> None: + conn = sqlite3.connect(path) + cols = [ + '"index" INTEGER', + '"현재가" REAL', + '"체결강도" REAL', + '"초당매수금액" REAL', + '"초당매도금액" REAL', + '"초당매수수량" REAL', + '"초당매도수량" REAL', + '"매수총잔량" REAL', + '"매도총잔량" REAL', + '"매수호가1" REAL', + '"매도호가1" REAL', + '"매수잔량1" REAL', + ] + if not missing_columns: + cols.append('"매도잔량1" REAL') + conn.execute(f'CREATE TABLE "000001" ({",".join(cols)})') + for i in range(8): + values = [ + 20250103090000 + i, + 100.0 + i, + 120.0 + i, + 1000.0 + i, + 900.0, + 10.0 + i, + 9.0, + 600.0 + i, + 400.0, + 99.0 + i, + 101.0 + i, + 60.0, + ] + if not missing_columns: + values.append(40.0) + conn.execute( + f'INSERT INTO "000001" VALUES ({",".join("?" for _ in values)})', + values, + ) + conn.commit() + conn.close() + + +def test_assess_orderbook_rl_readiness_writes_dashboard_artifact(tmp_path): + db_path = tmp_path / "tick.db" + _create_readiness_db(db_path) + + payload = assess_orderbook_rl_readiness( + OrderbookRlReadinessConfig( + db_path=str(db_path), + output_dir=str(tmp_path / "run"), + omx_output_dir=str(tmp_path / "omx"), + max_symbols=1, + min_rows_per_episode=5, + lookback_window=3, + min_eligible_episodes=1, + ) + ) + + assert payload["summary"]["readiness_status"] == "READY_FOR_MARKETABLE_RL" + assert payload["summary"]["is_live_ready"] is False + assert payload["sample_env_smoke"]["passed"] is True + summary_path = tmp_path / "run" / "orderbook_rl_readiness_summary.json" + assert summary_path.is_file() + assert json.loads(summary_path.read_text(encoding="utf-8-sig"))["artifact_type"] == "orderbook_rl_readiness" + + +def test_assess_orderbook_rl_readiness_flags_missing_orderbook_columns(tmp_path): + db_path = tmp_path / "tick_missing.db" + _create_readiness_db(db_path, missing_columns=True) + + payload = assess_orderbook_rl_readiness( + OrderbookRlReadinessConfig( + db_path=str(db_path), + output_dir=str(tmp_path / "run"), + omx_output_dir=str(tmp_path / "omx"), + max_symbols=1, + min_rows_per_episode=5, + lookback_window=3, + min_eligible_episodes=1, + write_artifacts=False, + ) + ) + + assert payload["summary"]["readiness_status"] == "NO-GO_DATA" + assert payload["missing_column_tables"][0]["missing"] == ["매도잔량1"] diff --git a/tests/test_stom_rl_orderbook_persistence.py b/tests/test_stom_rl_orderbook_persistence.py new file mode 100644 index 000000000..58fb22a7d --- /dev/null +++ b/tests/test_stom_rl_orderbook_persistence.py @@ -0,0 +1,69 @@ +from stom_rl.orderbook_persistence import ( + COL_ASK_TOTAL, + COL_BID_TOTAL, + COL_BUY_AMOUNT, + COL_PRICE, + COL_SELL_AMOUNT, + COL_TRADE_STRENGTH, + REQUIRED_SCORE_COMPONENTS, + build_orderbook_persistence_score, + write_orderbook_persistence_artifact, +) +from tests.fixtures.stom_opening_rl import opening_orderbook_frame + + +def test_orderbook_persistence_score_prefers_sustained_bid_pressure(tmp_path): + strong = opening_orderbook_frame(symbol="000250", session="20250103") + weak = strong.copy() + weak[COL_BID_TOTAL] = 600.0 + weak[COL_ASK_TOTAL] = 2400.0 + weak[COL_TRADE_STRENGTH] = 80.0 + weak[COL_BUY_AMOUNT] = 500_000.0 + weak[COL_SELL_AMOUNT] = 1_800_000.0 + + strong_score = build_orderbook_persistence_score(strong, decision_second=5) + weak_score = build_orderbook_persistence_score(weak, decision_second=5) + + assert set(strong_score["components"]) == set(REQUIRED_SCORE_COMPONENTS) + assert strong_score["feature_groups"]["orderbook_imbalance"] == [ + "bid_ask_depth_imbalance", + "microprice_pressure", + "spread_penalty", + ] + assert strong_score["score"] > weak_score["score"] + assert strong_score["components"]["bid_ask_depth_imbalance"] > weak_score["components"]["bid_ask_depth_imbalance"] + assert strong_score["components"]["bid_depth_persistence"] > weak_score["components"]["bid_depth_persistence"] + assert strong_score["components"]["signed_flow_persistence"] > weak_score["components"]["signed_flow_persistence"] + + artifact = write_orderbook_persistence_artifact( + strong, + output_dir=tmp_path / "score", + decision_second=5, + ) + assert artifact["artifact_type"] == "orderbook_persistence_score" + assert (tmp_path / "score" / "orderbook_persistence_score_summary.json").is_file() + + +def test_overheat_score_is_causal_and_componentized(tmp_path): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + changed_future = frame.copy() + frame.loc[1, COL_PRICE] = 1120.0 + frame.loc[3, COL_PRICE] = 1002.0 + changed_future.loc[1, COL_PRICE] = 1120.0 + changed_future.loc[3, COL_PRICE] = 1002.0 + changed_future.loc[changed_future.index > 3, COL_PRICE] = 1600.0 + + original = build_orderbook_persistence_score(frame, decision_second=3) + changed = build_orderbook_persistence_score(changed_future, decision_second=3) + + assert original["components"] == changed["components"] + assert original["components"]["overheat_penalty"] > 0.0 + assert original["components"]["upper_wick_ratio"] > 2.0 + assert original["feature_groups"]["overheat_upper_wick"] == [ + "overheat_penalty", + "upper_wick_ratio", + "pullback_reacceleration", + ] + assert "component_values" in original["artifact_fields"] + assert "feature_groups" in original["artifact_fields"] + assert original["rows_used"] == 4 diff --git a/tests/test_stom_rl_orderbook_sb3.py b/tests/test_stom_rl_orderbook_sb3.py new file mode 100644 index 000000000..394dbdc07 --- /dev/null +++ b/tests/test_stom_rl_orderbook_sb3.py @@ -0,0 +1,241 @@ +import json +import sqlite3 +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pytest + +from stom_rl.orderbook_rl_env import StomOrderbookRlEnvConfig +from stom_rl.orderbook_sb3_adapter import OrderbookEpisode, StomOrderbookGymEnv + + +def _frame(rows: int = 16) -> pd.DataFrame: + return pd.DataFrame( + { + "sec": list(range(rows)), + "price": [100.0 + i for i in range(rows)], + "ts": [120.0 + i for i in range(rows)], + "buy_val": [1000.0 + i for i in range(rows)], + "sell_val": [900.0 for _ in range(rows)], + "buy_qty": [10.0 + i for i in range(rows)], + "sell_qty": [9.0 for _ in range(rows)], + "bid_tot": [600.0 + i for i in range(rows)], + "ask_tot": [400.0 for _ in range(rows)], + "bid1": [99.0 + i for i in range(rows)], + "ask1": [101.0 + i for i in range(rows)], + "bidq1": [60.0 for _ in range(rows)], + "askq1": [40.0 for _ in range(rows)], + } + ) + + +def _run_python(code: str) -> subprocess.CompletedProcess[str]: + result = subprocess.run([sys.executable, "-c", code], text=True, capture_output=True, check=False) + combined = result.stdout + result.stderr + if result.returncode != 0 and any( + marker in combined for marker in ["ModuleNotFoundError", "DLL load failed", "WinError 1114", "c10.dll"] + ): + pytest.skip(combined) + return result + + +def test_orderbook_gym_env_resets_and_steps_with_episode_metadata(): + env = StomOrderbookGymEnv( + [OrderbookEpisode("000001_20250103", "000001", "20250103", _frame())], + StomOrderbookRlEnvConfig(lookback_window=3, cost_bps=0.0, max_episode_steps=4), + ) + + obs, info = env.reset(seed=7) + obs, reward, terminated, truncated, step_info = env.step(1) + + assert env.observation_space.contains(obs) + assert info["episode_id"] == "000001_20250103" + assert step_info["symbol"] == "000001" + assert reward != 0.0 + assert terminated is False + assert truncated is False + + +def test_orderbook_gym_env_can_constrain_invalid_actions_to_hold(): + env = StomOrderbookGymEnv( + [OrderbookEpisode("000001_20250103", "000001", "20250103", _frame())], + StomOrderbookRlEnvConfig(lookback_window=3, cost_bps=0.0, max_episode_steps=4), + constrain_invalid_actions=True, + ) + + env.reset(seed=7) + _, reward, _, _, step_info = env.step(2) # market_exit while flat -> hold + + assert reward >= 0.0 + assert step_info["policy_action"] == 2 + assert step_info["executed_action"] == 0 + assert step_info["action_remapped"] is True + assert step_info["invalid_action_prevented"] is True + assert step_info["invalid_action"] is False + assert step_info["invalid_action_count"] == 0 + assert step_info["constraint_mode"] == "hold_on_invalid" + + +def test_orderbook_gym_env_single_entry_exit_contract_blocks_overtrading(): + env = StomOrderbookGymEnv( + [OrderbookEpisode("000001_20250103", "000001", "20250103", _frame())], + StomOrderbookRlEnvConfig(lookback_window=3, cost_bps=0.0, max_episode_steps=8), + single_entry_exit=True, + ) + + env.reset(seed=7) + _, skip_reward, terminated, _, skip_info = env.step(0) + assert terminated is True + assert skip_reward == 0.0 + assert skip_info["semantic_action_name"] == "skip" + assert skip_info["trade_count"] == 0 + + env.reset(seed=7) + _, buy_reward, terminated, _, buy_info = env.step(1) + assert terminated is False + assert buy_reward != 0.0 + assert buy_info["semantic_action_name"] == "enter" + assert buy_info["trade_count"] == 1 + + _, exit_reward, terminated, _, exit_info = env.step(2) + assert terminated is True + assert exit_info["semantic_action_name"] == "exit" + assert exit_info["trade_count"] == 2 + assert exit_reward == pytest.approx(float(exit_reward)) + + +def test_orderbook_gym_env_fixed_entry_exit_only_starts_position_and_only_exits(): + env = StomOrderbookGymEnv( + [OrderbookEpisode("000001_20250103", "000001", "20250103", _frame())], + StomOrderbookRlEnvConfig(lookback_window=3, cost_bps=0.0, max_episode_steps=8), + fixed_entry_exit_only=True, + ) + + obs, info = env.reset(seed=7) + assert env.action_space.n == 2 + assert env.observation_space.contains(obs) + assert info["fixed_entry"] is True + assert info["position_after"] == 1 + assert info["trade_count"] == 1 + + _, hold_reward, terminated, _, hold_info = env.step(0) + assert terminated is False + assert hold_info["semantic_action_name"] == "hold" + assert hold_info["policy_action_name"] == "hold" + assert hold_reward == pytest.approx(float(hold_reward)) + + _, exit_reward, terminated, _, exit_info = env.step(1) + assert terminated is True + assert exit_info["semantic_action_name"] == "exit" + assert exit_info["policy_action_name"] == "exit" + assert exit_info["position_after"] == 0 + assert exit_info["trade_count"] == 2 + assert exit_reward == pytest.approx(float(exit_reward)) + + +def _create_db(path: Path) -> None: + conn = sqlite3.connect(path) + cols = [ + '"index" INTEGER', + '"현재가" REAL', + '"등락율" REAL', + '"체결강도" REAL', + '"초당매수금액" REAL', + '"초당매도금액" REAL', + '"초당매수수량" REAL', + '"초당매도수량" REAL', + '"매수총잔량" REAL', + '"매도총잔량" REAL', + '"매수호가1" REAL', + '"매도호가1" REAL', + '"매수잔량1" REAL', + '"매도잔량1" REAL', + ] + for symbol_idx, symbol in enumerate(["000001", "000002", "000003", "000004"]): + conn.execute(f'CREATE TABLE "{symbol}" ({",".join(cols)})') + for session_idx, session in enumerate(["20250103", "20250106", "20250107", "20250108"]): + base_price = 100.0 + symbol_idx + session_idx + for sec in range(20): + price = base_price + sec * (0.2 if session_idx % 2 == 0 else -0.05) + values = [ + int(f"{session}0900{sec:02d}"), + price, + 2.5, + 150.0, + 1000.0 + sec, + 800.0, + 10.0 + sec, + 8.0, + 700.0, + 300.0, + price - 0.1, + price + 0.1, + 70.0, + 30.0, + ] + conn.execute(f'INSERT INTO "{symbol}" VALUES ({",".join("?" for _ in values)})', values) + conn.commit() + conn.close() + + +def test_orderbook_dqn_smoke_writes_oos_verdict_artifact(tmp_path): + db_path = tmp_path / "tick.db" + out_dir = tmp_path / "out" + _create_db(db_path) + + result = _run_python( + f""" +import json +from stom_rl.orderbook_sb3_smoke import OrderbookDqnSmokeConfig, run_orderbook_dqn_smoke +payload = run_orderbook_dqn_smoke( + OrderbookDqnSmokeConfig( + db_path={str(db_path)!r}, + output_dir={str(out_dir)!r}, + max_scan_symbols=4, + train_episodes=4, + eval_episodes=4, + min_eval_episodes=2, + lookback_window=3, + max_episode_steps=5, + total_timesteps=16, + cost_bps=0.0, + overtrade_penalty=0.001, + constrain_invalid_actions=True, + single_entry_exit=True, + fixed_entry_exit_only=True, + device="cpu", + ) +) +print(json.dumps({{ + "mode": payload["mode"], + "artifact_type": payload["artifact_type"], + "verdict": payload["summary"]["verdict"], + "eval_episode_count": payload["summary"]["eval_episode_count"], +}})) +""" + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout.strip().splitlines()[-1]) + assert payload["mode"] == "stom_orderbook_rl_sb3_smoke" + assert payload["artifact_type"] == "sb3_smoke" + assert payload["verdict"] in {"GO_CANDIDATE", "NO-GO"} + assert payload["eval_episode_count"] >= 2 + assert (out_dir / "sb3_smoke_summary.json").is_file() + assert (out_dir / "orderbook_oos_verdict.json").is_file() + assert (out_dir / "orderbook_diagnostics.json").is_file() + assert (out_dir / "dqn_model.zip").is_file() + summary = json.loads((out_dir / "sb3_smoke_summary.json").read_text(encoding="utf-8-sig")) + diagnostics = json.loads((out_dir / "orderbook_diagnostics.json").read_text(encoding="utf-8-sig")) + assert summary["summary"]["overtrade_penalty"] == 0.001 + assert summary["summary"]["constrain_invalid_actions"] is True + assert summary["summary"]["single_entry_exit"] is True + assert summary["summary"]["fixed_entry_exit_only"] is True + assert diagnostics["smallest_fix_selected"] == "fixed_entry_exit_only" + assert diagnostics["constrain_invalid_actions"] is True + assert diagnostics["invalid_action_rate"] == 0.0 + assert "action_counts" in diagnostics + assert "executed_action_counts" in diagnostics + assert set(diagnostics["action_counts"]).issubset({"hold", "exit"}) diff --git a/tests/test_stom_rl_panel_join.py b/tests/test_stom_rl_panel_join.py new file mode 100644 index 000000000..7f8b18364 --- /dev/null +++ b/tests/test_stom_rl_panel_join.py @@ -0,0 +1,408 @@ +"""Tests for Page 7.5 multi-symbol 1s time-sync panel join (the leakage gate). + +These tests use a tiny in-memory / temp sqlite fixture with 2-3 symbols and +UTF-8 Korean column names. They have NO dependency on the 29.7GB production DB. + +Coverage: +* (a) symbols aligned on a common timestamp grid; +* (b) a symbol with a halt/gap yields NaN (or exclusion), never a future value; +* (c) injecting a future-dated row does NOT change any row at-or-before T + (the core leakage guard for the as-of backward join); +* (d) determinism — repeated joins produce identical output. +Plus the memory precondition (P2-1) and the DB-backed convenience path. +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pandas as pd +import pytest + +from finetune.qlib_stom_pipeline import STOM_RL_CANONICAL_FEATURES +from stom_rl.panel_join import ( + DEFAULT_MEMORY_BUDGET_BYTES, + PANEL_LONG_COLUMNS, + SymbolFrame, + assert_panel_memory_budget, + build_panel_from_db, + estimate_panel_memory, + join_symbol_frames, + prepare_symbol_frame, +) + +# STOM tick DB column order (UTF-8 Korean names), trimmed to the RL-export +# fields. Mirrors the real per-symbol table layout (see Page 7 fixture). +_FIXTURE_COLUMNS = [ + "index", + "현재가", + "시가", + "고가", + "저가", + "초당매수수량", + "초당매도수량", + "체결강도", + "초당거래대금", + "회전율", + "매수총잔량", + "매도총잔량", + "매수호가1", + "매도호가1", +] + + +def _fixture_row(idx: int, close: float, buy: float, sell: float, amount: float, strength: float = 200.0) -> tuple: + return ( + idx, + close, + close, + close + 10.0, + close - 10.0, + buy, + sell, + strength, + amount, + 0.05, + 80.0, + 20.0, + close - 10.0, + close + 10.0, + ) + + +def _make_multi_symbol_db(db_path: Path, tables: dict[str, list[tuple]]) -> None: + """Create a tiny multi-symbol STOM-shaped DB with UTF-8 Korean columns.""" + + conn = sqlite3.connect(str(db_path)) + try: + column_defs = ", ".join(f'"{name}"' for name in _FIXTURE_COLUMNS) + placeholders = ", ".join(["?"] * len(_FIXTURE_COLUMNS)) + for table_name, rows in tables.items(): + conn.execute(f'CREATE TABLE "{table_name}" ({column_defs})') + conn.executemany(f'INSERT INTO "{table_name}" VALUES ({placeholders})', rows) + conn.commit() + finally: + conn.close() + + +def _make_keyed_frame(symbol: str, seconds: list[int], base: float = 100.0) -> pd.DataFrame: + """Build a keyed canonical frame (timestamp,symbol,session,<14 features>). + + ``seconds`` are second-offsets from 2022-12-12 09:00:00. Feature values are + deterministic functions of the offset so cross-symbol leakage is detectable. + """ + + ts = [pd.Timestamp("2022-12-12 09:00:00") + pd.Timedelta(seconds=s) for s in seconds] + n = len(seconds) + data = { + "timestamp": ts, + "symbol": [symbol] * n, + "session": ["20221212"] * n, + } + for i, feat in enumerate(STOM_RL_CANONICAL_FEATURES): + # close carries a recognisable point-in-time value: base + second offset. + if feat == "close": + data[feat] = [base + s for s in seconds] + else: + data[feat] = [float(i) + float(s) for s in seconds] + return pd.DataFrame(data) + + +# --------------------------------------------------------------------------- +# (a) common-grid alignment +# --------------------------------------------------------------------------- +def test_symbols_aligned_on_common_grid(): + a = prepare_symbol_frame(_make_keyed_frame("000020", [0, 1, 2, 3])) + b = prepare_symbol_frame(_make_keyed_frame("000040", [0, 2, 4])) + + panel, report = join_symbol_frames([a, b]) + + assert list(panel.columns) == PANEL_LONG_COLUMNS + # Common grid is the union of observed seconds: {0,1,2,3,4} -> 5 timestamps. + grid = sorted(panel["timestamp"].unique()) + assert len(grid) == 5 + assert report.grid_size == 5 + # Every grid timestamp carries exactly one row per symbol (long format). + counts = panel.groupby("timestamp")["symbol"].nunique() + assert (counts == 2).all() + # Both symbols share the identical timestamp grid. + for sym in ("000020", "000040"): + sym_ts = sorted(panel.loc[panel["symbol"] == sym, "timestamp"].unique()) + assert sym_ts == grid + + +# --------------------------------------------------------------------------- +# (b) halt / gap -> NaN (or exclusion), never a future value +# --------------------------------------------------------------------------- +def test_gap_yields_nan_not_future_value(): + # Symbol B is "halted" between second 1 and second 4: it has observations at + # t=0 and t=5 only. At grid times 1..4 the as-of (backward) value must be + # B's t=0 observation (stale-but-real), and NEVER its t=5 future value. + a = prepare_symbol_frame(_make_keyed_frame("000020", [0, 1, 2, 3, 4, 5])) + b = prepare_symbol_frame(_make_keyed_frame("000040", [0, 5], base=500.0)) + + panel, _ = join_symbol_frames([a, b]) + + b_rows = panel[panel["symbol"] == "000040"].set_index("timestamp")["close"] + t = pd.Timestamp("2022-12-12 09:00:00") + # At the halted seconds 1..4 the value is the stale t=0 close (500.0), + # which is strictly LESS than the future t=5 close (505.0). + for s in (1, 2, 3, 4): + assert b_rows[t + pd.Timedelta(seconds=s)] == 500.0 + assert b_rows[t + pd.Timedelta(seconds=s)] != 505.0 + assert b_rows[t + pd.Timedelta(seconds=5)] == 505.0 + + +def test_no_observation_before_grid_start_is_nan_not_future(): + # Symbol B's first observation is at t=3; grid starts at t=0 (from A). + # Grid times 0,1,2 have NO B observation at-or-before them -> must be NaN, + # never B's future t=3 value. + a = prepare_symbol_frame(_make_keyed_frame("000020", [0, 1, 2, 3])) + b = prepare_symbol_frame(_make_keyed_frame("000040", [3], base=900.0)) + + panel, report = join_symbol_frames([a, b]) + + b_rows = panel[panel["symbol"] == "000040"].set_index("timestamp")["close"] + t = pd.Timestamp("2022-12-12 09:00:00") + for s in (0, 1, 2): + assert pd.isna(b_rows[t + pd.Timedelta(seconds=s)]) + assert b_rows[t + pd.Timedelta(seconds=3)] == 903.0 + assert report.per_symbol["000040"]["grid_rows_nan"] == 3 + + +def test_halt_rows_with_nonpositive_close_are_excluded_and_reported(): + frame = _make_keyed_frame("000020", [0, 1, 2, 3]) + # Mark seconds 1 and 2 as a trading halt (close <= 0). + frame.loc[frame.index[1:3], "close"] = 0.0 + sf = prepare_symbol_frame(frame) + + assert sf.excluded_halt_rows == 2 + # Remaining observations are only the tradable seconds 0 and 3. + kept = sf.frame["timestamp"].dt.second.tolist() + assert kept == [0, 3] + + +# --------------------------------------------------------------------------- +# (c) leakage guard: injecting a future-dated row must not change rows <= T +# --------------------------------------------------------------------------- +def test_injecting_future_row_does_not_change_rows_at_or_before_T(): + """Core leakage assertion for the as-of backward join. + + Build a panel from baseline observations, then rebuild it after injecting a + far-future, extreme-valued row for one symbol. Every panel row whose + timestamp is at-or-before the last baseline grid time T must be byte-for-byte + identical between the two runs. A backward as-of join guarantees this; any + forward/leaking fill would change the <= T rows. + """ + + a = prepare_symbol_frame(_make_keyed_frame("000020", [0, 1, 2, 3, 4])) + b_base = _make_keyed_frame("000040", [0, 1, 2, 3, 4], base=200.0) + b_future = pd.concat( + [b_base, _make_keyed_frame("000040", [600], base=999999.0)], + ignore_index=True, + ) + + baseline_panel, _ = join_symbol_frames([a, prepare_symbol_frame(b_base)]) + future_panel, _ = join_symbol_frames([a, prepare_symbol_frame(b_future)]) + + last_baseline_T = baseline_panel["timestamp"].max() + + # Restrict both panels to rows at-or-before the last baseline grid time T. + base_leq = baseline_panel[baseline_panel["timestamp"] <= last_baseline_T].reset_index(drop=True) + fut_leq = ( + future_panel[future_panel["timestamp"] <= last_baseline_T] + .reset_index(drop=True) + ) + + # Same shape and byte-identical features at every row <= T. + assert base_leq.shape == fut_leq.shape + pd.testing.assert_frame_equal(base_leq, fut_leq) + + # And the injected future value (999999.0) appears nowhere in the <= T panel. + assert not (fut_leq[STOM_RL_CANONICAL_FEATURES] >= 999999.0).any().any() + + +def test_tolerance_treats_long_staleness_as_missing(): + # With a 2s tolerance, B's t=0 obs cannot fill grid times beyond t=2. + a = prepare_symbol_frame(_make_keyed_frame("000020", [0, 1, 2, 3, 4, 5])) + b = prepare_symbol_frame(_make_keyed_frame("000040", [0, 5], base=500.0)) + + panel, _ = join_symbol_frames([a, b], tolerance=pd.Timedelta(seconds=2)) + + b_rows = panel[panel["symbol"] == "000040"].set_index("timestamp")["close"] + t = pd.Timestamp("2022-12-12 09:00:00") + assert b_rows[t + pd.Timedelta(seconds=2)] == 500.0 # within tolerance + assert pd.isna(b_rows[t + pd.Timedelta(seconds=3)]) # stale beyond tolerance + assert pd.isna(b_rows[t + pd.Timedelta(seconds=4)]) + assert b_rows[t + pd.Timedelta(seconds=5)] == 505.0 # fresh obs + + +# --------------------------------------------------------------------------- +# (d) determinism +# --------------------------------------------------------------------------- +def test_join_is_deterministic(): + frames = [ + prepare_symbol_frame(_make_keyed_frame("000040", [0, 2, 4])), + prepare_symbol_frame(_make_keyed_frame("000020", [0, 1, 2, 3, 4])), + ] + panel1, _ = join_symbol_frames(frames) + # Rebuild from scratch (fresh objects) and re-join in a different input order. + frames2 = [ + prepare_symbol_frame(_make_keyed_frame("000020", [0, 1, 2, 3, 4])), + prepare_symbol_frame(_make_keyed_frame("000040", [0, 2, 4])), + ] + panel2, _ = join_symbol_frames(frames2) + + pd.testing.assert_frame_equal(panel1, panel2) + + +# --------------------------------------------------------------------------- +# Memory precondition (P2-1) +# --------------------------------------------------------------------------- +def test_memory_estimate_and_budget_precondition(): + est = estimate_panel_memory(max_symbols=3, max_rows_per_group=10) + assert est["max_symbols"] == 3 + assert est["estimated_bytes"] > 0 + + ok = assert_panel_memory_budget(max_symbols=3, max_rows_per_group=1000) + assert ok["within_budget"] is True + assert ok["budget_bytes"] == DEFAULT_MEMORY_BUDGET_BYTES + + +def test_memory_budget_rejects_oversized_chunk(): + with pytest.raises(MemoryError): + # 2400 symbols x 23400 seconds (full session) x 112 bytes/row >> 1MB budget. + assert_panel_memory_budget( + max_symbols=2400, + max_rows_per_group=23400, + budget_bytes=1_000_000, + ) + + +# --------------------------------------------------------------------------- +# DB-backed convenience path (per-day-chunk; no full scan) +# --------------------------------------------------------------------------- +def test_build_panel_from_db_multi_symbol(tmp_path: Path): + db_path = tmp_path / "fixture.db" + rows_a = [ + _fixture_row(20221212090005 + i, close=9260.0 + i, buy=10.0 + i, sell=3.0, amount=100.0 + i) + for i in range(6) + ] + # Symbol B has a gap: observations only at +0 and +5 seconds. + rows_b = [ + _fixture_row(20221212090005, close=5000.0, buy=4.0, sell=1.0, amount=50.0), + _fixture_row(20221212090010, close=5005.0, buy=4.0, sell=1.0, amount=55.0), + ] + _make_multi_symbol_db(db_path, {"000020": rows_a, "000040": rows_b}) + + panel, report = build_panel_from_db( + db_path=db_path, + tables=["000020", "000040"], + session="20221212", + time_start="090000", + time_end="093000", + ) + + assert list(panel.columns) == PANEL_LONG_COLUMNS + assert set(report.symbols) == {"000020", "000040"} + assert report.grid_size == 6 # union of A's 6 seconds (B's seconds subset) + # Long format: 6 grid timestamps x 2 symbols = 12 rows. + assert len(panel) == 12 + # B has only 2 real observations on a 6-row grid -> 4 backward-filled rows, + # all stale-but-real (never future). No NaN here because B starts at the grid + # start; the value at the gapped seconds equals B's last close 5000.0. + b_rows = panel[panel["symbol"] == "000040"].set_index("timestamp")["close"] + t = pd.Timestamp("2022-12-12 09:00:05") + assert b_rows[t + pd.Timedelta(seconds=1)] == 5000.0 # stale, not the future 5005 + assert b_rows[t + pd.Timedelta(seconds=5)] == 5005.0 + + +def test_empty_symbol_frame_is_all_nan_not_future(): + a = prepare_symbol_frame(_make_keyed_frame("000020", [0, 1, 2])) + empty = SymbolFrame( + symbol="000099", + frame=pd.DataFrame(columns=["timestamp", "symbol", *STOM_RL_CANONICAL_FEATURES]), + ) + panel, report = join_symbol_frames([a, empty]) + + empty_rows = panel[panel["symbol"] == "000099"] + assert len(empty_rows) == 3 + assert empty_rows[STOM_RL_CANONICAL_FEATURES].isna().all().all() + assert report.per_symbol["000099"]["grid_rows_nan"] == 3 + + +def _make_minutes_db(db_path: Path, symbol: str, n_minutes: int, secs_per_min: int) -> None: + """Build a single-symbol DB spanning ``n_minutes`` × ``secs_per_min`` seconds. + + Timestamps run 09:00:00, 09:00:01, ... so the live feed path can be exercised + at both 1s and 1min grids without the production DB. + """ + + rows = [] + base = pd.Timestamp("2022-12-12 09:00:00") + counter = 0 + for minute in range(n_minutes): + for sec in range(secs_per_min): + t = base + pd.Timedelta(minutes=minute, seconds=sec) + idx = int(t.strftime("%Y%m%d%H%M%S")) + # close drifts up over the session; flow varies per second. + rows.append( + _fixture_row( + idx, + close=9260.0 + counter * 0.5, + buy=10.0 + (counter % 7), + sell=3.0 + (counter % 5), + amount=100.0 + (counter % 11) * 10.0, + strength=180.0 + (counter % 13) * 5.0, + ) + ) + counter += 1 + _make_multi_symbol_db(db_path, {symbol: rows}) + + +def test_build_panel_from_db_freq_1min_resamples_live_feed(tmp_path: Path): + """The live feed path (build_panel_from_db) at freq='1min' resamples the RL + SOURCE frame to 1-min bars: far fewer timestamps than 1s, flow summed, and + the 22 canonical features (incl. the 4 trend features) present + non-degenerate.""" + + db_path = tmp_path / "minutes.db" + n_minutes, secs_per_min = 6, 12 + _make_minutes_db(db_path, "000020", n_minutes=n_minutes, secs_per_min=secs_per_min) + + panel_1s, report_1s = build_panel_from_db( + db_path=db_path, tables=["000020"], session="20221212", freq="1s" + ) + panel_1min, report_1min = build_panel_from_db( + db_path=db_path, tables=["000020"], session="20221212", freq="1min" + ) + + assert list(panel_1min.columns) == PANEL_LONG_COLUMNS + # 1-min grid has ~one bar per minute, far fewer than the per-second grid. + assert report_1min.grid_size == n_minutes + assert report_1s.grid_size == n_minutes * secs_per_min + assert report_1min.grid_size < report_1s.grid_size + + # Bars labeled at bucket start (floor to minute), one per minute. + minute_stamps = sorted(panel_1min["timestamp"].unique()) + assert len(minute_stamps) == n_minutes + assert pd.Timestamp(minute_stamps[0]) == pd.Timestamp("2022-12-12 09:00:00") + + # V-NONDEGEN on the live feed: the trend + flow/rate features must vary. + sym = panel_1min[panel_1min["symbol"] == "000020"] + for col in ( + "amount", + "volume", + "trade_strength", + "moving_average_n", + "amount_slope_n", + ): + assert float(sym[col].var()) > 0.0, f"{col!r} degenerate on the 1-min live-feed panel" + + # Flow is SUMMED per minute: the 1-min volume of the first bar equals the sum + # of the per-second volumes in that minute (each second volume = buy+sell). + expected_first_volume = sum( + (10.0 + (i % 7)) + (3.0 + (i % 5)) for i in range(secs_per_min) + ) + first_bar = sym.sort_values("timestamp").iloc[0] + assert first_bar["volume"] == pytest.approx(expected_first_volume) diff --git a/tests/test_stom_rl_paper_replay.py b/tests/test_stom_rl_paper_replay.py new file mode 100644 index 000000000..76590d0b7 --- /dev/null +++ b/tests/test_stom_rl_paper_replay.py @@ -0,0 +1,186 @@ +import csv +import json + +import pytest + +from stom_rl.paper_replay import PaperReplayConfig, run_paper_replay + + +def _write_tiny_candidates(path): + """Write a minimal in-memory candidate CSV with an explicit T+1 contract. + + ``price`` is the decision-bar close at T; ``fill_price`` is the next-bar + close (T+1). They are deliberately distinct so a test can assert that the + accounting ledger fills at ``fill_price`` rather than ``price`` (no DB and + no 29.7GB dependency). + """ + + header = [ + "timestamp", + "symbol", + "condition_id", + "passed", + "rank_score", + "price", + "fill_price", + "fillable", + "feature_momentum", + ] + rows = [ + # symbol A: decision close 100, T+1 fill 110 (distinct -> T+1 honored) + ["2025-01-03T09:00:00", "000111", "tiny_rule", "True", "9.0", "100.0", "110.0", "True", "1.0"], + ["2025-01-03T09:00:01", "000111", "tiny_rule", "True", "8.0", "110.0", "121.0", "True", "1.1"], + ["2025-01-03T09:00:02", "000111", "tiny_rule", "True", "7.0", "121.0", "130.0", "True", "1.2"], + ["2025-01-03T09:00:03", "000111", "tiny_rule", "True", "6.0", "130.0", "140.0", "True", "1.3"], + # symbol B: a second slot so buy actions have a real fillable target + ["2025-01-03T09:00:00", "000222", "tiny_rule", "True", "5.0", "200.0", "210.0", "True", "2.0"], + ["2025-01-03T09:00:01", "000222", "tiny_rule", "True", "4.0", "210.0", "221.0", "True", "2.1"], + ["2025-01-03T09:00:02", "000222", "tiny_rule", "True", "3.0", "221.0", "230.0", "True", "2.2"], + ["2025-01-03T09:00:03", "000222", "tiny_rule", "True", "2.0", "230.0", "240.0", "True", "2.3"], + ] + with path.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.writer(f) + writer.writerow(header) + writer.writerows(rows) + return str(path) + + +def test_paper_replay_is_read_only_and_logs_decisions(tmp_path): + payload = run_paper_replay(PaperReplayConfig(output_dir=str(tmp_path), max_steps=5, max_daily_trades=1)) + + assert payload["summary"]["read_only"] is True + assert payload["summary"]["order_write_path"] is False + assert payload["summary"]["steps"] == 5 + assert (tmp_path / "paper_replay_summary.json").is_file() + assert (tmp_path / "decisions.csv").is_file() + assert (tmp_path / "risk_triggers.json").is_file() + assert (tmp_path / "blocked_actions.json").is_file() + triggers = json.loads((tmp_path / "risk_triggers.json").read_text(encoding="utf-8-sig")) + assert "risk_triggers" in triggers + + +def test_paper_replay_refuses_non_read_only_mode(): + with pytest.raises(ValueError, match="read_only"): + run_paper_replay(PaperReplayConfig(read_only=False, write_artifacts=False)) + + +def test_paper_replay_read_only_writes_only_logs_no_order_side_effects(tmp_path): + """Read-only invariant: a replay produces logs/artifacts only. + + The only files written live under ``output_dir`` (decision/NAV/risk/blocked + logs + summary). No broker/order/trading-path file is created, and the + summary advertises ``order_write_path == False``. + """ + + csv_path = _write_tiny_candidates(tmp_path / "candidates.csv") + out_dir = tmp_path / "run" + payload = run_paper_replay( + PaperReplayConfig(candidate_path=csv_path, output_dir=str(out_dir), max_steps=8, seed=7) + ) + + assert payload["summary"]["order_write_path"] is False + assert payload["summary"]["read_only"] is True + written = sorted(p.name for p in out_dir.iterdir()) + assert written == [ + "blocked_actions.json", + "decisions.csv", + "nav.csv", + "paper_replay_summary.json", + "risk_triggers.json", + ] + # No file escaped the output directory into the input/trading path. + assert sorted(p.name for p in tmp_path.iterdir()) == ["candidates.csv", "run"] + + +def test_paper_replay_is_deterministic_for_fixed_seed_and_input(tmp_path): + """Same seed + input -> byte-identical decision log and NAV curve.""" + + csv_path = _write_tiny_candidates(tmp_path / "candidates.csv") + out_a = tmp_path / "a" + out_b = tmp_path / "b" + run_paper_replay(PaperReplayConfig(candidate_path=csv_path, output_dir=str(out_a), max_steps=8, seed=42)) + run_paper_replay(PaperReplayConfig(candidate_path=csv_path, output_dir=str(out_b), max_steps=8, seed=42)) + + assert (out_a / "decisions.csv").read_bytes() == (out_b / "decisions.csv").read_bytes() + assert (out_a / "nav.csv").read_bytes() == (out_b / "nav.csv").read_bytes() + assert (out_a / "blocked_actions.json").read_bytes() == (out_b / "blocked_actions.json").read_bytes() + + +def test_paper_replay_blocked_actions_carry_reason_codes(tmp_path): + """Blocked actions carry (timestamp, symbol/slot, reason, risk state).""" + + csv_path = _write_tiny_candidates(tmp_path / "candidates.csv") + out_dir = tmp_path / "run" + # Force the daily-trade risk gate to trip quickly so buys get blocked. + payload = run_paper_replay( + PaperReplayConfig( + candidate_path=csv_path, + output_dir=str(out_dir), + max_steps=8, + seed=11, + max_daily_trades=1, + ) + ) + + blocked = json.loads((out_dir / "blocked_actions.json").read_text(encoding="utf-8-sig"))["blocked_actions"] + assert blocked, "expected at least one blocked action under a tight daily-trade cap" + assert payload["summary"]["blocked_action_count"] == len(blocked) + for entry in blocked: + assert entry["timestamp"] + assert "slot" in entry + assert "symbol" in entry + assert entry["reason"], "every blocked action must carry a reason code" + assert entry["source"] in {"risk_gate", "env_mask"} + assert isinstance(entry["risk_state"], dict) + # risk state snapshot describes the current state at block time + assert "peak_nav" in entry["risk_state"] + # A pure HOLD proposal is never recorded as a blocked action (no order). + assert all(entry["action_type"] in {"buy", "sell"} for entry in blocked) + + +def test_paper_replay_honors_t1_fill_price(tmp_path): + """Trades fill at the T+1 ``fill_price``, never the decision-bar ``price``. + + The fixture sets ``price`` (T close) and ``fill_price`` (T+1 close) to + distinct values, so a buy fill recorded at ``fill_price`` proves the T+1 + contract is honored rather than collapsing to the decision bar. + """ + + csv_path = _write_tiny_candidates(tmp_path / "candidates.csv") + out_dir = tmp_path / "run" + run_paper_replay( + PaperReplayConfig( + candidate_path=csv_path, + output_dir=str(out_dir), + max_steps=8, + seed=3, + # generous caps so the first buy executes (not risk-blocked) + max_daily_trades=20, + max_consecutive_losses=99, + max_drawdown_pct=99.0, + ) + ) + + decisions = list(csv.DictReader((out_dir / "decisions.csv").open(encoding="utf-8-sig"))) + buys = [d for d in decisions if d["action_type"] == "buy" and d["blocked"] == "False"] + assert buys, "expected at least one executed buy" + + # The set of distinct T close prices and T+1 fill prices is disjoint in the + # fixture, so we can verify fills came from the fill_price column. + close_prices = {100.0, 110.0, 121.0, 130.0, 200.0, 210.0, 221.0, 230.0} + fill_prices = {110.0, 121.0, 130.0, 140.0, 210.0, 221.0, 230.0, 240.0} + t1_only = fill_prices - close_prices # {140.0, 240.0} can only be a T+1 fill + + # Re-run via the env directly to inspect trade fills (read-only, no writes). + from stom_rl.portfolio_env import PortfolioEnv, PortfolioEnvConfig + + env = PortfolioEnv(PortfolioEnvConfig(candidate_path=csv_path, top_k_candidates=2, max_positions=2, seed=3)) + _, info = env.reset(seed=3) + # Buy slot 0 at the first bar: decision price is the T close, fill is T+1. + first_close = float(env._current_candidates().iloc[0]["price"]) + env.step(1) + assert env.trade_log, "buy should have produced a fill" + fill_price = float(env.trade_log[0]["price"]) + assert fill_price != first_close, "fill must not collapse to the decision-bar close (lookahead)" + assert fill_price in fill_prices, "fill must come from the T+1 fill_price column" + assert close_prices and t1_only # sanity: fixture has a T+1-only price band diff --git a/tests/test_stom_rl_paper_sim.py b/tests/test_stom_rl_paper_sim.py new file mode 100644 index 000000000..5827615d2 --- /dev/null +++ b/tests/test_stom_rl_paper_sim.py @@ -0,0 +1,117 @@ +"""Unit tests for the frozen-policy paper replay (Page D). + +RULE strategy, NOT reinforcement learning. Synthetic chronological trade +sequences exercise: a single win's account math, the top-K concurrency cap, the +consecutive-loss halt at streak 7, the intraday daily-loss halt, compounding vs +fixed sizing, and max-drawdown tracking. No DB / no I/O. +""" + +from __future__ import annotations + +import pytest + +from stom_rl.gap_up_risk_sizing import RiskConfig +from stom_rl.paper_sim import simulate_paper_account + + +def _approx(value: float, expected: float, tol: float = 1e-3) -> bool: + return abs(value - expected) <= tol + + +def _t(date: str, net_pct: float, *, strength: float = 150.0, sec=None): + return {"date": date, "strength": strength, "sec_amount_won": sec, "net_pct": net_pct} + + +ONE_EOK = 100_000_000.0 + + +def test_single_win_account_math(): + c = RiskConfig() + s = simulate_paper_account([_t("20230102", 5.0)], c, initial_account_won=ONE_EOK) + # order 0.10*1e8=1e7; +5% -> +500,000. + assert _approx(s["final_account_won"], 100_500_000.0) + assert _approx(s["total_return_pct"], 0.5) + assert s["n_taken"] == 1 and s["n_signals"] == 1 + assert _approx(s["max_drawdown_pct"], 0.0) + + +def test_top_k_concurrency_cap(): + c = RiskConfig() # K=3 + day = [_t("20230102", 1.0, strength=s) for s in (1, 2, 3, 4, 5)] + s = simulate_paper_account(day, c, initial_account_won=ONE_EOK) + # top-3 by strength taken; each +1% on 1e7 -> +100,000 x3. + assert s["n_signals"] == 5 + assert s["n_taken"] == 3 + assert s["n_skipped_cap"] == 2 + assert _approx(s["final_account_won"], 100_300_000.0) + + +def test_consecutive_loss_halt_at_streak_7(): + c = RiskConfig() # tiers halt (scale 0.0) at streak >= 7 + trades = [_t(f"202301{d:02d}", -1.0) for d in range(1, 9)] # 8 losing days + s = simulate_paper_account(trades, c, initial_account_won=ONE_EOK) + # days 1-7 taken (streaks 0..6 -> f>0); day 8 (streak 7 -> f_eff 0) skipped. + assert s["n_taken"] == 7 + assert s["n_skipped_halt"] == 1 + assert s["n_signals"] == 8 + + +def test_streak_halt_is_a_recoverable_circuit_breaker_not_a_deadlock(): + c = RiskConfig() + # 7 losing days build the streak to 7; day 8 (streak 7) trips the breaker and + # is skipped + reset; day 9 (streak 0 again) must RESUME and take the win. + trades = [_t(f"202301{d:02d}", -1.0) for d in range(1, 8)] # 7 losses + trades.append(_t("20230108", -1.0)) # day 8: halted (streak 7), then resets + trades.append(_t("20230109", 5.0)) # day 9: resumes at full size, wins + s = simulate_paper_account(trades, c, initial_account_won=ONE_EOK) + assert s["n_taken"] == 8 # 7 losses + the day-9 win (NOT frozen forever) + assert s["n_skipped_halt"] == 1 # only the day-8 halt day + + +def test_daily_loss_limit_halts_remaining_same_day_entries(): + # Tight 0.1% daily limit (=100,000 on 1e8); one -1.23% loss (-123,000) breaches it. + c = RiskConfig(daily_loss_limit_pct=0.1) + day = [_t("20230102", -1.23, strength=s) for s in (3, 2, 1)] + s = simulate_paper_account(day, c, initial_account_won=ONE_EOK) + assert s["n_taken"] == 1 + assert s["n_skipped_halt"] == 2 + assert s["n_days_daily_limit_hit"] == 1 + + +def test_compounding_beats_fixed_after_gains(): + c = RiskConfig() + wins = [_t("20230102", 5.0), _t("20230103", 5.0), _t("20230104", 5.0)] + comp = simulate_paper_account(wins, c, initial_account_won=ONE_EOK, compounding=True) + fixed = simulate_paper_account(wins, c, initial_account_won=ONE_EOK, compounding=False) + assert comp["final_account_won"] > fixed["final_account_won"] + assert fixed["final_account_won"] > ONE_EOK # both profit on three wins + + +def test_max_drawdown_tracked_after_a_loss(): + c = RiskConfig() + s = simulate_paper_account( + [_t("20230102", 5.0), _t("20230103", -10.0)], c, initial_account_won=ONE_EOK + ) + # peak 100.5M; then 0.10*100.5M=10.05M notional, -10% -> -1.005M -> 99.495M. + # dd = 99.495/100.5 - 1 = -1.0%. + assert _approx(s["max_drawdown_pct"], -1.0, tol=1e-2) + + +def test_empty_and_validation(): + c = RiskConfig() + s = simulate_paper_account([], c, initial_account_won=ONE_EOK) + assert s["n_signals"] == 0 and _approx(s["total_return_pct"], 0.0) + assert _approx(s["final_account_won"], ONE_EOK) + with pytest.raises(ValueError): + simulate_paper_account([], c, initial_account_won=0.0) + + +def test_liquidity_cap_shrinks_sizing(): + # A thin name (sec_amount 1,000,000원 << base order) caps the order via + # position_notional_won -> notional = max_participation * 1,000,000. + c = RiskConfig(max_participation=1.0) # explicit: cap = 1.0 * sec_amount + s = simulate_paper_account( + [_t("20230102", 5.0, sec=1_000_000.0)], c, initial_account_won=ONE_EOK + ) + # capped order 1,000,000; +5% -> +50,000 (vs +500,000 uncapped). + assert _approx(s["final_account_won"], 100_050_000.0) diff --git a/tests/test_stom_rl_participant_dashboard.py b/tests/test_stom_rl_participant_dashboard.py new file mode 100644 index 000000000..b6bfd7ad8 --- /dev/null +++ b/tests/test_stom_rl_participant_dashboard.py @@ -0,0 +1,123 @@ +import json +import sys +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +DASHBOARD_SRC = REPO_ROOT / "webui" / "v2_src" / "src" +RL_COMPONENT_DIR = DASHBOARD_SRC / "tabs" / "rlTrading" + +from webui import rl_dashboard # noqa: E402 +from webui.app import app as flask_app # noqa: E402 + + +def _write_csv(path: Path, header: str, rows: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join([header, *rows]) + "\n", encoding="utf-8-sig") + + +def _write_participant_run(root: Path) -> None: + run = root / "opening_participant_run" + run.mkdir() + (run / "opening_30m_rl_workflow_summary.json").write_text( + json.dumps( + { + "artifact_type": "opening_30m_rl_workflow", + "mode": "opening_30m_rl_workflow", + "run_id": "opening_participant_run", + "verdict": "NO-GO", + "config": {"cost_bps": 23.0, "time_start": "090000", "time_end": "093000"}, + "proxy_availability": {"trade_strength": "available", "foreign_net_buy": "missing"}, + "missing_proxy_columns": ["외국인순매수"], + "feature_ablation_results": { + "no_participant_pressure": {"verdict": "NO-GO", "baseline_delta_pct": -0.2} + }, + "stages": [{"name": "controls", "status": "failed", "reason": "NO-GO"}], + }, + ensure_ascii=False, + ), + encoding="utf-8-sig", + ) + (run / "participant_pressure_readiness_summary.json").write_text( + json.dumps( + { + "artifact_type": "participant_pressure_readiness", + "proxy_availability": {"trade_strength": "available", "foreign_net_buy": "missing"}, + "missing_proxy_columns": ["외국인순매수"], + "feature_schema": [ + {"name": "trade_strength", "feature_group": "participant_pressure", "source_column": "체결강도"}, + {"name": "foreign_net_buy", "feature_group": "participant_flow_optional", "source_column": "외국인순매수"}, + ], + }, + ensure_ascii=False, + ), + encoding="utf-8-sig", + ) + (run / "orderbook_persistence_score_summary.json").write_text( + json.dumps( + { + "artifact_type": "orderbook_persistence_score", + "score": 0.62, + "components": {"bid_depth_persistence": 0.8, "overheat_penalty": 0.3}, + } + ), + encoding="utf-8-sig", + ) + _write_csv( + run / "market_participant_study_groups.csv", + "group,episode_count,verdict", + ["absolute_ge_100b_krw,3,NO-GO_SAMPLE"], + ) + _write_csv( + run / "market_participant_study_episodes.csv", + "episode_id,symbol,upper_wick_signal", + ["000250_20250103,000250,True"], + ) + + +def _participant_source_text() -> str: + files = [ + DASHBOARD_SRC / "lib" / "rlApi.ts", + DASHBOARD_SRC / "tabs" / "RLTradingTab.svelte", + ] + files.extend(sorted(RL_COMPONENT_DIR.glob("ParticipantProxyCard.svelte"))) + return "\n".join(path.read_text(encoding="utf-8") for path in files if path.is_file()) + + +def test_dashboard_displays_participant_proxy_evidence_guardrails(tmp_path, monkeypatch): + _write_participant_run(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + proxy = rl_dashboard.load_rl_table("opening_participant_run", "proxy_availability", limit=10) + orderbook = rl_dashboard.load_rl_table("opening_participant_run", "orderbook_persistence", limit=10) + studies = rl_dashboard.load_rl_table("opening_participant_run", "participant_study_groups", limit=10) + ablation = rl_dashboard.load_rl_table("opening_participant_run", "feature_ablation", limit=10) + client = flask_app.test_client() + response = client.get("/api/rl/runs/opening_participant_run/table/proxy_availability") + source = _participant_source_text() + + assert response.status_code == 200 + assert proxy["rows"][0]["proxy"] == "trade_strength" + assert proxy["rows"][1]["status"] == "missing" + assert orderbook["rows"][0]["component"] == "bid_depth_persistence" + assert studies["rows"][0]["group"] == "absolute_ge_100b_krw" + assert ablation["rows"][0]["feature_ablation"] == "no_participant_pressure" + for marker in ["PARTICIPANT PROXY EVIDENCE", "ORDERBOOK PERSISTENCE", "PROXY AVAILABILITY", "FEATURE ABLATION", "NO-GO", "not live-ready"]: + assert marker in source + + +def test_dashboard_rejects_identity_and_profit_claim_copy(tmp_path, monkeypatch): + _write_participant_run(tmp_path) + monkeypatch.setattr(rl_dashboard, "RL_RUN_ROOTS", [tmp_path]) + + with pytest.raises(ValueError, match="direct child|Invalid"): + rl_dashboard.load_rl_table("../opening_participant_run", "proxy_availability") + source = _participant_source_text() + + for forbidden in ["actual foreign buyer detected", "big-money actor identified", "profit model"]: + assert forbidden not in source diff --git a/tests/test_stom_rl_participant_pressure_features.py b/tests/test_stom_rl_participant_pressure_features.py new file mode 100644 index 000000000..0ed51fccc --- /dev/null +++ b/tests/test_stom_rl_participant_pressure_features.py @@ -0,0 +1,72 @@ +import pytest + +from stom_rl.participant_pressure_features import ( + COL_BID_TOTAL, + COL_BUY_AMOUNT, + COL_TRADE_STRENGTH, + ParticipantPressureError, + build_participant_pressure_readiness, + compute_participant_pressure_features, +) +from tests.fixtures.stom_opening_rl import opening_orderbook_frame + + +def test_participant_proxy_schema_records_available_and_missing_sources(tmp_path): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + + payload = build_participant_pressure_readiness( + frame, + output_dir=tmp_path / "participant", + decision_second=3, + ) + + assert payload["artifact_type"] == "participant_pressure_readiness" + assert (tmp_path / "participant" / "participant_pressure_readiness_summary.json").is_file() + assert payload["proxy_availability"]["trade_strength"] == "available" + assert payload["proxy_availability"]["bid_depth_imbalance"] == "available" + assert payload["proxy_availability"]["participant_proxy_pressure"] == "available" + assert payload["proxy_availability"]["transaction_value_surge"] == "available" + assert payload["proxy_availability"]["signed_amount_persistence"] == "available" + assert payload["proxy_availability"]["foreign_net_buy"] == "missing" + assert payload["proxy_availability"]["institution_net_buy"] == "missing" + assert payload["proxy_availability"]["program_net_buy"] == "missing" + assert payload["computed_features"]["foreign_net_buy"] is None + assert payload["computed_features"]["institution_net_buy"] is None + assert payload["computed_features"]["program_net_buy"] is None + assert payload["computed_features"]["participant_proxy_pressure"] > 0.0 + assert payload["computed_features"]["transaction_value_surge"] == payload["computed_features"]["transaction_value_sum"] + assert payload["computed_features"]["signed_amount_persistence"] == payload["computed_features"]["signed_amount_ratio"] + assert { + "participant_proxy_pressure", + "transaction_value_surge", + "signed_amount_persistence", + } <= {spec["name"] for spec in payload["feature_schema"]} + for spec in payload["feature_schema"]: + assert spec["feature_group"] + assert spec["source_column"] + assert spec["available_at_decision_second"] in {"required", "optional"} + assert spec["lookback"] >= 0 + assert spec["missing_policy"] in {"fail_closed", "not_causal_at_decision"} + + +def test_participant_proxy_features_do_not_use_future_rows(): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + changed_future = frame.copy() + changed_future.loc[changed_future.index > 2, COL_TRADE_STRENGTH] = 999.0 + changed_future.loc[changed_future.index > 2, COL_BUY_AMOUNT] = 99_000_000.0 + changed_future.loc[changed_future.index > 2, COL_BID_TOTAL] = 99_000.0 + + original = compute_participant_pressure_features(frame, decision_second=2) + changed = compute_participant_pressure_features(changed_future, decision_second=2) + + assert original == changed + assert original["rows_used"] == 3 + assert original["trade_strength"] == frame[COL_TRADE_STRENGTH].iloc[2] + assert original["foreign_net_buy"] is None + + +def test_participant_proxy_rejects_out_of_range_decision_second(): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + + with pytest.raises(ParticipantPressureError, match="decision_second"): + compute_participant_pressure_features(frame, decision_second=len(frame)) diff --git a/tests/test_stom_rl_participant_rl_context.py b/tests/test_stom_rl_participant_rl_context.py new file mode 100644 index 000000000..67485b193 --- /dev/null +++ b/tests/test_stom_rl_participant_rl_context.py @@ -0,0 +1,110 @@ +from stom_rl.opening_30m_rl_context import ( + CANONICAL_FEATURE_GROUPS, + FEATURE_CONTRACTS, + OPENING_RL_CONTEXT_FEATURE_NAMES, + REQUIRED_ABLATION_KEYS, + apply_participant_context_ablation_gate, + build_opening_rl_context, + compute_opening_context_reward_penalty, + normalize_feature_set_id, +) +from stom_rl.orderbook_persistence import COL_ASK1, COL_BID1, COL_PRICE +from stom_rl.orderbook_rl_env import ACTION_NAMES +from tests.fixtures.stom_opening_rl import opening_orderbook_frame + + +def test_opening_rl_observation_includes_participant_orderbook_context(): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + changed_future = frame.copy() + changed_future.loc[changed_future.index > 3, COL_PRICE] = 1600.0 + + context = build_opening_rl_context(frame, decision_second=3) + changed = build_opening_rl_context(changed_future, decision_second=3) + + assert context["feature_names"] == list(OPENING_RL_CONTEXT_FEATURE_NAMES) + assert context["feature_names"][:3] == [ + "participant_pressure_score", + "orderbook_persistence_score", + "overheat_score", + ] + assert "proxy_available_trade_strength" in context["feature_names"] + assert "proxy_available_foreign_net_buy" in context["feature_names"] + assert len(context["vector"]) == len(OPENING_RL_CONTEXT_FEATURE_NAMES) + assert context["vector"] == changed["vector"] + assert set(ACTION_NAMES.values()) == {"hold", "market_buy", "market_exit"} + + +def test_opening_rl_feature_contract_uses_canonical_groups_and_aliases(): + assert CANONICAL_FEATURE_GROUPS == ( + "price_volume", + "participant_pressure", + "orderbook_imbalance", + "orderbook_persistence", + "overheat_upper_wick", + "optional_investor_flow", + ) + assert normalize_feature_set_id("full") == "full_context" + assert normalize_feature_set_id("no_participant") == "no_participant_pressure" + assert normalize_feature_set_id("no_orderbook") == "no_orderbook_imbalance" + assert normalize_feature_set_id("no_overheat") == "no_overheat_upper_wick" + assert set(REQUIRED_ABLATION_KEYS) == { + "full_context", + "no_participant_pressure", + "no_orderbook_imbalance", + "no_orderbook_persistence", + "no_overheat_upper_wick", + "minimal_price_volume", + "shuffled_participant_context", + "ts_imb_rule_baseline", + } + for contract in FEATURE_CONTRACTS: + assert contract.feature_group in CANONICAL_FEATURE_GROUPS + assert contract.source + assert contract.causal_lookback + assert contract.missing_policy + assert contract.dashboard_label + + +def test_participant_context_ablation_failure_blocks_go_candidate(): + result = apply_participant_context_ablation_gate( + { + "candidate_verdict": "GO_CANDIDATE", + "full_context_net_return_pct": 0.4, + "ts_imb_net_return_pct": 0.6, + "cost_gate_passed": True, + }, + { + "full_context": 0.4, + "no_participant_pressure": 0.5, + "no_orderbook_persistence": 0.3, + "no_overheat_penalty": 0.4, + "shuffled_participant_context": 0.7, + "ts_imb_rule_baseline": 0.6, + }, + ) + + assert result["verdict"] == "NO-GO" + assert result["participant_context_ablation_passed"] is False + assert result["negative_control_blocked_go"] is True + assert set(result["required_ablation_keys"]) == set(REQUIRED_ABLATION_KEYS) + assert result["go_block_reason"] == "participant_context_failed_ablation_or_cost_gate" + + +def test_context_reward_penalties_are_causal_and_diagnostic(): + frame = opening_orderbook_frame(symbol="000250", session="20250103") + changed_future = frame.copy() + frame.loc[1, COL_PRICE] = 1120.0 + frame.loc[3, COL_PRICE] = 1002.0 + changed_future.loc[1, COL_PRICE] = 1120.0 + changed_future.loc[3, COL_PRICE] = 1002.0 + changed_future.loc[changed_future.index > 3, COL_PRICE] = 1600.0 + changed_future.loc[changed_future.index > 3, COL_BID1] = 0.0 + changed_future.loc[changed_future.index > 3, COL_ASK1] = 0.0 + + penalty = compute_opening_context_reward_penalty(frame, decision_second=3, action_name="market_buy") + changed = compute_opening_context_reward_penalty(changed_future, decision_second=3, action_name="market_buy") + + assert penalty == changed + assert penalty["total_penalty"] > 0.0 + assert penalty["diagnostics"]["overheat_entry_penalty"] > 0.0 + assert penalty["diagnostics"]["decision_second"] == 3 diff --git a/tests/test_stom_rl_performance_leaderboard.py b/tests/test_stom_rl_performance_leaderboard.py new file mode 100644 index 000000000..55fe5d29d --- /dev/null +++ b/tests/test_stom_rl_performance_leaderboard.py @@ -0,0 +1,229 @@ +import json +from pathlib import Path + +from stom_rl.performance_leaderboard import ( + PerformanceLeaderboardConfig, + build_performance_leaderboard, + discover_sb3_summary_reports, +) + + +def test_performance_leaderboard_combines_baseline_and_bandit(tmp_path: Path): + baseline_dir = tmp_path / "baseline" + baseline_dir.mkdir() + baseline_report = baseline_dir / "leaderboard_report.json" + baseline_report.write_text( + json.dumps( + { + "summary": { + "target_rows": [ + { + "policy": "buy_and_hold", + "cost_bps": 25.0, + "episode_count": 10, + "trade_count": 10, + "trades_per_episode": 1.0, + "avg_episode_net_return_pct": 0.5, + "max_drawdown_pct": -5.0, + }, + { + "policy": "no_trade", + "cost_bps": 25.0, + "episode_count": 10, + "trade_count": 0, + "avg_episode_net_return_pct": 0.0, + "max_drawdown_pct": 0.0, + }, + ] + } + } + ), + encoding="utf-8", + ) + bandit_dir = tmp_path / "bandit" + bandit_dir.mkdir() + bandit_report = bandit_dir / "eval_summary.json" + bandit_report.write_text( + json.dumps( + { + "eval_summary": { + "summary": { + "policy": "contextual_bandit", + "eval_split": "test", + "episode_count": 10, + "trade_count": 4, + "trades_per_episode": 0.4, + "avg_episode_net_return_pct": 0.2, + "max_drawdown_pct": -4.0, + "passes_cost_gate": False, + "cost_bps": 25.0, + } + } + } + ), + encoding="utf-8", + ) + + payload = build_performance_leaderboard( + PerformanceLeaderboardConfig( + baseline_report=str(baseline_report), + contextual_bandit_report=str(bandit_report), + output_dir=str(tmp_path / "out"), + sb3_smoke_reports=(), + ) + ) + + assert payload["summary"]["row_count"] == 3 + assert payload["summary"]["best_policy"] == "buy_and_hold" + assert payload["summary"]["best_rl_usability"] == "watch" + rows = {row["policy"]: row for row in payload["leaderboard"]} + assert rows["contextual_bandit"]["beats_no_trade"] is True + assert rows["contextual_bandit"]["beats_buy_and_hold"] is False + assert (tmp_path / "out" / "performance_leaderboard.json").is_file() + assert (tmp_path / "out" / "performance_leaderboard.csv").is_file() + + +def test_performance_leaderboard_includes_sb3_smoke_models(tmp_path: Path): + baseline_dir = tmp_path / "baseline" + baseline_dir.mkdir() + baseline_report = baseline_dir / "leaderboard_report.json" + baseline_report.write_text( + json.dumps( + { + "summary": { + "target_rows": [ + {"policy": "buy_and_hold", "avg_episode_net_return_pct": 0.5, "episode_count": 1}, + {"policy": "no_trade", "avg_episode_net_return_pct": 0.0, "episode_count": 1}, + ] + } + } + ), + encoding="utf-8", + ) + bandit_dir = tmp_path / "bandit" + bandit_dir.mkdir() + bandit_report = bandit_dir / "eval_summary.json" + bandit_report.write_text( + json.dumps({"eval_summary": {"summary": {"policy": "contextual_bandit", "avg_episode_net_return_pct": 0.1}}}), + encoding="utf-8", + ) + sb3_dir = tmp_path / "sb3" + sb3_dir.mkdir() + sb3_report = sb3_dir / "sb3_smoke_summary.json" + sb3_report.write_text( + json.dumps( + { + "models": [ + { + "algorithm": "dqn", + "model": "dqn_smoke", + "policy": "stable_baselines3_dqn", + "avg_episode_net_return_pct": 0.2, + "episode_count": 1, + "trade_count": 1, + "passes_cost_gate": False, + "is_smoke": True, + }, + { + "algorithm": "ppo", + "model": "ppo_smoke", + "policy": "stable_baselines3_ppo", + "avg_episode_net_return_pct": -0.1, + "episode_count": 1, + "trade_count": 0, + "passes_cost_gate": False, + "is_smoke": True, + }, + ] + } + ), + encoding="utf-8", + ) + + payload = build_performance_leaderboard( + PerformanceLeaderboardConfig( + baseline_report=str(baseline_report), + contextual_bandit_report=str(bandit_report), + sb3_smoke_reports=(str(sb3_report),), + output_dir=str(tmp_path / "out"), + ) + ) + + rows = {row["model"]: row for row in payload["leaderboard"]} + assert payload["summary"]["row_count"] == 5 + assert payload["summary"]["rl_smoke_models"] == ["dqn_smoke", "ppo_smoke"] + assert rows["dqn_smoke"]["is_smoke"] is True + assert rows["dqn_smoke"]["run_name"] == "sb3" + + +def test_performance_leaderboard_auto_discovers_sb3_training_runs(tmp_path: Path): + baseline_dir = tmp_path / "baseline" + baseline_dir.mkdir() + baseline_report = baseline_dir / "leaderboard_report.json" + baseline_report.write_text( + json.dumps( + { + "summary": { + "target_rows": [ + {"policy": "buy_and_hold", "avg_episode_net_return_pct": 0.5, "episode_count": 1}, + {"policy": "no_trade", "avg_episode_net_return_pct": 0.0, "episode_count": 1}, + ] + } + } + ), + encoding="utf-8", + ) + bandit_dir = tmp_path / "bandit" + bandit_dir.mkdir() + bandit_report = bandit_dir / "eval_summary.json" + bandit_report.write_text( + json.dumps({"eval_summary": {"summary": {"policy": "contextual_bandit", "avg_episode_net_return_pct": 0.1}}}), + encoding="utf-8", + ) + + rl_root = tmp_path / "webui" / "rl_runs" + sb3_5k_dir = rl_root / "stom_1s_2025_sb3_5k" + sb3_50k_dir = rl_root / "stom_1s_2025_sb3_50k" + sb3_5k_dir.mkdir(parents=True) + sb3_50k_dir.mkdir(parents=True) + for run_dir, timesteps, avg_return in ((sb3_5k_dir, 5000, 0.6), (sb3_50k_dir, 50000, 0.8)): + (run_dir / "sb3_smoke_summary.json").write_text( + json.dumps( + { + "models": [ + { + "algorithm": "dqn", + "model": "dqn_smoke", + "policy": "stable_baselines3_dqn", + "training_timesteps": timesteps, + "avg_episode_net_return_pct": avg_return, + "episode_count": 1, + "trade_count": 1, + "passes_cost_gate": True, + } + ] + } + ), + encoding="utf-8", + ) + + discovered = discover_sb3_summary_reports(rl_root) + assert len(discovered) == 2 + + payload = build_performance_leaderboard( + PerformanceLeaderboardConfig( + baseline_report=str(baseline_report), + contextual_bandit_report=str(bandit_report), + sb3_smoke_reports=("auto",), + sb3_report_root=str(rl_root), + output_dir=str(tmp_path / "out"), + ) + ) + + rows = {row["model"]: row for row in payload["leaderboard"]} + assert payload["config"]["sb3_smoke_reports_source"] == "auto" + assert payload["summary"]["row_count"] == 5 + assert payload["summary"]["max_sb3_training_timesteps"] == 50000 + assert payload["summary"]["rl_training_models"] == ["dqn_50k", "dqn_5k"] + assert rows["dqn_50k"]["run_category"] == "short" + assert rows["dqn_50k"]["is_smoke"] is False diff --git a/tests/test_stom_rl_portfolio_env.py b/tests/test_stom_rl_portfolio_env.py new file mode 100644 index 000000000..988fdb64f --- /dev/null +++ b/tests/test_stom_rl_portfolio_env.py @@ -0,0 +1,213 @@ +import numpy as np +import pandas as pd +import pytest + +from stom_rl.portfolio_env import ACTION_HOLD, PortfolioEnv, PortfolioEnvConfig, synthetic_candidates + + +def _t1_candidates() -> pd.DataFrame: + """Tiny in-memory candidate frame carrying the Page 9 T+1 fill contract. + + Symbol A rises 100->110->120, symbol B 50->55->60. Per symbol, ``fill_price`` + is the next bar's close (T+1); the last bar has no T+1 so it is unfillable. + No DB is required — this mirrors the real candidate schema in-memory. + """ + + base = pd.Timestamp("2025-07-09 09:00:00") + prices = {"A": [100.0, 110.0, 120.0], "B": [50.0, 55.0, 60.0]} + rows = [] + for symbol, series in prices.items(): + for t, price in enumerate(series): + fill = series[t + 1] if t + 1 < len(series) else float("nan") + rows.append( + { + "timestamp": (base + pd.Timedelta(seconds=t)).isoformat(), + "symbol": symbol, + "condition_id": "t1_fixture", + "passed": True, + "rank_score": float((10 - t) if symbol == "A" else (5 - t)), + "price": price, + "fill_price": fill, + "fillable": not np.isnan(fill), + "feature_f": float(t), + } + ) + return pd.DataFrame(rows) + + +def test_portfolio_env_fills_at_t1_not_decision_bar(): + """A buy decided at T (close=100) must fill at the T+1 price (110).""" + + env = PortfolioEnv( + PortfolioEnvConfig(top_k_candidates=2, max_positions=2, buy_fraction=0.5, cost_bps=0.0, seed=7), + candidates=_t1_candidates(), + ) + _, info = env.reset(seed=7) + decision_ts = pd.Timestamp(info["timestamp"]) + + env.step(1) # buy highest-rank slot (symbol A, decision close=100) + fill = env.trade_log[-1] + + assert fill["symbol"] == "A" + # Fill price is the next-bar close, strictly above the decision-bar close. + assert fill["price"] == pytest.approx(110.0) + assert fill["price"] > 100.0 + # The decision timestamp is recorded; the executed price is from T+1. + assert pd.Timestamp(fill["timestamp"]) == decision_ts + + +def test_portfolio_env_nav_conserved_after_t1_change(): + """cash + holdings == NAV must hold across a full T+1 episode.""" + + env = PortfolioEnv( + PortfolioEnvConfig(top_k_candidates=2, max_positions=2, buy_fraction=0.5, cost_bps=25.0, seed=7), + candidates=_t1_candidates(), + ) + _, info = env.reset(seed=7) + terminated = False + while not terminated: + mask = list(info["action_mask"]) + action = next((a for a in range(1, len(mask)) if mask[a]), ACTION_HOLD) + _, _, terminated, _, info = env.step(action) + prices = {symbol: env.last_prices[symbol] for symbol in env.account.positions} + # NAV is cash + holdings by construction; assert_invariants enforces it. + env.account.assert_invariants({**env.last_prices, **prices}) + assert abs(info["nav"] - (info["cash"] + env.account.holdings_value(env.last_prices))) < 1e-6 + + +def test_portfolio_env_unfillable_last_bar_cannot_buy(): + """A candidate with fillable==False (no T+1) is never buyable.""" + + env = PortfolioEnv( + PortfolioEnvConfig(top_k_candidates=2, max_positions=2, buy_fraction=0.5, invalid_action_penalty=1.0, seed=7), + candidates=_t1_candidates(), + ) + env.reset(seed=7) + env.current_step = len(env.timestamps) - 1 # last bar: every candidate is unfillable + candidates = env._current_candidates() + assert not candidates["fillable"].any() + + mask = env.action_mask(candidates) + # Only HOLD is permitted; all buy slots are masked out. + assert mask[ACTION_HOLD] == 1 + sell_offset = 1 + env.config.top_k_candidates + assert all(mask[slot] == 0 for slot in range(1, sell_offset)) + assert env._blocked_reason(1, candidates) == "unfillable_no_t1" + + +def test_portfolio_env_missing_fill_price_falls_back_with_warning(): + """Legacy CSVs without fill_price keep working (synthetic smoke path).""" + + with pytest.warns(RuntimeWarning, match="fill_price"): + env = PortfolioEnv( + PortfolioEnvConfig(top_k_candidates=2, max_positions=1), + candidates=synthetic_candidates(), + ) + _, info = env.reset(seed=1) + # Fallback marks every positive-price row fillable, so a buy is still possible. + assert info["action_mask"][1] == 1 + env.step(1) + assert env.trade_log[-1]["price"] > 0 + + +def test_portfolio_env_fixed_masks_and_discrete_action_layout(): + env = PortfolioEnv( + PortfolioEnvConfig(top_k_candidates=2, max_positions=1, invalid_action_penalty=1.0), + candidates=synthetic_candidates(), + ) + observation, info = env.reset(seed=1) + + assert observation.shape == env.observation_space.shape + assert env.action_space.n == 4 + assert info["candidate_mask"] == [1, 1] + assert info["holding_mask"] == [0] + assert env.decode_action(1) == {"type": "buy", "slot": 0} + assert env.decode_action(3) == {"type": "sell", "slot": 0} + + _, reward, terminated, truncated, info = env.step(1) + assert reward == pytest.approx(reward) + assert terminated is False + assert truncated is False + assert info["trade_count"] == 1 + assert info["holding_mask"] == [1] + + _, invalid_reward, _, _, invalid_info = env.step(2) + assert invalid_reward < 0 + assert invalid_info["invalid_action"] is True + assert invalid_info["blocked_reason"] == "max_positions_reached" + + _, _, _, _, sell_info = env.step(3) + assert sell_info["trade_count"] == 2 + + +def test_portfolio_env_hold_action_is_always_valid(): + env = PortfolioEnv(PortfolioEnvConfig(top_k_candidates=3, max_positions=2), candidates=synthetic_candidates()) + _, info = env.reset() + assert info["action_mask"][ACTION_HOLD] == 1 + + +def _step_buy_reward(*, turnover_penalty_lambda: float) -> float: + """Reward of a single buy step under a given cost-aware λ (cost_bps>0).""" + + env = PortfolioEnv( + PortfolioEnvConfig( + top_k_candidates=2, + max_positions=2, + buy_fraction=0.5, + cost_bps=25.0, + turnover_penalty_lambda=turnover_penalty_lambda, + seed=7, + ), + candidates=_t1_candidates(), + ) + _, _ = env.reset(seed=7) + _, reward, _, _, _ = env.step(1) # buy highest-rank slot (a real fill) + assert env.trade_log, "expected a fill on the buy step" + return float(reward) + + +def test_cost_aware_lambda_penalizes_turnover(): + """λ>0 makes a trading step's reward strictly lower than the λ=0 reward. + + The penalty is additive (``reward = Δnav − λ·cost/nav``) and only fires on a + fill, so a buy step with λ>0 must score below the same buy step with λ=0, + and a larger λ must penalize more. This is the cost-aware reward's teeth. + """ + + reward_no_penalty = _step_buy_reward(turnover_penalty_lambda=0.0) + reward_small_penalty = _step_buy_reward(turnover_penalty_lambda=1.0) + reward_large_penalty = _step_buy_reward(turnover_penalty_lambda=5.0) + + # A turnover penalty strictly reduces the reward of a trading step. + assert reward_small_penalty < reward_no_penalty + # Heavier λ penalizes turnover more (monotone in λ). + assert reward_large_penalty < reward_small_penalty + + +def test_cost_aware_lambda_default_is_a_noop_for_hold_and_no_fill(): + """λ default (0.0) leaves the reward identical to the legacy NAV-change one; + and even with λ>0, a HOLD step (no fill) incurs no turnover penalty.""" + + # Default λ == 0.0: a buy step reward equals the explicitly-zero-λ reward. + assert _step_buy_reward(turnover_penalty_lambda=0.0) == _step_buy_reward( + turnover_penalty_lambda=0.0 + ) + + # With λ>0 but a HOLD action (no fill), the penalty term is zero, so the + # reward is pure Δnav — no turnover cost is charged when nothing trades. + env = PortfolioEnv( + PortfolioEnvConfig( + top_k_candidates=2, + max_positions=2, + cost_bps=25.0, + turnover_penalty_lambda=5.0, + seed=7, + ), + candidates=_t1_candidates(), + ) + _, _ = env.reset(seed=7) + trades_before = len(env.trade_log) + _, reward, _, _, _ = env.step(ACTION_HOLD) + assert len(env.trade_log) == trades_before # no fill on HOLD + # No fill ⇒ no penalty term ⇒ reward is the unshaped Δnav (here ~0 move). + assert reward == pytest.approx(reward) diff --git a/tests/test_stom_rl_portfolio_sb3_adapter.py b/tests/test_stom_rl_portfolio_sb3_adapter.py new file mode 100644 index 000000000..e55ff1a5f --- /dev/null +++ b/tests/test_stom_rl_portfolio_sb3_adapter.py @@ -0,0 +1,164 @@ +"""Stage A: PortfolioEnv -> Gymnasium adapter contract + leakage tests. + +Three groups: + +* **contract** — the wrapped env is a faithful passthrough of ``PortfolioEnv`` + (identical obs/reward for an identical action sequence; ``action_masks()`` + equals ``action_mask()``); +* **check_env** — the adapter passes Gymnasium's ``check_env`` cleanly; +* **adapter leakage canary (V6b)** — the wrapped-env obs/reward at step ``T`` is + a pure function of bars ``<= T``; appending FUTURE candidate rows does not + change obs/reward at steps ``<= T``, and ``reset()`` does not surface later + bars. This guards the SB3 wrapper specifically (distinct from the env/CSV + canary in ``tests/test_stom_rl_portfolio_walk_forward.py``). +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from stom_rl.portfolio_env import PortfolioEnv, synthetic_candidates +from stom_rl.portfolio_sb3_adapter import ( + PortfolioSb3GymEnv, + make_portfolio_sb3_env, +) + + +def _action_sequence() -> list[int]: + # hold, buy slot 0, hold, buy slot 1, hold, sell slot 0, hold ... + return [0, 1, 0, 2, 0, 4, 0, 0] + + +# --------------------------------------------------------------------------- # +# Group 1: contract — faithful passthrough +# --------------------------------------------------------------------------- # +def test_spaces_match_underlying_env(): + raw = PortfolioEnv(candidates=synthetic_candidates()) + wrapped = PortfolioSb3GymEnv(candidates=synthetic_candidates()) + assert wrapped.action_space.n == raw.action_space.n + assert wrapped.observation_space.shape == raw.observation_space.shape + assert wrapped.observation_space.dtype == np.float32 + + +def test_wrapped_obs_reward_equal_raw_env_for_identical_actions(): + raw = PortfolioEnv(candidates=synthetic_candidates()) + wrapped = PortfolioSb3GymEnv(candidates=synthetic_candidates()) + + raw_obs, _ = raw.reset(seed=7) + wrapped_obs, _ = wrapped.reset(seed=7) + np.testing.assert_array_equal(wrapped_obs, raw_obs) + + for action in _action_sequence(): + r_obs, r_reward, r_term, r_trunc, _ = raw.step(action) + w_obs, w_reward, w_term, w_trunc, _ = wrapped.step(action) + np.testing.assert_array_equal(w_obs, r_obs) + assert w_reward == pytest.approx(r_reward) + assert w_term == r_term + assert w_trunc == r_trunc + if r_term: + break + + +def test_action_masks_plural_equals_singular_action_mask(): + raw = PortfolioEnv(candidates=synthetic_candidates()) + wrapped = PortfolioSb3GymEnv(candidates=synthetic_candidates()) + raw.reset(seed=11) + wrapped.reset(seed=11) + + # At reset and after a buy, the mask must agree element-wise. + np.testing.assert_array_equal(wrapped.action_masks(), raw.action_mask()) + raw.step(1) + wrapped.step(1) + masks = wrapped.action_masks() + np.testing.assert_array_equal(masks, raw.action_mask()) + assert masks.dtype == np.int8 + assert masks.shape == (wrapped.action_space.n,) + + +def test_factory_builds_equivalent_env(): + wrapped = make_portfolio_sb3_env(candidates=synthetic_candidates(), seed=3) + assert isinstance(wrapped, PortfolioSb3GymEnv) + obs, info = wrapped.reset(seed=3) + assert obs.shape == wrapped.observation_space.shape + assert "action_mask" in info + + +# --------------------------------------------------------------------------- # +# Group 2: Gymnasium check_env passes cleanly +# --------------------------------------------------------------------------- # +def test_check_env_passes(): + from gymnasium.utils.env_checker import check_env + + wrapped = PortfolioSb3GymEnv(candidates=synthetic_candidates()) + # skip_render_check default True; raises on any space/dtype/contract issue. + check_env(wrapped, skip_render_check=True) + + +# --------------------------------------------------------------------------- # +# Group 3: adapter leakage canary (V6b) +# --------------------------------------------------------------------------- # +def _early_bars(frame: pd.DataFrame, keep_steps: int) -> pd.DataFrame: + """Return only the first ``keep_steps`` distinct timestamps' rows.""" + + frame = frame.copy() + frame["timestamp"] = pd.to_datetime(frame["timestamp"]) + keep_ts = sorted(frame["timestamp"].unique())[:keep_steps] + return frame[frame["timestamp"].isin(keep_ts)].reset_index(drop=True) + + +def test_adapter_obs_reward_pure_function_of_past_bars(): + """Appending FUTURE candidate bars must not change obs/reward at steps <= T. + + Build a base frame, then a ``+future`` frame that is byte-identical for the + first ``T`` bars but has extra LATER timestamps appended. Running the same + action prefix on both wrapped envs must yield identical obs/reward for every + step up to ``T`` — i.e. the wrapped obs at step ``t`` depends only on bars + ``<= t``, never on appended future rows. + """ + + full = synthetic_candidates() + full["timestamp"] = pd.to_datetime(full["timestamp"]) + distinct_ts = sorted(full["timestamp"].unique()) + cut = 5 # number of leading bars we compare over (well under the 8 total) + + base = _early_bars(full, cut) + with_future = full # identical first ``cut`` bars + extra later bars + + env_base = PortfolioSb3GymEnv(candidates=base) + env_future = PortfolioSb3GymEnv(candidates=with_future) + + obs_base, _ = env_base.reset(seed=21) + obs_future, _ = env_future.reset(seed=21) + # reset() must not surface any later-bar data. + np.testing.assert_array_equal(obs_future, obs_base) + + actions = [0, 1, 0, 2] # stays within the first ``cut`` bars + for step_idx, action in enumerate(actions): + ob_b, rew_b, term_b, _, _ = env_base.step(action) + ob_f, rew_f, term_f, _, _ = env_future.step(action) + # The base env terminates at its own horizon; only compare while both + # envs are still inside the shared past window. + if term_b: + break + np.testing.assert_array_equal( + ob_f, ob_b, err_msg=f"future bars leaked into obs at step {step_idx}" + ) + assert rew_f == pytest.approx(rew_b), f"future bars leaked into reward at step {step_idx}" + + assert len(distinct_ts) > cut # sanity: there really were future bars to leak + + +def test_adapter_reset_does_not_surface_future_bars(): + """``reset()`` obs equals the obs computed from only the first bar's data.""" + + full = synthetic_candidates() + first_bar_only = _early_bars(full, 1) + + env_full = PortfolioSb3GymEnv(candidates=full) + env_first = PortfolioSb3GymEnv(candidates=first_bar_only) + + obs_full, _ = env_full.reset(seed=99) + obs_first, _ = env_first.reset(seed=99) + np.testing.assert_array_equal(obs_full, obs_first) diff --git a/tests/test_stom_rl_portfolio_sb3_train.py b/tests/test_stom_rl_portfolio_sb3_train.py new file mode 100644 index 000000000..39d221657 --- /dev/null +++ b/tests/test_stom_rl_portfolio_sb3_train.py @@ -0,0 +1,478 @@ +"""Stage-B tests: SB3 portfolio trainer + trained_ppo/supervised_ranker wiring. + +Most tests use a lightweight STUB model (no SB3 training) so the wiring, +leakage-guard, and masking logic run instantly. One bounded REAL-train test +proves determinism (V4) within the explicit ``atol=1e-6, rtol=1e-5`` tolerance; +it is kept tiny but still exercises the actual SB3 PPO path. +""" + +from __future__ import annotations + +import warnings + +import numpy as np +import pandas as pd +import pytest + +from stom_rl import portfolio_walk_forward as pwf +from stom_rl.portfolio_env import ACTION_HOLD, PortfolioEnv +from stom_rl.portfolio_sb3_train import ( + MASKABLE_PPO_INVALID_ACTION_TRIGGER, + _best_valid_action, + make_trained_policy_fn, +) + +warnings.filterwarnings("ignore", category=RuntimeWarning) + + +# --------------------------------------------------------------------------- # +# Fixtures: tiny holdout candidate frame (no DB dependency) + a stub model. +# --------------------------------------------------------------------------- # +def _holdout_candidates(n_steps: int = 12) -> pd.DataFrame: + """Two symbols, ``n_steps`` one-second bars, Page-9 T+1 fill contract.""" + + base = pd.Timestamp("2025-07-09 09:00:00") + rows = [] + for symbol, start in (("A", 100.0), ("B", 50.0)): + series = [start + step for step in range(n_steps)] + for t, price in enumerate(series): + fill = series[t + 1] if t + 1 < len(series) else float("nan") + rows.append( + { + "timestamp": (base + pd.Timedelta(seconds=t)).isoformat(), + "symbol": symbol, + "condition_id": "holdout_fixture", + "passed": True, + "rank_score": float((100 - t) if symbol == "A" else (50 - t)), + "price": float(price), + "fill_price": fill, + "fillable": not np.isnan(fill), + "feature_momentum": float(t), + } + ) + return pd.DataFrame(rows) + + +class _StubModel: + """Deterministic stand-in for an SB3 model. + + ``predict`` returns a fixed action (default: buy slot 1) regardless of obs, so + tests are instant and seed-independent while still exercising the obs-decode + + masking bridge. ``always_invalid`` makes it return an out-of-range action to + prove the masked fallback never executes an invalid action. + """ + + def __init__(self, action: int = 1, *, always_invalid: bool = False) -> None: + self._action = int(action) + self._always_invalid = bool(always_invalid) + + def predict(self, observation, deterministic: bool = True): + observation = np.asarray(observation) + action = 999 if self._always_invalid else self._action + return np.asarray(action), None + + +def _write_csv(frame: pd.DataFrame, path) -> str: + frame.to_csv(path, index=False, encoding="utf-8-sig") + return str(path) + + +# --------------------------------------------------------------------------- # +# trained_ppo wiring: factory delivers the model through _fit_policy. +# --------------------------------------------------------------------------- # +def test_trained_ppo_factory_delivers_model_through_fit_policy(tmp_path): + """A registered trained_ppo factory's PolicyFn runs through the holdout. + + Proves P0-2: ``_fit_policy`` consults ``TRAINED_POLICY_FACTORIES`` and the + trained branch runs BEFORE the ``del`` (the factory receives train_frame). + """ + + csv = _write_csv(_holdout_candidates(), tmp_path / "cands.csv") + seen = {} + + def _factory(*, train_frame, config, fold_index): + # The factory MUST receive a real train_frame (proves it ran before del). + seen["train_rows"] = int(len(train_frame)) + seen["fold_index"] = int(fold_index) + return make_trained_policy_fn(_StubModel(action=1)) + + pwf.register_trained_policy_factory("trained_ppo", _factory) + try: + cfg = pwf.PortfolioWalkForwardConfig( + candidate_path=csv, + n_folds=2, + max_steps_per_fold=8, + write_artifacts=False, + baselines=("no_trade", "trained_ppo"), + ) + payload = pwf.run_portfolio_walk_forward(cfg) + finally: + pwf.unregister_trained_policy_factory("trained_ppo") + + policies = {row["policy"] for row in payload["folds"]} + assert "trained_ppo" in policies + assert seen["train_rows"] > 0 # factory saw a non-empty TRAIN segment + # The trained policy actually traded (the stub buys), distinguishing it from + # no_trade — proving the model handoff reached the eval, not a no-op. + trained_trades = [row["trade_count"] for row in payload["folds"] if row["policy"] == "trained_ppo"] + assert any(tc > 0 for tc in trained_trades) + + +def test_fit_policy_trained_branch_runs_before_del(): + """Directly exercise ``_fit_policy`` for a trained key: it returns the factory's + PolicyFn (the train_frame is consumed, not deleted, for the trained branch).""" + + frame = _holdout_candidates() + frame["timestamp"] = pd.to_datetime(frame["timestamp"]) + captured = {} + + def _factory(*, train_frame, config, fold_index): + captured["len"] = len(train_frame) + return make_trained_policy_fn(_StubModel(action=1)) + + pwf.register_trained_policy_factory("trained_ppo", _factory) + try: + cfg = pwf.PortfolioWalkForwardConfig(write_artifacts=False) + policy_fn = pwf._fit_policy("trained_ppo", frame, cfg, 0) + finally: + pwf.unregister_trained_policy_factory("trained_ppo") + assert callable(policy_fn) + assert captured["len"] == len(frame) + + +def test_trained_policy_fn_is_deterministic_for_fixed_model(tmp_path): + """The obs-decode PolicyFn is deterministic given a fixed (stub) model.""" + + csv = _write_csv(_holdout_candidates(), tmp_path / "cands.csv") + + def _factory(*, train_frame, config, fold_index): + return make_trained_policy_fn(_StubModel(action=1)) + + pwf.register_trained_policy_factory("trained_ppo", _factory) + try: + cfg = pwf.PortfolioWalkForwardConfig( + candidate_path=csv, n_folds=2, max_steps_per_fold=8, + write_artifacts=False, baselines=("trained_ppo",), + ) + first = pwf.run_portfolio_walk_forward(cfg) + second = pwf.run_portfolio_walk_forward(cfg) + finally: + pwf.unregister_trained_policy_factory("trained_ppo") + assert first["folds"] == second["folds"] + + +# --------------------------------------------------------------------------- # +# Masking: the obs-decode PolicyFn never executes an invalid action. +# --------------------------------------------------------------------------- # +def test_best_valid_action_prefers_predicted_when_valid(): + assert _best_valid_action(1, [1, 1, 0, 0]) == 1 + + +def test_best_valid_action_falls_back_when_invalid(): + # predicted=2 is masked off -> first valid non-HOLD (index 1). + assert _best_valid_action(2, [1, 1, 0, 0]) == 1 + # nothing valid except HOLD -> HOLD. + assert _best_valid_action(3, [1, 0, 0, 0]) == ACTION_HOLD + # out-of-range prediction -> fallback. + assert _best_valid_action(999, [1, 1, 0, 0]) == 1 + + +def test_masked_policy_never_emits_invalid_action(tmp_path): + """An always-invalid stub model, wrapped, never executes a masked action.""" + + frame = _holdout_candidates() + env = PortfolioEnv(candidates=frame, top_k_candidates=3, max_positions=2) + _, info = env.reset(seed=1) + policy_fn = make_trained_policy_fn(_StubModel(always_invalid=True)) + terminated = truncated = False + steps = 0 + while not (terminated or truncated) and steps < 8: + mask = list(info["action_mask"]) + action = policy_fn(env, info) + assert mask[action] == 1, f"masked action {action} executed at step {steps}" + _, _, terminated, truncated, info = env.step(action) + steps += 1 + assert env.invalid_actions == [] # zero invalid actions reached the engine + + +# --------------------------------------------------------------------------- # +# supervised_ranker: TRAIN-fit, holdout-eval, no leakage. +# --------------------------------------------------------------------------- # +def test_parse_baselines_accepts_trained_and_ranker_keys(): + """_parse_baselines accepts trained_ppo + supervised_ranker (no ValueError).""" + + parsed = pwf._parse_baselines("no_trade,equal_weight_candidate,supervised_ranker,trained_ppo") + assert parsed == ("no_trade", "equal_weight_candidate", "supervised_ranker", "trained_ppo") + + +def test_parse_baselines_still_rejects_unknown_key(): + """Existing typo-guard is intact for genuinely unknown keys.""" + + with pytest.raises(ValueError, match="totally_bogus"): + pwf._parse_baselines("no_trade,totally_bogus") + + +def test_existing_default_baselines_unaffected(tmp_path): + """All six DEFAULT_BASELINES still run unchanged through the holdout.""" + + csv = _write_csv(_holdout_candidates(), tmp_path / "cands.csv") + cfg = pwf.PortfolioWalkForwardConfig( + candidate_path=csv, n_folds=2, max_steps_per_fold=8, write_artifacts=False + ) + payload = pwf.run_portfolio_walk_forward(cfg) + policies = {row["policy"] for row in payload["folds"]} + assert policies == set(pwf.DEFAULT_BASELINES) + + +def test_supervised_ranker_fits_on_train_and_runs_on_holdout(tmp_path): + """supervised_ranker selects candidates on the disjoint holdout (P1-5b).""" + + csv = _write_csv(_holdout_candidates(), tmp_path / "cands.csv") + cfg = pwf.PortfolioWalkForwardConfig( + candidate_path=csv, n_folds=2, max_steps_per_fold=8, + write_artifacts=False, baselines=("no_trade", "equal_weight_candidate", "supervised_ranker"), + ) + payload = pwf.run_portfolio_walk_forward(cfg) + ranker_rows = [row for row in payload["folds"] if row["policy"] == "supervised_ranker"] + assert ranker_rows, "supervised_ranker must appear in the fold report" + for row in ranker_rows: + assert row["cost_bps"] == 25.0 # explicit non-zero cost + assert row["total_cost"] >= 0.0 + + +def test_supervised_ranker_fit_does_not_see_test_segment(tmp_path): + """Leakage guard: perturbing the TEST tail must not change the fitted ranker. + + Build two frames identical on TRAIN but differing on the latest timestamps + (which fall in TEST). Because ``_fit_supervised_ranker`` is handed only the + train_frame, the fold-0 fit (whose train excludes the perturbed tail) must be + identical — proving the ranker never peeks at TEST data. + """ + + frame = _holdout_candidates(n_steps=12) + frame["timestamp"] = pd.to_datetime(frame["timestamp"]) + folds = pwf.build_expanding_window_folds(frame, n_folds=3) + fold0 = folds[0] + cfg = pwf.PortfolioWalkForwardConfig(write_artifacts=False) + + train_max = max(pd.Timestamp(ts) for ts in fold0.train_frame["timestamp"].unique()) + perturbed = frame.copy() + later = perturbed["timestamp"] > train_max + assert later.any() + perturbed.loc[later, "feature_momentum"] = perturbed.loc[later, "feature_momentum"] + 1000.0 + perturbed.loc[later, "price"] = perturbed.loc[later, "price"] * 3.0 + perturbed_fold0 = pwf.build_expanding_window_folds(perturbed, n_folds=3)[0] + + # fold0 TRAIN is byte-identical (perturbation was TEST-only). + pd.testing.assert_frame_equal( + fold0.train_frame.reset_index(drop=True), + perturbed_fold0.train_frame.reset_index(drop=True), + ) + + fit_clean = pwf._fit_supervised_ranker(train_frame=fold0.train_frame, config=cfg, fold_index=0) + fit_perturbed = pwf._fit_supervised_ranker( + train_frame=perturbed_fold0.train_frame, config=cfg, fold_index=0 + ) + # Same TRAIN => identical fit metadata, regardless of the future TEST tail. + assert fit_clean.frozen_params == fit_perturbed.frozen_params + # And the TRAIN-only labels are byte-identical (the fit never saw the tail). + X_clean, y_clean = pwf._next_bar_return_labels(fold0.train_frame) + X_pert, y_pert = pwf._next_bar_return_labels(perturbed_fold0.train_frame) + np.testing.assert_array_equal(X_clean, X_pert) + np.testing.assert_array_equal(y_clean, y_pert) + + +def test_supervised_ranker_label_uses_only_train_returns(tmp_path): + """The next-bar-return label is built from TRAIN rows only (no TEST shift).""" + + frame = _holdout_candidates(n_steps=6) + frame["timestamp"] = pd.to_datetime(frame["timestamp"]) + X, y = pwf._next_bar_return_labels(frame) + # Every symbol loses its last bar (no next price) -> rows = (n_steps-1)*2. + assert X.shape[0] == (6 - 1) * 2 + assert y.shape[0] == X.shape[0] + assert set(np.unique(y)).issubset({0, 1}) + + +# --------------------------------------------------------------------------- # +# Determinism (V4): bounded REAL SB3 train, reproducible within atol/rtol. +# --------------------------------------------------------------------------- # +def test_invalid_action_trigger_threshold_is_five_percent(): + assert MASKABLE_PPO_INVALID_ACTION_TRIGGER == 0.05 + + +# torch's DLL can fail to initialise inside the pytest process on Windows +# (WinError 1114 / c10.dll), an environment quirk the repo's other SB3 tests +# already sidestep by training in a SUBPROCESS and skipping on that signature +# (see tests/test_stom_rl_sb3_eval.py:61-80). We follow the same convention so +# the determinism gate exercises a REAL SB3 train without the in-process DLL flake. +_SKIP_MARKERS = ( + "ModuleNotFoundError", + "DLL load failed", + "WinError 1114", + "c10.dll", + "Error loading", +) + + +def _run_python(code: str): + import subprocess + import sys + + result = subprocess.run( + [sys.executable, "-c", code], text=True, capture_output=True, check=False + ) + combined = result.stderr + result.stdout + if result.returncode != 0 and any(marker in combined for marker in _SKIP_MARKERS): + pytest.skip(combined) + return result + + +def test_real_ppo_train_is_reproducible_within_tolerance(): + """Two trains, same seed/data => eval metrics agree within atol=1e-6, rtol=1e-5. + + Bounded to a tiny budget; the pins in ``apply_determinism`` + (use_deterministic_algorithms + single-thread + cpu) are what make this pass — + without them the gate is untestable (V4). Run in a subprocess to dodge the + Windows in-process torch DLL flake (skips on that signature, never silently). + """ + + code = ( + "import warnings; warnings.filterwarnings('ignore')\n" + "from stom_rl.portfolio_sb3_train import PortfolioSb3TrainConfig, assert_reproducible\n" + "cfg = PortfolioSb3TrainConfig(algorithm='ppo', total_timesteps=64, ppo_n_steps=16,\n" + " ppo_batch_size=8, ppo_n_epochs=1, write_artifacts=False)\n" + "res = assert_reproducible(cfg, atol=1e-6, rtol=1e-5)\n" + "assert res['reproducible'] is True, res\n" + "print('DETERMINISM_OK', res['folds_compared'])\n" + ) + result = _run_python(code) + assert result.returncode == 0, result.stderr + result.stdout + assert "DETERMINISM_OK" in (result.stdout + result.stderr) + + +# --------------------------------------------------------------------------- # +# Story A: SB3 training-loop telemetry -> live training dashboard stream. +# --------------------------------------------------------------------------- # +def test_training_event_config_flag_defaults_on(): + """``write_training_events`` is default-on (the "watch it learn" stream).""" + + from stom_rl.portfolio_sb3_train import PortfolioSb3TrainConfig + + assert PortfolioSb3TrainConfig().write_training_events is True + assert PortfolioSb3TrainConfig(write_training_events=False).write_training_events is False + + +def test_cli_no_training_events_flag_disables_stream(): + """``--no-training-events`` flips the config flag off without touching writes.""" + + from stom_rl.portfolio_sb3_train import _parse_args + + on, _ = _parse_args([]) + off, _ = _parse_args(["--no-training-events"]) + assert on.write_training_events is True + assert off.write_training_events is False + # The flag is independent of artifact writing (default-on write_artifacts). + assert off.write_artifacts is True + + +def test_real_ppo_train_emits_well_formed_live_events(tmp_path): + """A tiny REAL ``model.learn`` streams well-formed phase=train live events. + + Asserts (round-tripped via ``read_live_events``): + * >=1 event with ``phase=='train'`` and ``algorithm=='portfolio_ppo'``; + * ``global_step`` is monotonic non-decreasing and finite; + * every ``reward`` is finite (mean episode reward); + * the dashboard signature (sb3_smoke_summary.json) + rl_live_summary.json + are written so the run is follow/replay-visible. + + Subprocess + skip-on-DLL convention (see module header) so this exercises a + REAL SB3 train without the Windows in-process torch DLL flake. + """ + + out_dir = (tmp_path / "train_live").as_posix() + code = ( + "import warnings, json, math; warnings.filterwarnings('ignore')\n" + "from pathlib import Path\n" + "from stom_rl.portfolio_sb3_train import PortfolioSb3TrainConfig, train_and_save, SB3_SMOKE_SIGNATURE_FILE\n" + "from stom_rl.rl_events import read_live_events\n" + f"out = Path({out_dir!r})\n" + "cfg = PortfolioSb3TrainConfig(algorithm='ppo', total_timesteps=96, ppo_n_steps=32,\n" + " ppo_batch_size=8, ppo_n_epochs=1, output_dir=str(out))\n" + "summary = train_and_save(cfg)\n" + "rows, _ = read_live_events(out / 'rl_live_events.jsonl', limit=500)\n" + "train_rows = [r for r in rows if r.get('phase') == 'train']\n" + "assert train_rows, ('no phase=train events', rows)\n" + "assert all(r.get('algorithm') == 'portfolio_ppo' for r in train_rows), rows\n" + "steps = [int(r['global_step']) for r in train_rows]\n" + "assert steps == sorted(steps), ('global_step not monotonic', steps)\n" + "assert all(math.isfinite(float(r['reward'])) for r in train_rows), rows\n" + "assert (out / SB3_SMOKE_SIGNATURE_FILE).is_file(), 'missing dashboard signature'\n" + "assert (out / 'rl_live_summary.json').is_file(), 'missing live summary'\n" + "assert int(summary['live_events']['event_count']) == len(rows)\n" + "print('LIVE_EVENTS_OK', len(train_rows), steps[-1])\n" + ) + result = _run_python(code) + assert result.returncode == 0, result.stderr + result.stdout + assert "LIVE_EVENTS_OK" in (result.stdout + result.stderr) + + +def test_training_events_disabled_writes_no_jsonl(tmp_path): + """With ``write_training_events=False`` no live-event JSONL/signature appears. + + Proves the stream is opt-out and that the default-off path leaves the run dir + free of the live-event artifacts (so disabling truly disables). + """ + + out_dir = (tmp_path / "train_no_events").as_posix() + code = ( + "import warnings; warnings.filterwarnings('ignore')\n" + "from pathlib import Path\n" + "from stom_rl.portfolio_sb3_train import PortfolioSb3TrainConfig, train_and_save, SB3_SMOKE_SIGNATURE_FILE\n" + f"out = Path({out_dir!r})\n" + "cfg = PortfolioSb3TrainConfig(algorithm='ppo', total_timesteps=64, ppo_n_steps=16,\n" + " ppo_batch_size=8, ppo_n_epochs=1, output_dir=str(out), write_training_events=False)\n" + "summary = train_and_save(cfg)\n" + "assert not (out / 'rl_live_events.jsonl').is_file(), 'jsonl written while disabled'\n" + "assert not (out / SB3_SMOKE_SIGNATURE_FILE).is_file(), 'signature written while disabled'\n" + "assert 'live_events' not in summary, summary.keys()\n" + "print('NO_EVENTS_OK')\n" + ) + result = _run_python(code) + assert result.returncode == 0, result.stderr + result.stdout + assert "NO_EVENTS_OK" in (result.stdout + result.stderr) + + +def test_dashboard_serves_training_events(tmp_path): + """The trained run is dashboard-visible: /table/events serves phase=train rows. + + Trains into a temp ``rl_runs`` root, points the dashboard's RL_RUN_ROOTS at it, + and asserts the existing follow/replay route returns the live training stream + with no new ``/api/*`` surface. + """ + + runs_root = (tmp_path / "rl_runs").as_posix() + code = ( + "import warnings; warnings.filterwarnings('ignore')\n" + "import sys\n" + "from pathlib import Path\n" + "from stom_rl.portfolio_sb3_train import PortfolioSb3TrainConfig, train_and_save\n" + f"runs_root = Path({runs_root!r})\n" + "run_dir = runs_root / 'portfolio_train_dash'\n" + "cfg = PortfolioSb3TrainConfig(algorithm='ppo', total_timesteps=96, ppo_n_steps=32,\n" + " ppo_batch_size=8, ppo_n_epochs=1, output_dir=str(run_dir))\n" + "train_and_save(cfg)\n" + "sys.path.insert(0, 'webui')\n" + "import rl_dashboard as dash\n" + "dash.RL_RUN_ROOTS = [runs_root]\n" + "names = [r['name'] for r in dash.list_rl_runs()]\n" + "assert 'portfolio_train_dash' in names, names\n" + "tbl = dash.load_rl_table('portfolio_train_dash', 'events', limit=500)\n" + "assert tbl['row_count'] > 0, tbl\n" + "assert tbl['source_file'] == 'rl_live_events.jsonl', tbl\n" + "assert all(r.get('phase') == 'train' for r in tbl['rows']), tbl['rows']\n" + "print('DASH_OK', tbl['row_count'])\n" + ) + result = _run_python(code) + assert result.returncode == 0, result.stderr + result.stdout + assert "DASH_OK" in (result.stdout + result.stderr) diff --git a/tests/test_stom_rl_portfolio_train.py b/tests/test_stom_rl_portfolio_train.py new file mode 100644 index 000000000..9cf08db21 --- /dev/null +++ b/tests/test_stom_rl_portfolio_train.py @@ -0,0 +1,191 @@ +import json +import math + +import numpy as np +import pandas as pd + +from stom_rl.portfolio_train import PortfolioTrainConfig, run_portfolio_smoke +from stom_rl.rl_events import read_live_events + + +def _write_real_candidate_csv(path) -> "object": + """Write a Page 9-schema candidate CSV (with the T+1 fill contract). + + Mirrors the real ``stom_rl.candidate_gen`` output schema in-memory so the + train smoke can be exercised on a real-shaped CSV without touching the + multi-GB tick DB. + """ + + base = pd.Timestamp("2025-07-09 09:00:00") + prices = {"000100": [106100.0, 106200.0, 106000.0, 106300.0, 106500.0], + "000250": [158900.0, 159500.0, 159000.0, 159500.0, 160000.0]} + rows = [] + for symbol, series in prices.items(): + for t, price in enumerate(series): + fill = series[t + 1] if t + 1 < len(series) else float("nan") + rows.append( + { + "timestamp": (base + pd.Timedelta(seconds=t)).isoformat(), + "symbol": symbol, + "condition_id": "buy_demand_pressure", + "passed": True, + "rank_score": float(100 + t if symbol == "000250" else 50 + t), + "price": price, + "fill_price": fill, + "fillable": not np.isnan(fill), + "feature_trade_strength": float(120 + t), + "feature_bid_ask_imbalance": 0.6, + } + ) + pd.DataFrame(rows).to_csv(path, index=False, encoding="utf-8-sig") + return path + + +def test_portfolio_train_smoke_writes_core_artifacts(tmp_path): + payload = run_portfolio_smoke( + PortfolioTrainConfig(output_dir=str(tmp_path), max_steps=5, top_k_candidates=2, max_positions=1) + ) + + assert payload["summary"]["steps"] == 5 + assert payload["summary"]["action_space_n"] == 4 + assert (tmp_path / "portfolio_train_summary.json").is_file() + assert (tmp_path / "actions.csv").is_file() + assert (tmp_path / "trades.csv").is_file() + summary = json.loads((tmp_path / "portfolio_train_summary.json").read_text(encoding="utf-8-sig")) + assert summary["summary"]["final_nav"] > 0 + + +def test_portfolio_train_real_candidate_csv_is_deterministic(tmp_path): + """A real-shaped candidate CSV (T+1 fill) yields byte-identical artifacts + for a fixed seed across two runs.""" + + candidate_csv = _write_real_candidate_csv(tmp_path / "candidates.csv") + common = dict( + candidate_path=str(candidate_csv), + max_steps=8, + top_k_candidates=3, + max_positions=2, + seed=100, + ) + out_a = tmp_path / "run_a" + out_b = tmp_path / "run_b" + payload_a = run_portfolio_smoke(PortfolioTrainConfig(output_dir=str(out_a), **common)) + payload_b = run_portfolio_smoke(PortfolioTrainConfig(output_dir=str(out_b), **common)) + + # Deterministic summary + byte-identical NAV/trade artifacts. + assert payload_a["summary"]["final_nav"] == payload_b["summary"]["final_nav"] + assert payload_a["summary"]["trade_count"] == payload_b["summary"]["trade_count"] + assert (out_a / "nav.csv").read_bytes() == (out_b / "nav.csv").read_bytes() + assert (out_a / "trades.csv").read_bytes() == (out_b / "trades.csv").read_bytes() + # All required artifacts exist; invalid-action rate measurable. + for name in ("portfolio_train_summary.json", "actions.csv", "trades.csv", "nav.csv", "blocked_actions.json"): + assert (out_a / name).is_file() + assert payload_a["summary"]["invalid_action_count"] >= 0 + + +def test_portfolio_train_real_candidate_fills_use_t1_price(tmp_path): + """Trades executed on the real-shaped CSV fill at the T+1 price, never the + decision-bar close.""" + + candidate_csv = _write_real_candidate_csv(tmp_path / "candidates.csv") + run_portfolio_smoke( + PortfolioTrainConfig( + output_dir=str(tmp_path / "run"), + candidate_path=str(candidate_csv), + max_steps=8, + top_k_candidates=3, + max_positions=2, + seed=100, + ) + ) + trades = pd.read_csv(tmp_path / "run" / "trades.csv", encoding="utf-8-sig") + assert not trades.empty + source = pd.read_csv(candidate_csv, encoding="utf-8-sig") + source["symbol"] = source["symbol"].astype(str) + # Every buy price must match a fill_price (T+1), and never equal the same-row + # decision price unless that bar's next close was genuinely unchanged. + buys = trades[trades["side"] == "buy"] + assert not buys.empty + fill_prices = set(round(v, 4) for v in source["fill_price"].dropna()) + for price in buys["price"]: + assert round(float(price), 4) in fill_prices + + +def test_portfolio_train_writes_well_formed_live_events(tmp_path): + """The train smoke emits one live event per step, each carrying NAV as + ``equity`` with a monotonic ``global_step``; the JSONL round-trips via + ``read_live_events``.""" + + candidate_csv = _write_real_candidate_csv(tmp_path / "candidates.csv") + payload = run_portfolio_smoke( + PortfolioTrainConfig( + output_dir=str(tmp_path / "run"), + candidate_path=str(candidate_csv), + max_steps=4, + top_k_candidates=3, + max_positions=2, + seed=100, + ) + ) + steps = int(payload["summary"]["steps"]) + assert steps > 0 + assert payload["summary"]["live_event_count"] == steps + + events_path = tmp_path / "run" / "rl_live_events.jsonl" + assert events_path.is_file() + assert (tmp_path / "run" / "rl_live_summary.json").is_file() + + rows, _ = read_live_events(events_path, limit=500) + # One well-formed event per step. + assert len(rows) == steps + global_steps = [int(row["global_step"]) for row in rows] + assert global_steps == list(range(1, steps + 1)) # monotonic, 1-based + for row in rows: + assert row["algorithm"] == "portfolio" + assert row["phase"] == "train" + # NAV is mapped to equity and must be present + finite. + assert isinstance(row["equity"], (int, float)) + assert math.isfinite(float(row["equity"])) + assert float(row["equity"]) > 0 + # Per-step info carries the portfolio accounting context. + info = row["info"] + assert "nav" in info and "cash" in info and "trade_count" in info + assert info["nav"] == row["equity"] + assert isinstance(info["candidate_count"], int) + + +def test_portfolio_train_live_events_are_deterministic(tmp_path): + """A fixed seed yields byte-identical live-event JSONL across two runs.""" + + candidate_csv = _write_real_candidate_csv(tmp_path / "candidates.csv") + common = dict( + candidate_path=str(candidate_csv), + max_steps=6, + top_k_candidates=3, + max_positions=2, + seed=100, + ) + # Same run-dir basename so the embedded ``run_id`` matches; the only + # remaining variable is the seed, which is fixed -> byte-identical JSONL. + run_portfolio_smoke(PortfolioTrainConfig(output_dir=str(tmp_path / "a" / "run"), **common)) + run_portfolio_smoke(PortfolioTrainConfig(output_dir=str(tmp_path / "b" / "run"), **common)) + assert (tmp_path / "a" / "run" / "rl_live_events.jsonl").read_bytes() == ( + tmp_path / "b" / "run" / "rl_live_events.jsonl" + ).read_bytes() + + +def test_portfolio_train_no_live_events_flag_disables_emission(tmp_path): + """``write_live_events=False`` skips the JSONL while keeping core artifacts.""" + + payload = run_portfolio_smoke( + PortfolioTrainConfig( + output_dir=str(tmp_path), + max_steps=4, + top_k_candidates=2, + max_positions=1, + write_live_events=False, + ) + ) + assert not (tmp_path / "rl_live_events.jsonl").exists() + assert "live_event_count" not in payload["summary"] + assert (tmp_path / "portfolio_train_summary.json").is_file() diff --git a/tests/test_stom_rl_portfolio_walk_forward.py b/tests/test_stom_rl_portfolio_walk_forward.py new file mode 100644 index 000000000..29aac0e87 --- /dev/null +++ b/tests/test_stom_rl_portfolio_walk_forward.py @@ -0,0 +1,364 @@ +import numpy as np +import pandas as pd + +from stom_rl.portfolio_env import PortfolioEnv, PortfolioEnvConfig +from stom_rl.portfolio_walk_forward import ( + COST_AWARE_MIN_HOLD_STEPS, + PortfolioWalkForwardConfig, + _fit_cost_aware_policy, + build_expanding_window_folds, + run_portfolio_walk_forward, + shuffle_candidate_signal, +) + + +def _holdout_candidates(n_steps: int = 12) -> pd.DataFrame: + """Tiny in-memory candidate frame with many distinct timestamps. + + Two symbols, ``n_steps`` one-second bars each, carrying the Page 9 T+1 fill + contract (``price`` = close at T, ``fill_price`` = next-bar close, last bar + unfillable). Enough distinct timestamps for a real train/test split with no + DB dependency. + """ + + base = pd.Timestamp("2025-07-09 09:00:00") + rows = [] + for symbol, start in (("A", 100.0), ("B", 50.0)): + series = [start + step for step in range(n_steps)] + for t, price in enumerate(series): + fill = series[t + 1] if t + 1 < len(series) else float("nan") + rows.append( + { + "timestamp": (base + pd.Timedelta(seconds=t)).isoformat(), + "symbol": symbol, + "condition_id": "holdout_fixture", + "passed": True, + "rank_score": float((100 - t) if symbol == "A" else (50 - t)), + "price": float(price), + "fill_price": fill, + "fillable": not np.isnan(fill), + "feature_momentum": float(t), + } + ) + return pd.DataFrame(rows) + + +def _config(tmp_path, **overrides) -> PortfolioWalkForwardConfig: + params = dict(output_dir=str(tmp_path), n_folds=3, max_steps_per_fold=8, write_artifacts=False) + params.update(overrides) + return PortfolioWalkForwardConfig(**params) + + +def test_eval_segment_is_disjoint_and_strictly_later_than_train(): + """Every fold's TEST timestamps are disjoint from AND strictly later than TRAIN.""" + + frame = _holdout_candidates() + frame["timestamp"] = pd.to_datetime(frame["timestamp"]) + folds = build_expanding_window_folds(frame, n_folds=3) + + assert len(folds) >= 2 + for fold in folds: + train_ts = set(pd.Timestamp(ts) for ts in fold.train_frame["timestamp"].unique()) + test_ts = set(pd.Timestamp(ts) for ts in fold.test_frame["timestamp"].unique()) + assert train_ts and test_ts + # Disjoint: no shared timestamp between train and test (no in-sample eval). + assert not (train_ts & test_ts) + # Strictly later: the earliest test bar comes after the latest train bar. + assert min(test_ts) > max(train_ts) + # Expanding window: train of fold N contains train of fold N-1. + for earlier, later in zip(folds, folds[1:]): + earlier_train = set(pd.Timestamp(ts) for ts in earlier.train_frame["timestamp"].unique()) + later_train = set(pd.Timestamp(ts) for ts in later.train_frame["timestamp"].unique()) + assert earlier_train < later_train + + +def test_fold_split_is_deterministic(tmp_path): + """Same input + seed -> identical fold rows (split + eval determinism).""" + + config = _config(tmp_path) + first = run_portfolio_walk_forward(config) + second = run_portfolio_walk_forward(config) + assert first["fold_periods"] == second["fold_periods"] + assert first["folds"] == second["folds"] + + +def test_baseline_metrics_are_populated(tmp_path): + """The fold report carries the 5 baselines + costed metrics on the TEST segment.""" + + payload = run_portfolio_walk_forward(_config(tmp_path)) + + assert payload["summary"]["smoke_success"] is True + assert payload["summary"]["cost_bps"] == 25.0 + policies = {row["policy"] for row in payload["folds"]} + assert policies == { + "no_trade", + "equal_weight_candidate", + "buy_and_hold", + "rule_baseline", + "rl_baseline", + "cost_aware", + } + # Each fold period exposes explicit train/test ranges. + for period in payload["fold_periods"]: + assert period["train_end"] < period["test_start"] + assert period["test_candidate_count"] > 0 + # Costed metrics are present on every row, with cost_bps kept explicit. + for row in payload["folds"]: + for key in ("return_pct", "max_drawdown_pct", "turnover", "trade_count", "total_cost", "cost_bps"): + assert key in row + assert row["cost_bps"] == 25.0 + assert row["total_cost"] >= 0.0 + assert row["turnover"] >= 0.0 + + +def test_walk_forward_writes_fold_report_artifacts(tmp_path): + payload = run_portfolio_walk_forward(_config(tmp_path, write_artifacts=True)) + assert (tmp_path / "portfolio_walk_forward_report.json").is_file() + assert (tmp_path / "portfolio_walk_forward_folds.csv").is_file() + assert payload["summary"]["holdout"] == "expanding_window_train_le_N_eval_N_plus_1" + + +def _policy_returns(payload, policy): + return [row["return_pct"] for row in payload["folds"] if row["policy"] == policy] + + +def test_leakage_canary_future_column_does_not_change_holdout_eval(tmp_path): + """Backward-only canary: a forward-looking *column* must not alter results. + + We inject a column whose values are the *next bar's* price (``future_close``) + plus a perfect ``future_return`` — exactly the kind of forward-looking signal + a leaky pipeline would consume. The split geometry is untouched (same + timestamps), so any change in held-out metrics would be a pure leak. The + correct backward-only env ignores unknown future columns, so the fold report + is byte-identical with and without the injected future signal. + """ + + frame = _holdout_candidates(n_steps=12) + clean_csv = tmp_path / "clean_cols.csv" + frame.to_csv(clean_csv, index=False, encoding="utf-8-sig") + + leaky = frame.copy() + # Perfect foresight columns: the realised next-bar move, only knowable later. + leaky["future_close"] = leaky["fill_price"].fillna(leaky["price"]) + leaky["future_return"] = (leaky["future_close"] - leaky["price"]) / leaky["price"] + leaky_csv = tmp_path / "leaky_cols.csv" + leaky.to_csv(leaky_csv, index=False, encoding="utf-8-sig") + + clean_payload = run_portfolio_walk_forward(_config(tmp_path, candidate_path=str(clean_csv))) + leaky_payload = run_portfolio_walk_forward(_config(tmp_path, candidate_path=str(leaky_csv))) + + # Identical timestamps -> identical splits; backward-only eval ignores the + # future columns, so the held-out fold report is unchanged. If a leak were + # introduced (the env consuming future_return), these would diverge. + assert clean_payload["fold_periods"] == leaky_payload["fold_periods"] + assert clean_payload["folds"] == leaky_payload["folds"] + + +def test_leakage_canary_detects_forward_looking_corruption(tmp_path): + """The canary has teeth: corrupting the TEST segment's prices shifts results. + + This proves the harness is sensitive to the data it evaluates on, so a real + forward-looking leak (e.g. fills using a future price) would be *detected* + as a performance change rather than silently passing. + """ + + frame = _holdout_candidates(n_steps=12) + clean_csv = tmp_path / "clean.csv" + frame.to_csv(clean_csv, index=False, encoding="utf-8-sig") + clean = run_portfolio_walk_forward(_config(tmp_path, candidate_path=str(clean_csv))) + + corrupted = frame.copy() + # Inject a forward-looking shock into fill prices (a leak would raise fills). + corrupted["fill_price"] = corrupted["fill_price"] * 5.0 + corrupt_csv = tmp_path / "corrupt.csv" + corrupted.to_csv(corrupt_csv, index=False, encoding="utf-8-sig") + corrupt = run_portfolio_walk_forward(_config(tmp_path, candidate_path=str(corrupt_csv))) + + clean_returns = _policy_returns(clean, "rl_baseline") + corrupt_returns = _policy_returns(corrupt, "rl_baseline") + # Performance changes (collapses/inflates) under forward-looking corruption. + assert clean_returns != corrupt_returns + + +def test_cost_aware_policy_fits_on_train_and_freezes_params(tmp_path): + """The cost-aware policy is genuinely FIT on TRAIN, then frozen for TEST. + + The fold report carries the frozen (rank_score threshold, min-hold) params + on every cost_aware row, and the fitted min-hold is drawn from the bounded + TRAIN tuning grid. This proves a real fit happened on train (not a no-op) + before the disjoint, strictly-later TEST eval. + """ + + payload = run_portfolio_walk_forward(_config(tmp_path)) + cost_rows = [row for row in payload["folds"] if row["policy"] == "cost_aware"] + assert cost_rows, "cost_aware policy must appear in the fold report" + for row in cost_rows: + # Frozen params are present and concrete on every held-out fold. + assert row["fitted_score_threshold"] != "" + assert int(row["fitted_min_hold_steps"]) in COST_AWARE_MIN_HOLD_STEPS + # Costed metrics are still reported (honest comparison vs baselines). + assert row["cost_bps"] == 25.0 + assert row["total_cost"] >= 0.0 + + +def test_cost_aware_fit_uses_only_train_segment_no_test_leakage(tmp_path): + """Fitting only sees TRAIN: perturbing the TEST tail must not change the fit. + + We build two frames identical on the TRAIN portion but differing on the + last (latest) timestamp — which falls in a fold's TEST segment. Because + ``_fit_cost_aware_policy`` is handed only the train_frame, the frozen params + of the *earliest* fold (whose train excludes the perturbed tail) must be + byte-identical. Any change would mean the fitter peeked at TEST data. + """ + + frame = _holdout_candidates(n_steps=12) + frame["timestamp"] = pd.to_datetime(frame["timestamp"]) + folds = build_expanding_window_folds(frame, n_folds=3) + fold0 = folds[0] + config = _config(tmp_path) + + # Perturb only rows strictly later than fold0's train (i.e. its TEST/future). + train_max = max(pd.Timestamp(ts) for ts in fold0.train_frame["timestamp"].unique()) + perturbed = frame.copy() + later = perturbed["timestamp"] > train_max + assert later.any(), "fixture must have rows later than fold0 train" + perturbed.loc[later, "rank_score"] = perturbed.loc[later, "rank_score"] + 1000.0 + perturbed.loc[later, "price"] = perturbed.loc[later, "price"] * 3.0 + + perturbed_folds = build_expanding_window_folds(perturbed, n_folds=3) + perturbed_fold0 = perturbed_folds[0] + # fold0's TRAIN is untouched by a TEST-only perturbation. + pd.testing.assert_frame_equal( + fold0.train_frame.reset_index(drop=True), + perturbed_fold0.train_frame.reset_index(drop=True), + ) + + fit_clean = _fit_cost_aware_policy(fold0.train_frame, config, fold0.fold_index) + fit_perturbed = _fit_cost_aware_policy(perturbed_fold0.train_frame, config, perturbed_fold0.fold_index) + # Same TRAIN ⇒ identical frozen params, regardless of the future TEST tail. + assert fit_clean.frozen_params == fit_perturbed.frozen_params + + +def test_too_few_timestamps_raises(tmp_path): + """A single distinct timestamp cannot form a holdout split (fails loudly).""" + + frame = _holdout_candidates(n_steps=1) + frame["timestamp"] = pd.to_datetime(frame["timestamp"]) + try: + build_expanding_window_folds(frame, n_folds=3) + except ValueError as exc: + assert "two time segments" in str(exc) + else: # pragma: no cover - defensive + raise AssertionError("Expected ValueError for too few timestamps") + + +def _multi_candidate_frame(n_steps: int = 8) -> pd.DataFrame: + """Candidate frame with THREE symbols per timestamp (so selection is real). + + Each timestamp has 3 competing candidates with distinct rank_scores and a + feature column, so the env's top_k re-rank actually chooses between them — + the precondition for the shuffle test to be able to change selection. + """ + + base = pd.Timestamp("2025-08-30 09:00:00") + rows = [] + for sym_i, (symbol, start) in enumerate((("A", 100.0), ("B", 50.0), ("C", 75.0))): + series = [start + step + sym_i for step in range(n_steps)] + for t, price in enumerate(series): + fill = series[t + 1] if t + 1 < len(series) else float("nan") + rows.append( + { + "timestamp": (base + pd.Timedelta(seconds=t)).isoformat(), + "symbol": symbol, + "condition_id": "shuffle_fixture", + "passed": True, + # Distinct, symbol-separated scores so the top_k pick is unambiguous. + "rank_score": float((sym_i + 1) * 10 + t), + "price": float(price), + "fill_price": fill, + "fillable": not np.isnan(fill), + "feature_momentum": float(sym_i * 100 + t), + } + ) + return pd.DataFrame(rows) + + +def _env_topk_selection(frame: pd.DataFrame, top_k: int = 2) -> dict: + """Per-timestamp env top_k selection (symbol order after the env re-rank).""" + + env = PortfolioEnv( + PortfolioEnvConfig(top_k_candidates=top_k, max_positions=2, seed=100), + candidates=frame, + ) + selection = {} + for ts in sorted(env.candidates["timestamp"].unique()): + rows = env.candidates[env.candidates["timestamp"] == ts] + ordered = rows.sort_values(["rank_score", "symbol"], ascending=[False, True]).head(top_k) + selection[str(ts)] = list(ordered["symbol"]) + return selection + + +def test_shuffle_changes_candidate_selection_order(tmp_path): + """V8b: the shuffle (rank_score overwrite) yields a DIFFERENT top_k selection. + + Proves the shuffle actually perturbs selection and is not silently cancelled + by the env's re-rank (which sorts by rank_score before head(top_k)). At one + or more timestamps the shuffled frame must select a different symbol set. + """ + + frame = _multi_candidate_frame(n_steps=8) + shuffled = shuffle_candidate_signal(frame, seed=7) + + real_sel = _env_topk_selection(frame, top_k=2) + shuf_sel = _env_topk_selection(shuffled, top_k=2) + assert real_sel.keys() == shuf_sel.keys() + differing = [ts for ts in real_sel if real_sel[ts] != shuf_sel[ts]] + assert differing, "shuffle must change the top_k selection at >=1 timestamp" + + +def test_shuffle_keeps_price_fill_symbol_untouched(): + """The shuffle scrambles ONLY signal columns; fills/accounting stay identical. + + price / fill_price / symbol / timestamp / fillable are byte-identical to the + real frame (per row), so realised fills and cost accounting are unchanged — + only rank_score + feature_* are permuted within each timestamp. + """ + + frame = _multi_candidate_frame(n_steps=8) + shuffled = shuffle_candidate_signal(frame, seed=3) + + # Normalize the timestamp dtype (the shuffle parses to datetime) and align on + # the stable (timestamp, symbol) key — each pair is unique in the fixture. + a = frame.copy() + a["timestamp"] = pd.to_datetime(a["timestamp"]) + key = ["timestamp", "symbol"] + a = a.set_index(key).sort_index() + b = shuffled.set_index(key).sort_index() + for col in ("price", "fill_price", "fillable"): + pd.testing.assert_series_equal(a[col], b[col], check_names=False) + # The signal columns DO change for at least some rows (it really shuffled). + assert not a["rank_score"].equals(b["rank_score"]) + + +def test_shuffle_is_deterministic_for_a_fixed_seed(): + """Same seed -> identical shuffle (auditable, reproducible §7 sanity check).""" + + frame = _multi_candidate_frame(n_steps=8) + first = shuffle_candidate_signal(frame, seed=42) + second = shuffle_candidate_signal(frame, seed=42) + pd.testing.assert_frame_equal(first, second) + + +def test_shuffle_signal_runs_end_to_end_through_walk_forward(tmp_path): + """The --shuffle-signal path runs the full holdout and flags it in the summary.""" + + frame = _multi_candidate_frame(n_steps=12) + csv = tmp_path / "multi.csv" + frame.to_csv(csv, index=False, encoding="utf-8-sig") + + payload = run_portfolio_walk_forward( + _config(tmp_path, candidate_path=str(csv), shuffle_signal=True, shuffle_seed=11) + ) + assert payload["summary"]["shuffle_signal"] is True + assert payload["summary"]["shuffle_seed"] == 11 + assert payload["summary"]["smoke_success"] is True diff --git a/tests/test_stom_rl_predictability_probe.py b/tests/test_stom_rl_predictability_probe.py new file mode 100644 index 000000000..b531deea4 --- /dev/null +++ b/tests/test_stom_rl_predictability_probe.py @@ -0,0 +1,88 @@ +"""Unit tests for the predictability gate P0+P1 (pure stats, no DB). + +RULE strategy, NOT reinforcement learning. Covers MinTRL known values and the +walk-forward probe's discrimination: a PLANTED linear signal must yield GO (IC CI +> 0, net-of-cost DSR > 0.95) while pure NOISE must yield NO-GO (IC CI includes 0). +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from stom_rl.predictability_probe import min_track_record_length, run_probe + + +def _approx(value, expected, tol=1e-2): + return abs(value - expected) <= tol + + +# --------------------------------------------------------------------------- +# P0 — MinTRL +# --------------------------------------------------------------------------- +def test_min_track_record_length_known_value(): + # SR=0.1, normal (kurt=3), prob .95: + # num = 1 - 0 + (3-1)/4*0.1^2 = 1.005 ; (Z.95/0.1)^2 = (1.6448536/0.1)^2 = 270.554 + # MinTRL = 1 + 1.005*270.554 = 272.91 + assert _approx(min_track_record_length(0.1), 272.91, tol=0.05) + + +def test_min_track_record_length_infinite_when_not_above_benchmark(): + assert math.isinf(min_track_record_length(0.0)) + assert math.isinf(min_track_record_length(0.1, benchmark_sharpe=0.2)) + + +def test_min_track_record_length_decreases_with_sharpe(): + assert min_track_record_length(0.2) < min_track_record_length(0.1) + + +# --------------------------------------------------------------------------- +# P1 — probe discrimination on synthetic data +# --------------------------------------------------------------------------- +def _dataset(signal: float, *, seed: int = 0, n_groups: int = 300, spg: int = 10, + n_dates: int = 40, d: int = 6): + rng = np.random.default_rng(seed) + n = n_groups * spg + X = rng.standard_normal((n, d)) + datepool = sorted("2023%02d%02d" % (1 + (i // 28), 1 + (i % 28)) for i in range(n_dates)) + gids, dts, secs = [], [], [] + for g in range(n_groups): + dt = datepool[g % n_dates] + for s in range(spg): + gids.append("g%d" % g) + dts.append(dt) + secs.append(10.0 * (s + 1)) + noise = rng.standard_normal(n) + y = (signal * X[:, 0] + 0.5 * noise) if signal > 0 else noise.copy() + return X, y, dts, np.array(gids), np.array(secs, dtype=float) + + +def test_probe_detects_planted_signal_GO(): + X, y, dts, gids, secs = _dataset(signal=2.0, seed=1) + res = run_probe(X, y, dts, gids, secs, n_bootstrap=200, rng_seed=1) + ridge = res["models"]["ridge"] + assert ridge["ic_primary"] > 0.3 + assert ridge["ic_ci_excludes_zero"] is True + assert res["verdict"] == "GO" + + +def test_probe_rejects_pure_noise_NOGO(): + X, y, dts, gids, secs = _dataset(signal=0.0, seed=2) + res = run_probe(X, y, dts, gids, secs, n_bootstrap=200, rng_seed=2) + # No real signal -> IC CI should include zero for both models -> NO-GO. + for name in ("ridge", "gbm"): + lo, hi = res["models"][name]["ic_ci95"] + assert lo <= 0.0 <= hi + assert res["verdict"] == "NO-GO" + + +def test_probe_reports_structure(): + X, y, dts, gids, secs = _dataset(signal=1.0, seed=3) + res = run_probe(X, y, dts, gids, secs, n_bootstrap=100, rng_seed=3) + assert set(res["models"].keys()) == {"ridge", "gbm"} + assert res["n_trials"] == 2 * 5 + assert res["verdict"] in {"GO", "NO-GO"} + for d in res["per_boundary_ic"].values(): + assert len(d) == 5 # one IC per boundary diff --git a/tests/test_stom_rl_risk_gate.py b/tests/test_stom_rl_risk_gate.py new file mode 100644 index 000000000..7156ec027 --- /dev/null +++ b/tests/test_stom_rl_risk_gate.py @@ -0,0 +1,20 @@ +from stom_rl.risk_gate import RiskGate, RiskGateConfig, RiskState + + +def test_risk_gate_blocks_drawdown_position_count_and_daily_trades(): + gate = RiskGate(RiskGateConfig(max_drawdown_pct=5.0, max_daily_trades=1, max_positions=1)) + state = RiskState(peak_nav=100.0, daily_trade_count=1) + + drawdown = gate.evaluate(action_type="buy", nav=94.0, position_count=0, state=state) + assert drawdown["allowed"] is False + assert drawdown["reason"] == "max_drawdown" + + state = RiskState(peak_nav=100.0, daily_trade_count=1) + daily = gate.evaluate(action_type="buy", nav=100.0, position_count=0, state=state) + assert daily["allowed"] is False + assert daily["reason"] == "daily_trade_count" + + state = RiskState(peak_nav=100.0, daily_trade_count=0) + positions = gate.evaluate(action_type="buy", nav=100.0, position_count=1, state=state) + assert positions["allowed"] is False + assert positions["reason"] == "max_positions" diff --git a/tests/test_stom_rl_sb3_adapter.py b/tests/test_stom_rl_sb3_adapter.py new file mode 100644 index 000000000..fda3fedd0 --- /dev/null +++ b/tests/test_stom_rl_sb3_adapter.py @@ -0,0 +1,135 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pytest + + +def _write_sb3_fixture(tmp_path: Path, *, rows: int = 32) -> Path: + csv_dir = tmp_path / "qlib_csv" + csv_dir.mkdir(parents=True, exist_ok=True) + base = pd.Timestamp("2025-01-03 09:00:00") + csv_paths = [] + for symbol, session in [("KR000001", "20250103"), ("KR000002", "20250106")]: + csv_path = csv_dir / f"{symbol}_{session}.csv" + frame = pd.DataFrame( + { + "symbol": symbol, + "date": [(base + pd.Timedelta(seconds=i)).strftime("%Y-%m-%d %H:%M:%S") for i in range(rows)], + "open": [100.0 + i * 0.1 for i in range(rows)], + "high": [100.1 + i * 0.1 for i in range(rows)], + "low": [99.9 + i * 0.1 for i in range(rows)], + "close": [100.0 + i * 0.1 for i in range(rows)], + "volume": [10.0 + i for i in range(rows)], + "amount": [(100.0 + i * 0.1) * (10.0 + i) for i in range(rows)], + } + ) + frame.to_csv(csv_path, index=False, encoding="utf-8-sig") + csv_paths.append((csv_path, symbol, session)) + + episodes = [] + for idx, (csv_path, symbol, session) in enumerate(csv_paths): + episodes.append( + { + "episode_id": f"{symbol}_{session}", + "symbol": symbol, + "instrument": symbol, + "session": session, + "split": "train" if idx == 0 else "test", + "time_start": "090000", + "time_end": "090031", + "lookback_window": 5, + "reward_horizon_seconds": 3, + "row_count": rows, + "source_csv": str(csv_path), + } + ) + manifest_path = tmp_path / "episode_manifest.json" + manifest_path.write_text( + json.dumps({"mode": "stom_rl_episode_manifest", "summary": {"episode_count": 2}, "episodes": episodes}), + encoding="utf-8-sig", + ) + return manifest_path + + +def _run_python(code: str) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + [sys.executable, "-c", code], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0 and "ModuleNotFoundError" in (result.stderr + result.stdout): + pytest.skip(result.stderr or result.stdout) + return result + + +def test_stom_gymnasium_adapter_passes_sb3_check_env(tmp_path): + manifest_path = _write_sb3_fixture(tmp_path) + + result = _run_python( + f""" +import json +from stable_baselines3.common.env_checker import check_env +from stom_rl.sb3_adapter import make_sb3_env + +env = make_sb3_env( + {str(manifest_path)!r}, + split="train", + episode_index=0, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, +) +check_env(env, warn=True, skip_render_check=True) +observation, info = env.reset(seed=7) +print(json.dumps({{"shape": list(observation.shape), "contains": env.observation_space.contains(observation), "no_future": info["no_future_observation"]}})) +""" + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout.strip().splitlines()[-1]) + assert payload == {"shape": [5, 9], "contains": True, "no_future": True} + + +def test_sb3_smoke_runner_writes_dqn_and_ppo_summary(tmp_path): + manifest_path = _write_sb3_fixture(tmp_path) + output_dir = tmp_path / "sb3_out" + + result = _run_python( + f""" +import json +from stom_rl.sb3_smoke import Sb3SmokeConfig, run_sb3_smoke + +payload = run_sb3_smoke( + Sb3SmokeConfig( + manifest_path={str(manifest_path)!r}, + output_dir={str(output_dir)!r}, + algorithms=("dqn", "ppo"), + total_timesteps=8, + max_eval_episodes=1, + max_eval_steps_per_episode=6, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + device="cpu", + ) +) +print(json.dumps({{"passed": payload["check_env"]["passed"], "algorithms": [row["algorithm"] for row in payload["models"]]}})) +""" + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout.strip().splitlines()[-1]) + assert payload["passed"] is True + assert set(payload["algorithms"]) == {"dqn", "ppo"} + assert (tmp_path / "sb3_out" / "sb3_smoke_summary.json").is_file() + assert (tmp_path / "sb3_out" / "rl_live_events.jsonl").is_file() + assert (tmp_path / "sb3_out" / "rl_live_summary.json").is_file() + summary = json.loads((tmp_path / "sb3_out" / "sb3_smoke_summary.json").read_text(encoding="utf-8-sig")) + assert summary["summary"]["live_event_count"] > 0 + assert "eval" in summary["summary"]["live_event_phases"] + assert (tmp_path / "sb3_out" / "dqn_model.zip").is_file() + assert (tmp_path / "sb3_out" / "ppo_model.zip").is_file() diff --git a/tests/test_stom_rl_sb3_eval.py b/tests/test_stom_rl_sb3_eval.py new file mode 100644 index 000000000..19e38e9a4 --- /dev/null +++ b/tests/test_stom_rl_sb3_eval.py @@ -0,0 +1,266 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pytest + + +def _write_sb3_fixture(tmp_path: Path, *, rows: int = 32) -> Path: + csv_dir = tmp_path / "qlib_csv" + csv_dir.mkdir(parents=True, exist_ok=True) + base = pd.Timestamp("2025-01-03 09:00:00") + csv_paths = [] + for symbol, session in [ + ("KR000001", "20250103"), + ("KR000002", "20250106"), + ("KR000003", "20250107"), + ]: + csv_path = csv_dir / f"{symbol}_{session}.csv" + frame = pd.DataFrame( + { + "symbol": symbol, + "date": [(base + pd.Timedelta(seconds=i)).strftime("%Y-%m-%d %H:%M:%S") for i in range(rows)], + "open": [100.0 + i * 0.1 for i in range(rows)], + "high": [100.1 + i * 0.1 for i in range(rows)], + "low": [99.9 + i * 0.1 for i in range(rows)], + "close": [100.0 + i * 0.1 for i in range(rows)], + "volume": [10.0 + i for i in range(rows)], + "amount": [(100.0 + i * 0.1) * (10.0 + i) for i in range(rows)], + } + ) + frame.to_csv(csv_path, index=False, encoding="utf-8-sig") + csv_paths.append((csv_path, symbol, session)) + + episodes = [] + for idx, (csv_path, symbol, session) in enumerate(csv_paths): + episodes.append( + { + "episode_id": f"{symbol}_{session}", + "symbol": symbol, + "instrument": symbol, + "session": session, + "split": "train" if idx == 0 else "test", + "time_start": "090000", + "time_end": "090031", + "lookback_window": 5, + "reward_horizon_seconds": 3, + "row_count": rows, + "source_csv": str(csv_path), + } + ) + manifest_path = tmp_path / "episode_manifest.json" + manifest_path.write_text( + json.dumps({"mode": "stom_rl_episode_manifest", "summary": {"episode_count": 3}, "episodes": episodes}), + encoding="utf-8-sig", + ) + return manifest_path + + +_SKIP_MARKERS = ( + "ModuleNotFoundError", + "DLL load failed", + "WinError 1114", + "c10.dll", + "Error loading", +) + + +def _run_python(code: str) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + [sys.executable, "-c", code], + text=True, + capture_output=True, + check=False, + ) + combined = result.stderr + result.stdout + if result.returncode != 0 and any(marker in combined for marker in _SKIP_MARKERS): + pytest.skip(combined) + return result + + +def test_sb3_eval_reuses_saved_model_without_retraining(tmp_path): + manifest_path = _write_sb3_fixture(tmp_path) + train_dir = tmp_path / "sb3_train" + eval_dir = tmp_path / "sb3_eval" + + result = _run_python( + f""" +import json +from stom_rl.sb3_smoke import Sb3SmokeConfig, run_sb3_smoke +from stom_rl.sb3_eval import Sb3EvalConfig, run_sb3_eval + +run_sb3_smoke( + Sb3SmokeConfig( + manifest_path={str(manifest_path)!r}, + output_dir={str(train_dir)!r}, + algorithms=("dqn",), + total_timesteps=64, + max_eval_episodes=1, + max_eval_steps_per_episode=6, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + device="cpu", + ) +) + +payload = run_sb3_eval( + Sb3EvalConfig( + model_dir={str(train_dir)!r}, + algorithms=("dqn",), + eval_episodes=2, + max_eval_steps_per_episode=6, + manifest_path={str(manifest_path)!r}, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + device="cpu", + output_dir={str(eval_dir)!r}, + ) +) +row = payload["models"][0] +print(json.dumps({{ + "mode": payload["mode"], + "eval_only_summary": payload["summary"]["eval_only"], + "model": row["model"], + "eval_only": row["eval_only"], + "episode_count": row["episode_count"], + "training_elapsed_seconds": row["training_elapsed_seconds"], + "source_model": row["source_model"], + "training_timesteps": row["training_timesteps"], +}})) +""" + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout.strip().splitlines()[-1]) + assert payload["mode"] == "stom_rl_sb3_eval" + assert payload["eval_only_summary"] is True + assert payload["eval_only"] is True + assert payload["model"].endswith("_eval") + assert payload["episode_count"] == 2 + assert payload["training_elapsed_seconds"] == 0.0 + assert payload["training_timesteps"] == 64 + assert payload["source_model"] == "dqn_smoke" + + assert (eval_dir / "sb3_smoke_summary.json").is_file() + assert (eval_dir / "sb3_smoke_summary.csv").is_file() + assert (eval_dir / "actions.csv").is_file() + assert (eval_dir / "trades.csv").is_file() + assert (eval_dir / "equity.csv").is_file() + assert (eval_dir / "episodes.csv").is_file() + assert (eval_dir / "rl_live_events.jsonl").is_file() + assert (eval_dir / "rl_live_summary.json").is_file() + + +def test_sb3_eval_run_is_distinct_leaderboard_row(tmp_path): + manifest_path = _write_sb3_fixture(tmp_path) + rl_root = tmp_path / "webui" / "rl_runs" + train_dir = rl_root / "stom_1s_2025_sb3_50k" + eval_dir = rl_root / "stom_1s_2025_sb3_50k_eval2" + + result = _run_python( + f""" +import json +from stom_rl.sb3_smoke import Sb3SmokeConfig, run_sb3_smoke +from stom_rl.sb3_eval import Sb3EvalConfig, run_sb3_eval + +# Train a tiny model but stamp a large source training_timesteps so the +# leaderboard derives a distinct 'dqn_50k' style name. +run_sb3_smoke( + Sb3SmokeConfig( + manifest_path={str(manifest_path)!r}, + output_dir={str(train_dir)!r}, + algorithms=("dqn",), + total_timesteps=64, + max_eval_episodes=1, + max_eval_steps_per_episode=6, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + device="cpu", + ) +) + +# Rewrite the source summary's training_timesteps to 50000 to emulate a real +# 50k training run that produced the saved model. +summary_path = {str(train_dir / "sb3_smoke_summary.json")!r} +summary = json.loads(open(summary_path, encoding="utf-8-sig").read()) +for model_row in summary["models"]: + model_row["training_timesteps"] = 50000 +open(summary_path, "w", encoding="utf-8-sig").write(json.dumps(summary, ensure_ascii=False)) + +run_sb3_eval( + Sb3EvalConfig( + model_dir={str(train_dir)!r}, + algorithms=("dqn",), + eval_episodes=2, + max_eval_steps_per_episode=6, + manifest_path={str(manifest_path)!r}, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + device="cpu", + output_dir={str(eval_dir)!r}, + ) +) +print("OK") +""" + ) + + assert result.returncode == 0, result.stderr + + # Build a leaderboard discovering the rl_root; both the training run and the + # eval run live under it and should appear as distinct rows. + from stom_rl.performance_leaderboard import ( + PerformanceLeaderboardConfig, + build_performance_leaderboard, + ) + + baseline_dir = tmp_path / "baseline" + baseline_dir.mkdir() + baseline_report = baseline_dir / "leaderboard_report.json" + baseline_report.write_text( + json.dumps( + { + "summary": { + "target_rows": [ + {"policy": "buy_and_hold", "avg_episode_net_return_pct": 0.5, "episode_count": 1}, + {"policy": "no_trade", "avg_episode_net_return_pct": 0.0, "episode_count": 1}, + ] + } + } + ), + encoding="utf-8", + ) + bandit_dir = tmp_path / "bandit" + bandit_dir.mkdir() + bandit_report = bandit_dir / "eval_summary.json" + bandit_report.write_text( + json.dumps({"eval_summary": {"summary": {"policy": "contextual_bandit", "avg_episode_net_return_pct": 0.1}}}), + encoding="utf-8", + ) + + payload = build_performance_leaderboard( + PerformanceLeaderboardConfig( + baseline_report=str(baseline_report), + contextual_bandit_report=str(bandit_report), + sb3_smoke_reports=("auto",), + sb3_report_root=str(rl_root), + output_dir=str(tmp_path / "out"), + ) + ) + + rows = {row["model"]: row for row in payload["leaderboard"]} + eval_rows = [row for row in payload["leaderboard"] if row.get("eval_only")] + assert len(eval_rows) == 1 + eval_row = eval_rows[0] + assert eval_row["model"].endswith("_eval") + assert eval_row["is_smoke"] is False + assert "dqn_50k" in rows + assert "dqn_50k_eval" in rows + # Training row and eval row are distinct, non-colliding leaderboard entries. + assert eval_row["model"] != "dqn_50k" + assert payload["summary"]["rl_eval_only_models"] == ["dqn_50k_eval"] diff --git a/tests/test_stom_rl_skip_gate.py b/tests/test_stom_rl_skip_gate.py new file mode 100644 index 000000000..edf604d57 --- /dev/null +++ b/tests/test_stom_rl_skip_gate.py @@ -0,0 +1,172 @@ +"""Unit tests for STOM experiment ① — entry skip-gate. + +RULE strategy, NOT reinforcement learning. These tests use synthetic arrays only +so they prove the accounting and controls before any DB/full-universe run. +""" + +from __future__ import annotations + +import numpy as np + +from stom_rl.skip_gate import ( + apply_negative_control_gate, + bottom_fraction_mask, + run_skip_gate, + score_skip_policy, + select_skip_fraction, +) + + +def _dates(n_dates: int) -> list[str]: + return ["2023%02d%02d" % (1 + i // 28, 1 + i % 28) for i in range(n_dates)] + + +def _dataset( + *, + seed: int, + signal: float, + n_symbols: int = 40, + n_dates: int = 50, + d: int = 6, + positive_shift: float = 0.0, +): + rng = np.random.default_rng(seed) + X, net, dates, groups = [], [], [], [] + for dt in _dates(n_dates): + for sym in range(n_symbols): + x = rng.standard_normal(d) + realized = positive_shift + signal * x[0] + rng.normal(0.0, 0.15) + X.append(x) + net.append(realized) + dates.append(dt) + groups.append("s%03d_%s" % (sym, dt)) + return np.array(X), np.array(net), dates, groups + + +def test_bottom_fraction_mask_skips_exact_rank_count(): + mask = bottom_fraction_mask([3.0, 1.0, 2.0, 0.0, 4.0], 0.4) + assert mask.tolist() == [False, True, False, True, False] + + +def test_score_skip_policy_baseline_relative_accounting(): + scored = score_skip_policy([1.0, -2.0, 0.5], [False, True, False], ["a", "b", "c"]) + assert scored["baseline_total_net_pct"] == -0.5 + assert scored["policy_total_net_pct"] == 1.5 + assert scored["incremental_total_pct"] == 2.0 + assert scored["incremental_mean_pct"] == 2.0 / 3.0 + assert scored["skipped_net_mean_pct"] == -2.0 + assert scored["drift_trap_ok"] is True + + +def test_select_skip_fraction_uses_train_net_only(): + pred_train = np.array([0.0, 1.0, 2.0, 3.0]) + net_train = np.array([-5.0, -4.0, 3.0, 3.0]) + selected = select_skip_fraction(pred_train, net_train, (0.25, 0.5)) + assert selected["selected_skip_fraction"] == 0.5 + # A different test set would prefer 0.25, proving the selector itself is + # train-local and not allowed to inspect test performance. + pred_test = np.array([0.0, 1.0, 2.0, 3.0]) + net_test = np.array([-5.0, 4.0, 4.0, 4.0]) + assert select_skip_fraction(pred_test, net_test, (0.25, 0.5))["selected_skip_fraction"] == 0.25 + + +def test_skip_gate_go_on_planted_money_losing_slice(): + X, net, dates, groups = _dataset(seed=1, signal=1.5) + res = run_skip_gate( + X, + net, + dates, + groups, + n_bootstrap=200, + rng_seed=1, + n_trials=1, + external_sharpe_variance=0.0, + ) + assert res["verdict"] == "GO" + assert any(m.get("go") for m in res["models"].values()) + assert any(m.get("skipped_net_mean_pct") < 0 for m in res["models"].values()) + + +def test_skip_gate_no_go_on_noise(): + X, _, dates, groups = _dataset(seed=2, signal=0.0) + rng = np.random.default_rng(22) + net = rng.normal(0.0, 1.0, len(dates)) + res = run_skip_gate( + X, + net, + dates, + groups, + n_bootstrap=200, + rng_seed=2, + ) + assert res["verdict"] == "NO-GO" + assert not any(m.get("go") for m in res["models"].values()) + + +def test_skip_gate_blocks_drift_trap_when_skipped_net_is_positive(): + X, net, dates, groups = _dataset(seed=3, signal=0.05, positive_shift=0.75) + res = run_skip_gate( + X, + net, + dates, + groups, + n_bootstrap=200, + rng_seed=3, + n_trials=1, + external_sharpe_variance=0.0, + ) + assert res["verdict"] == "NO-GO" + assert res["models"] + assert all(m.get("drift_trap_ok") is False for m in res["models"].values()) + assert all((m.get("skipped_net_mean_pct") or 0.0) >= 0.0 for m in res["models"].values()) + + +def test_skip_gate_reports_required_structure(): + X, net, dates, groups = _dataset(seed=4, signal=0.0) + res = run_skip_gate(X, net, dates, groups, n_bootstrap=50, rng_seed=4) + assert { + "n_samples", + "n_groups", + "n_dates", + "primary_boundary", + "skip_fractions", + "per_boundary_incremental_mean", + "models", + "symbol_disjoint", + "verdict", + }.issubset(res.keys()) + assert set(res["models"].keys()) == {"ridge", "gbm"} + for model in res["models"].values(): + assert { + "selected_skip_fraction", + "incremental_mean_pct", + "incremental_ci95", + "incremental_dsr", + "skipped_net_mean_pct", + "policy_mean_net_pct", + "baseline_mean_net_pct", + "go", + }.issubset(model.keys()) + + +def test_negative_control_gate_blocks_primary_go(): + primary = {"verdict": "GO", "models": {"ridge": {"go": True}}} + negative = { + "verdict": "GO", + "models": {"ridge": {"go": True, "incremental_mean_pct": 1.0, "skipped_net_mean_pct": -2.0}}, + } + gated = apply_negative_control_gate(primary, negative) + assert gated["verdict_before_negative_control"] == "GO" + assert gated["verdict"] == "NO-GO" + assert gated["negative_control_passed"] is False + assert gated["negative_control_blocked_go"] is True + assert gated["go_block_reason"] == "negative_control_not_no_go" + + +def test_negative_control_gate_preserves_go_when_control_no_go(): + primary = {"verdict": "GO", "models": {"ridge": {"go": True}}} + negative = {"verdict": "NO-GO", "models": {"ridge": {"go": False}}} + gated = apply_negative_control_gate(primary, negative) + assert gated["verdict"] == "GO" + assert gated["negative_control_passed"] is True + assert gated["negative_control_blocked_go"] is False diff --git a/tests/test_stom_rl_sl_predictor.py b/tests/test_stom_rl_sl_predictor.py new file mode 100644 index 000000000..cd05610f2 --- /dev/null +++ b/tests/test_stom_rl_sl_predictor.py @@ -0,0 +1,91 @@ +"""Unit tests for experiment 3 — SL-predictability precursor gate. + +RULE strategy, NOT reinforcement learning. Pure-function checks: the rule exit +reason (tp/sl/time + SL-on-tie), and the gate's verdicts on a PLANTED separable +SL signal (must be PREDICTABLE) vs pure noise (must be AT-CHANCE). No DB / no I/O. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from stom_rl.sl_predictor import rule_exit_reason, run_sl_gate + + +# --------------------------------------------------------------------------- +# rule_exit_reason +# --------------------------------------------------------------------------- +def test_exit_reason_tp(): + reason, idx = rule_exit_reason([100.0, 102.0, 105.0], [0, 1, 2], tp_pct=5.0, sl_pct=1.0) + assert reason == "tp" and idx == 2 + + +def test_exit_reason_sl(): + reason, idx = rule_exit_reason([100.0, 100.5, 99.0], [0, 1, 2], tp_pct=5.0, sl_pct=1.0) + assert reason == "sl" and idx == 2 + + +def test_exit_reason_time(): + reason, idx = rule_exit_reason([100.0, 100.5, 101.0], [0, 1, 2], tp_pct=5.0, sl_pct=1.0) + assert reason == "time" and idx == 2 + + +def test_exit_reason_sl_wins_on_first_breach_order(): + # SL is checked before TP per scan step; a bar at exactly -1% stops out. + reason, idx = rule_exit_reason([100.0, 99.0], [0, 1], tp_pct=5.0, sl_pct=1.0) + assert reason == "sl" and idx == 1 + + +def test_exit_reason_rejects_bad_inputs(): + with pytest.raises(ValueError): + rule_exit_reason([], []) + with pytest.raises(ValueError): + rule_exit_reason([0.0, 1.0], [0, 1]) + + +# --------------------------------------------------------------------------- +# run_sl_gate — planted signal vs noise +# --------------------------------------------------------------------------- +def _dataset(signal: float, *, seed: int, n_symbols: int = 60, n_dates: int = 40, d: int = 6): + """Synthetic dataset mirroring real shape: each of ``n_symbols`` tickers appears + across many dates (so the symbol-disjoint 70/30 split has both classes).""" + + rng = np.random.default_rng(seed) + datepool = sorted("2023%02d%02d" % (1 + i // 28, 1 + i % 28) for i in range(n_dates)) + X, y, dates, groups = [], [], [], [] + for di, dt in enumerate(datepool): + for sym in range(n_symbols): + x = rng.standard_normal(d) + prob = 1.0 / (1.0 + np.exp(-signal * x[0])) if signal > 0 else 0.5 + label = int(rng.random() < prob) + X.append(x) + y.append(label) + dates.append(dt) + groups.append("s%03d_%s" % (sym, dt)) + return np.array(X), np.array(y), dates, groups + + +def test_sl_gate_predictable_on_planted_signal(): + X, y, dates, groups = _dataset(signal=3.0, seed=1) + res = run_sl_gate(X, y, dates, groups, n_bootstrap=200, rng_seed=1) + assert res["verdict"] == "PREDICTABLE" + assert res["predictable"] is True + # at least one model clears all three bars + assert any(m.get("predictable") for m in res["walk_forward"]["models"].values()) + + +def test_sl_gate_at_chance_on_noise(): + X, y, dates, groups = _dataset(signal=0.0, seed=2) + res = run_sl_gate(X, y, dates, groups, n_bootstrap=200, rng_seed=2) + assert res["verdict"] == "AT-CHANCE" + assert res["predictable"] is False + + +def test_sl_gate_reports_structure(): + X, y, dates, groups = _dataset(signal=0.0, seed=3) + res = run_sl_gate(X, y, dates, groups, n_bootstrap=100, rng_seed=3) + assert set(("n_samples", "base_rate_sl", "walk_forward", "symbol_disjoint_auc", + "predictable", "verdict")).issubset(res.keys()) + assert 0.0 <= res["base_rate_sl"] <= 1.0 + assert res["thresholds"]["auc_meaningful"] == 0.55 diff --git a/tests/test_stom_rl_state_exit_gate.py b/tests/test_stom_rl_state_exit_gate.py new file mode 100644 index 000000000..3566f4799 --- /dev/null +++ b/tests/test_stom_rl_state_exit_gate.py @@ -0,0 +1,233 @@ +"""Unit tests for STOM 30s state-conditioned early-exit gate. + +RULE / supervised risk-control, NOT reinforcement learning. Synthetic-only +checks for paired accounting, train-only policy selection, primary-model GO +semantics, shuffled negative-control blocking, and checkpoint feature causality. +""" + +from __future__ import annotations + +import numpy as np + +from stom_rl.state_exit_gate import ( + apply_negative_control_gate, + checkpoint_features_from_rows, + eligible_top_fraction_mask, + run_state_exit_gate, + score_exit_policy, + select_exit_fraction, + simulate_checkpoint_exit_pair, + top_fraction_mask, +) + + +def _dates(n_dates: int) -> list[str]: + return ["2023%02d%02d" % (1 + i // 28, 1 + i % 28) for i in range(n_dates)] + + +def _state_exit_dataset( + *, + seed: int, + signal: float, + n_symbols: int = 30, + n_dates: int = 50, + d: int = 6, + base_mean: float = 0.0, + noise: float = 0.05, + all_eligible: bool = True, +): + rng = np.random.default_rng(seed) + X, baseline, early, eligible, dates, groups = [], [], [], [], [], [] + for dt in _dates(n_dates): + for sym in range(n_symbols): + x = rng.standard_normal(d) + base = base_mean + rng.normal(0.0, noise) + delta = signal * x[0] + rng.normal(0.0, noise) + X.append(x) + baseline.append(base) + early.append(base + delta) + eligible.append(all_eligible or (sym % 2 == 0)) + dates.append(dt) + groups.append("s%03d_%s" % (sym, dt)) + return np.array(X), np.array(baseline), np.array(early), np.array(eligible, dtype=bool), dates, groups + + +def _rows(prices: list[float]) -> list[dict]: + rows = [] + for sec, price in enumerate(prices): + rows.append( + { + "sec": sec, + "price": float(price), + "cr": 2.5, + "ts": 150.0 + sec, + "buy_val": 1000.0 + sec, + "sell_val": 900.0, + "buy_qty": 10.0 + sec, + "sell_qty": 9.0, + "bid_tot": 600.0 + sec, + "ask_tot": 400.0, + "bid1": float(price) - 0.1, + "ask1": float(price) + 0.1, + "bidq1": 60.0, + "askq1": 40.0, + } + ) + return rows + + +def test_top_fraction_mask_selects_stable_highest_scores(): + mask = top_fraction_mask([0.1, 0.9, 0.3, 0.8, 0.2], 0.4) + assert mask.tolist() == [False, True, False, True, False] + + +def test_eligible_top_fraction_mask_maps_predictions_to_original_rows(): + mask = eligible_top_fraction_mask([0.1, 0.9, 0.3], [True, False, True, True], 1 / 3) + assert mask.tolist() == [False, False, True, False] + + +def test_score_exit_policy_uses_all_original_trades_denominator(): + scored = score_exit_policy( + baseline_net=[1.0, -1.0, 0.5, 2.0], + early_exit_net=[0.0, 1.0, 10.0, -5.0], + eligible_mask=[True, True, False, True], + exit_mask=[True, True, True, False], + ) + # Row 2 is ineligible, so even an exit_mask=True contributes zero increment. + assert scored["n_original_trades"] == 4 + assert scored["n_checkpoint_eligible_trades"] == 3 + assert scored["n_policy_exits"] == 2 + assert scored["incremental_total_pct"] == 1.0 # (-1 -> 0) + (-1 -> 1) + assert scored["incremental_mean_pct_per_original_trade"] == 0.25 + assert scored["eligible_incremental_mean_pct"] == 1.0 / 3.0 + + +def test_select_exit_fraction_uses_train_delta_only(): + pred_train = np.array([0.9, 0.8, 0.7, 0.1]) + baseline = np.zeros(4) + early = np.array([1.0, 1.0, -5.0, -5.0]) + selected = select_exit_fraction(pred_train, baseline, early, [True, True, True, True], (0.25, 0.5, 0.75)) + assert selected["selected_exit_fraction"] == 0.5 + + +def test_state_exit_gate_go_on_planted_exit_edge_primary_gbm(): + X, baseline, early, eligible, dates, groups = _state_exit_dataset(seed=11, signal=2.0) + res = run_state_exit_gate( + X, + baseline, + early, + eligible, + dates, + groups, + n_bootstrap=200, + rng_seed=11, + n_trials=1, + external_sharpe_variance=0.0, + min_checkpoint_eligible_test=10, + min_policy_exits=5, + ) + assert res["primary_model_family"] == "gbm" + assert res["verdict"] == "GO" + assert res["models"]["gbm"]["go"] is True + assert res["models"]["ridge"].get("diagnostic_only") is True + assert res["models"]["ridge"].get("go") is False + + +def test_state_exit_gate_no_go_on_noise(): + X, baseline, _, eligible, dates, groups = _state_exit_dataset(seed=12, signal=0.0) + rng = np.random.default_rng(120) + early = baseline + rng.normal(0.0, 1.0, len(baseline)) + res = run_state_exit_gate( + X, + baseline, + early, + eligible, + dates, + groups, + n_bootstrap=200, + rng_seed=12, + min_checkpoint_eligible_test=10, + min_policy_exits=5, + ) + assert res["verdict"] == "NO-GO" + assert not any(m.get("go") for m in res["models"].values()) + + +def test_state_exit_gate_inconclusive_when_primary_count_gate_fails(): + X, baseline, early, eligible, dates, groups = _state_exit_dataset(seed=13, signal=2.0, n_symbols=8) + res = run_state_exit_gate( + X, + baseline, + early, + eligible, + dates, + groups, + n_bootstrap=50, + rng_seed=13, + min_checkpoint_eligible_test=10_000, + min_policy_exits=5, + ) + assert res["verdict"] == "INCONCLUSIVE" + assert res["inconclusive_reason"] == "primary_min_count_not_met" + + +def test_negative_controls_block_primary_go_unless_all_no_go(): + primary = {"verdict": "GO", "models": {"gbm": {"go": True}}} + controls = [ + {"verdict": "NO-GO", "primary_model": {"go": False}}, + {"verdict": "GO", "primary_model": {"go": True}}, + ] + gated = apply_negative_control_gate(primary, controls) + assert gated["verdict_before_negative_control"] == "GO" + assert gated["verdict"] == "NO-GO" + assert gated["negative_control_passed"] is False + assert gated["negative_control_blocked_go"] is True + + +def test_negative_controls_preserve_go_when_all_no_go(): + primary = {"verdict": "GO", "models": {"gbm": {"go": True}}} + controls = [{"verdict": "NO-GO"} for _ in range(5)] + gated = apply_negative_control_gate(primary, controls) + assert gated["verdict"] == "GO" + assert gated["negative_control_passed"] is True + assert gated["negative_control_blocked_go"] is False + + +def test_simulate_checkpoint_exit_pair_zero_increment_when_closed_before_checkpoint(): + paired = simulate_checkpoint_exit_pair( + prices=[100.0, 98.0, 97.0, 99.0], + bids=[100.0, 98.0, 97.0, 99.0], + asks=[100.0, 98.0, 97.0, 99.0], + secs=[0, 10, 20, 30], + checkpoint_sec=30, + tp_pct=5.0, + sl_pct=1.0, + cost_bps=0.0, + ) + assert paired["eligible"] is False + assert paired["baseline_continue_net_pct"] == paired["early_exit_now_net_pct"] + assert paired["delta_exit_now_pct"] == 0.0 + + +def test_simulate_checkpoint_exit_pair_measures_checkpoint_exit_when_still_open(): + paired = simulate_checkpoint_exit_pair( + prices=[100.0, 101.0, 102.0, 103.0, 104.0], + bids=[100.0, 101.0, 102.0, 103.0, 104.0], + asks=[100.0, 101.0, 102.0, 103.0, 104.0], + secs=[0, 10, 20, 30, 1200], + checkpoint_sec=30, + tp_pct=5.0, + sl_pct=5.0, + cost_bps=0.0, + ) + assert paired["eligible"] is True + assert paired["checkpoint_sec_observed"] == 30 + assert abs(paired["early_exit_now_net_pct"] - 3.0) < 1e-9 + assert abs(paired["baseline_continue_net_pct"] - 4.0) < 1e-9 + assert abs(paired["delta_exit_now_pct"] + 1.0) < 1e-9 + + +def test_checkpoint_features_ignore_rows_after_checkpoint(): + base = _rows([100.0] * 61) + mutated = _rows([100.0] * 31 + [130.0] * 30) + assert checkpoint_features_from_rows(base, checkpoint_sec=30) == checkpoint_features_from_rows(mutated, checkpoint_sec=30) diff --git a/tests/test_stom_rl_symbol_norm.py b/tests/test_stom_rl_symbol_norm.py new file mode 100644 index 000000000..56d656f6b --- /dev/null +++ b/tests/test_stom_rl_symbol_norm.py @@ -0,0 +1,125 @@ +"""Follow-up A — symbol leading-zero strip fix (Page 16 prerequisite). + +Korean stock codes are 6-digit zero-padded. Before this fix a candidate CSV +symbol ``000250`` was re-read by pandas as ``int64`` (-> ``250``), which is +internally consistent but mis-joins against the DB table name ``000250`` at the +full-universe boundary. These tests pin the canonical round-trip and prove the +non-numeric synthetic symbols (e.g. ``"A"``) are left untouched. +""" + +import numpy as np +import pandas as pd + +from stom_rl.condition_screener import write_candidates +from stom_rl.portfolio_env import PortfolioEnv, PortfolioEnvConfig +from stom_rl.symbol_norm import ( + normalize_symbol, + normalize_symbol_series, + read_candidates_csv, +) + + +def test_normalize_symbol_zero_pads_digits_only(): + # All-digit codes are zero-padded to the canonical 6-digit Korean form. + assert normalize_symbol("250") == "000250" + assert normalize_symbol("000250") == "000250" + assert normalize_symbol(250) == "000250" + assert normalize_symbol("000100") == "000100" + # Non-numeric synthetic symbols must be left unchanged (zfill would corrupt). + assert normalize_symbol("A") == "A" + assert normalize_symbol("KOSPI200") == "KOSPI200" + # Missing -> empty so the caller's dropna(subset=["symbol"]) can drop it. + assert normalize_symbol(np.nan) == "" + + +def test_normalize_symbol_series_mixed(): + series = pd.Series(["000250", 250, "A", "00000A"]) + out = list(normalize_symbol_series(series)) + assert out == ["000250", "000250", "A", "00000A"] + + +def _candidates_with_zero_padded_symbol() -> pd.DataFrame: + """A T+1 candidate frame whose symbol is the zero-padded code 000250.""" + + base = pd.Timestamp("2025-07-09 09:00:00") + series = [100.0, 110.0, 120.0] + rows = [] + for t, price in enumerate(series): + fill = series[t + 1] if t + 1 < len(series) else float("nan") + rows.append( + { + "timestamp": (base + pd.Timedelta(seconds=t)).isoformat(), + "symbol": "000250", + "condition_id": "zero_pad_fixture", + "passed": True, + "rank_score": float(10 - t), + "price": price, + "fill_price": fill, + "fillable": not np.isnan(fill), + "feature_f": float(t), + } + ) + return pd.DataFrame(rows) + + +def test_symbol_round_trips_zero_padded_through_write_read(tmp_path): + """000250 must survive write -> read_candidates_csv as 000250 (not 250).""" + + path = tmp_path / "candidates.csv" + write_candidates(path, _candidates_with_zero_padded_symbol()) + + # A bare pandas read strips the leading zeros (this is the bug under test). + raw = pd.read_csv(path, encoding="utf-8-sig") + assert str(raw["symbol"].iloc[0]) == "250" + + # The shared helper restores the canonical zero-padded form. + fixed = read_candidates_csv(path) + assert list(fixed["symbol"].unique()) == ["000250"] + + +def test_zero_padded_symbol_buy_sell_matches_holding_key(tmp_path): + """A buy/sell on a CSV-loaded 000250 candidate matches the env holding key.""" + + path = tmp_path / "candidates.csv" + write_candidates(path, _candidates_with_zero_padded_symbol()) + + env = PortfolioEnv( + PortfolioEnvConfig( + candidate_path=str(path), + top_k_candidates=2, + max_positions=2, + buy_fraction=0.5, + cost_bps=0.0, + seed=7, + ), + ) + _, info = env.reset(seed=7) + + # Buy the top candidate; the trade log and the position key are both 000250. + env.step(1) + fill = env.trade_log[-1] + assert fill["symbol"] == "000250" + assert "000250" in env.account.positions + + # The sell slot resolves the same holding key -> a clean round-trip. + sell_offset = 1 + env.config.top_k_candidates + _, _, _, _, info = env.step(sell_offset) + assert info["trade_count"] == 2 + assert "000250" not in env.account.positions + + +def test_synthetic_non_numeric_symbol_csv_round_trip_unchanged(tmp_path): + """A non-numeric symbol 'A' must NOT be zfill-corrupted to '00000A'.""" + + frame = pd.DataFrame( + { + "timestamp": ["2025-07-09T09:00:00", "2025-07-09T09:00:01"], + "symbol": ["A", "A"], + "rank_score": [1.0, 0.5], + "price": [100.0, 110.0], + } + ) + path = tmp_path / "non_numeric.csv" + write_candidates(path, frame) + out = read_candidates_csv(path) + assert list(out["symbol"].unique()) == ["A"] diff --git a/tests/test_stom_rl_timing_gate.py b/tests/test_stom_rl_timing_gate.py new file mode 100644 index 000000000..465a61eaf --- /dev/null +++ b/tests/test_stom_rl_timing_gate.py @@ -0,0 +1,65 @@ +"""Unit tests for the P1b baseline-relative de-idealized timing gate (pure stats). + +RULE strategy, NOT reinforcement learning. A PLANTED per-session timing signal +(a feature that ranks which decision second has the higher entry-net) must yield +GO (incremental-vs-rule CI > 0, DSR > 0.95); pure noise must yield NO-GO (the +model's pick is no better than the rule baseline). No DB / no I/O. +""" + +from __future__ import annotations + +import numpy as np + +from stom_rl.timing_gate import run_timing_gate + + +def _ds(signal: float, *, seed: int = 0, n_groups: int = 300, spg: int = 10, + n_dates: int = 40, d: int = 6): + rng = np.random.default_rng(seed) + n = n_groups * spg + X = rng.standard_normal((n, d)) + datepool = sorted("2023%02d%02d" % (1 + (i // 28), 1 + (i % 28)) for i in range(n_dates)) + gids, dts, secs, base = [], [], [], [] + for g in range(n_groups): + dt = datepool[g % n_dates] + for s in range(spg): + gids.append("g%d" % g) + dts.append(dt) + secs.append(10.0 * (s + 1)) + base.append(0.0) # rule fixed-entry baseline net = 0 for all sessions + noise = rng.standard_normal(n) + # entry-net y: varies with feature 0 when signal>0 (so picking high-X0 second + # beats the baseline); pure noise (mean 0 = baseline) when signal==0. + y = (signal * X[:, 0] + 0.1 * noise) if signal > 0 else noise.copy() + return X, y, dts, np.array(gids), np.array(secs, dtype=float), np.array(base, dtype=float) + + +def test_timing_gate_GO_on_planted_timing_signal(): + X, y, dts, gids, secs, base = _ds(signal=1.0, seed=1) + res = run_timing_gate(X, y, dts, gids, secs, base, n_bootstrap=200, rng_seed=1) + # The model should pick higher-net entries than the rule -> positive increment. + assert res["verdict"] == "GO" + m = res["models"]["ridge"] + assert m["incremental_mean_pct"] > 0.0 + assert m["ci_excludes_zero"] is True + assert m["dsr_gt_0_95"] is True + + +def test_timing_gate_NOGO_on_noise(): + X, y, dts, gids, secs, base = _ds(signal=0.0, seed=2) + res = run_timing_gate(X, y, dts, gids, secs, base, n_bootstrap=200, rng_seed=2) + # No predictable timing edge -> model pick no better than baseline -> CI spans 0. + for name in ("ridge", "gbm"): + lo, hi = res["models"][name]["incremental_ci95"] + assert lo <= 0.0 <= hi + assert res["verdict"] == "NO-GO" + + +def test_timing_gate_reports_structure(): + X, y, dts, gids, secs, base = _ds(signal=0.5, seed=3) + res = run_timing_gate(X, y, dts, gids, secs, base, n_bootstrap=100, rng_seed=3) + assert set(res["models"].keys()) == {"ridge", "gbm"} + assert res["verdict"] in {"GO", "NO-GO"} + assert res["n_trials"] >= 1 + for dd in res["per_boundary_incremental_mean"].values(): + assert len(dd) == 5 diff --git a/tests/test_stom_rl_trading_env.py b/tests/test_stom_rl_trading_env.py new file mode 100644 index 000000000..95c226442 --- /dev/null +++ b/tests/test_stom_rl_trading_env.py @@ -0,0 +1,167 @@ +import json +from pathlib import Path + +import numpy as np +import pandas as pd +import pytest + +from stom_rl.trading_env import ( + ACTION_BUY, + ACTION_HOLD, + ACTION_SELL, + StomTickTradingEnv, + StomTickTradingEnvConfig, +) + + +def _write_env_fixture(tmp_path: Path, *, rows: int = 16) -> Path: + csv_dir = tmp_path / "qlib_csv" + csv_dir.mkdir(parents=True, exist_ok=True) + csv_path = csv_dir / "KR000001_20250103.csv" + base = pd.Timestamp("2025-01-03 09:00:00") + frame = pd.DataFrame( + { + "symbol": "KR000001", + "date": [(base + pd.Timedelta(seconds=i)).strftime("%Y-%m-%d %H:%M:%S") for i in range(rows)], + "open": [100.0 + i for i in range(rows)], + "high": [100.0 + i for i in range(rows)], + "low": [100.0 + i for i in range(rows)], + "close": [100.0 + i for i in range(rows)], + "volume": [10.0 + i for i in range(rows)], + "amount": [(100.0 + i) * (10.0 + i) for i in range(rows)], + "money": [(100.0 + i) * (10.0 + i) for i in range(rows)], + "factor": 1.0, + } + ) + frame.to_csv(csv_path, index=False, encoding="utf-8-sig") + + manifest = { + "mode": "stom_rl_episode_manifest", + "summary": {"episode_count": 1}, + "episodes": [ + { + "episode_id": "000001_20250103", + "symbol": "000001", + "instrument": "KR000001", + "session": "20250103", + "split": "train", + "time_start": "090000", + "time_end": "093000", + "lookback_window": 5, + "reward_horizon_seconds": 3, + "row_count": rows, + "source_csv": str(csv_path), + } + ], + } + manifest_path = tmp_path / "episode_manifest.json" + manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8-sig") + return manifest_path + + +def _env(tmp_path: Path, **overrides): + manifest_path = _write_env_fixture(tmp_path) + config = StomTickTradingEnvConfig( + manifest_path=str(manifest_path), + split="train", + episode_id="000001_20250103", + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + slippage_bps=0.0, + invalid_action_penalty=0.01, + **overrides, + ) + return StomTickTradingEnv(config) + + +def test_reset_returns_gymnasium_style_observation_without_future_leakage(tmp_path): + env = _env(tmp_path) + + observation, info = env.reset(seed=7) + + assert observation.shape == (5, 9) + assert env.observation_space.contains(observation) + assert info["event"] == "reset" + assert info["last_observation_timestamp"] == "2025-01-03T09:00:04" + assert info["action_timestamp"] == "2025-01-03T09:00:05" + assert info["horizon_timestamp"] == "2025-01-03T09:00:08" + assert info["no_future_observation"] is True + assert info["feature_columns"] == [ + "open", + "high", + "low", + "close", + "volume", + "amount", + "position", + "unrealized_return", + "time_in_position", + ] + + +def test_horizon_reward_buy_hold_sell_and_invalid_actions(tmp_path): + env = _env(tmp_path) + env.reset(seed=7) + + _, buy_reward, terminated, truncated, buy_info = env.step(ACTION_BUY) + expected_horizon_return = (108.0 - 105.0) / 105.0 + assert buy_reward == pytest.approx(expected_horizon_return) + assert buy_info["position_after"] == 1 + assert buy_info["invalid_action"] is False + assert terminated is False + assert truncated is False + + _, invalid_reward, _, _, invalid_info = env.step(ACTION_BUY) + assert invalid_reward < 0 + assert invalid_info["invalid_action"] is True + assert invalid_info["invalid_action_count"] == 1 + + _, sell_reward, _, _, sell_info = env.step(ACTION_SELL) + expected_realized = (107.0 - 105.0) / 105.0 + assert sell_reward == pytest.approx(expected_realized) + assert sell_info["position_after"] == 0 + + _, invalid_sell_reward, _, _, invalid_sell_info = env.step(ACTION_SELL) + assert invalid_sell_reward == pytest.approx(-0.01) + assert invalid_sell_info["invalid_action"] is True + + +def test_reset_and_action_sequence_are_deterministic(tmp_path): + env_a = _env(tmp_path) + env_b = _env(tmp_path) + obs_a, info_a = env_a.reset(seed=123) + obs_b, info_b = env_b.reset(seed=123) + + assert info_a["episode_id"] == info_b["episode_id"] + assert np.allclose(obs_a, obs_b) + + rewards_a = [] + rewards_b = [] + for action in [ACTION_HOLD, ACTION_BUY, ACTION_HOLD, ACTION_SELL]: + obs_a, reward_a, term_a, trunc_a, info_a = env_a.step(action) + obs_b, reward_b, term_b, trunc_b, info_b = env_b.step(action) + rewards_a.append(reward_a) + rewards_b.append(reward_b) + assert term_a == term_b + assert trunc_a == trunc_b + assert info_a["current_idx"] == info_b["current_idx"] + assert np.allclose(obs_a, obs_b) + + assert rewards_a == pytest.approx(rewards_b) + + +def test_environment_rejects_episode_without_required_horizon(tmp_path): + manifest_path = _write_env_fixture(tmp_path, rows=7) + env = StomTickTradingEnv( + StomTickTradingEnvConfig( + manifest_path=str(manifest_path), + split="train", + episode_id="000001_20250103", + lookback_window=5, + reward_horizon_seconds=3, + ) + ) + + with pytest.raises(ValueError, match="at least"): + env.reset() diff --git a/tests/test_stom_rl_walk_forward.py b/tests/test_stom_rl_walk_forward.py new file mode 100644 index 000000000..0ff3632cf --- /dev/null +++ b/tests/test_stom_rl_walk_forward.py @@ -0,0 +1,211 @@ +import json +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pytest + + +def _write_sb3_fixture(tmp_path: Path, *, rows: int = 32) -> Path: + csv_dir = tmp_path / "qlib_csv" + csv_dir.mkdir(parents=True, exist_ok=True) + base = pd.Timestamp("2025-01-03 09:00:00") + csv_paths = [] + # One train session plus four contiguous test sessions so a 2-fold split has + # two distinct, non-overlapping time periods. + for symbol, session, split in [ + ("KR000001", "20250103", "train"), + ("KR000002", "20250106", "test"), + ("KR000003", "20250107", "test"), + ("KR000004", "20250108", "test"), + ("KR000005", "20250109", "test"), + ]: + csv_path = csv_dir / f"{symbol}_{session}.csv" + frame = pd.DataFrame( + { + "symbol": symbol, + "date": [(base + pd.Timedelta(seconds=i)).strftime("%Y-%m-%d %H:%M:%S") for i in range(rows)], + "open": [100.0 + i * 0.1 for i in range(rows)], + "high": [100.1 + i * 0.1 for i in range(rows)], + "low": [99.9 + i * 0.1 for i in range(rows)], + "close": [100.0 + i * 0.1 for i in range(rows)], + "volume": [10.0 + i for i in range(rows)], + "amount": [(100.0 + i * 0.1) * (10.0 + i) for i in range(rows)], + } + ) + frame.to_csv(csv_path, index=False, encoding="utf-8-sig") + csv_paths.append((csv_path, symbol, session, split)) + + episodes = [] + for csv_path, symbol, session, split in csv_paths: + episodes.append( + { + "episode_id": f"{symbol}_{session}", + "symbol": symbol, + "instrument": symbol, + "session": session, + "split": split, + "time_start": "090000", + "time_end": "090031", + "lookback_window": 5, + "reward_horizon_seconds": 3, + "row_count": rows, + "source_csv": str(csv_path), + } + ) + manifest_path = tmp_path / "episode_manifest.json" + manifest_path.write_text( + json.dumps( + {"mode": "stom_rl_episode_manifest", "summary": {"episode_count": len(episodes)}, "episodes": episodes} + ), + encoding="utf-8-sig", + ) + return manifest_path + + +_SKIP_MARKERS = ( + "ModuleNotFoundError", + "DLL load failed", + "WinError 1114", + "c10.dll", + "Error loading", +) + + +def _run_python(code: str) -> subprocess.CompletedProcess[str]: + result = subprocess.run( + [sys.executable, "-c", code], + text=True, + capture_output=True, + check=False, + ) + combined = result.stderr + result.stdout + if result.returncode != 0 and any(marker in combined for marker in _SKIP_MARKERS): + pytest.skip(combined) + return result + + +def test_session_binning_is_contiguous_and_complete(): + """Unit test: session folds are contiguous, non-overlapping, and cover all sessions.""" + + from stom_rl.walk_forward import _build_session_folds + + # 6 episodes across 6 distinct, time-ordered sessions. + sessions = ["20250101", "20250102", "20250103", "20250104", "20250105", "20250106"] + episodes = [{"episode_id": f"e{i}", "session": session} for i, session in enumerate(sessions)] + + folds = _build_session_folds(episodes, n_folds=3, max_episodes_per_fold=30) + assert len(folds) == 3 + + # Indices partition [0..5] with no overlap, covering everything. + all_indices = [idx for fold in folds for idx in fold["episode_indices"]] + assert sorted(all_indices) == list(range(len(sessions))) + assert len(all_indices) == len(set(all_indices)) + + # Each fold is contiguous in time, and folds do not overlap in session ranges. + prev_end = None + for fold in folds: + fold_indices = fold["episode_indices"] + assert fold_indices == sorted(fold_indices) + assert fold["period_start"] <= fold["period_end"] + if prev_end is not None: + assert fold["period_start"] > prev_end + prev_end = fold["period_end"] + + # A single session must never span two folds. + seen_sessions = set() + for fold in folds: + fold_sessions = {sessions[idx] for idx in fold["episode_indices"]} + assert not (fold_sessions & seen_sessions) + seen_sessions |= fold_sessions + assert seen_sessions == set(sessions) + + +def test_walk_forward_evaluates_saved_model_across_folds(tmp_path): + manifest_path = _write_sb3_fixture(tmp_path) + train_dir = tmp_path / "sb3_train" + wf_dir = tmp_path / "walk_forward" + + result = _run_python( + f""" +import json +from stom_rl.sb3_smoke import Sb3SmokeConfig, run_sb3_smoke +from stom_rl.walk_forward import WalkForwardConfig, run_walk_forward + +run_sb3_smoke( + Sb3SmokeConfig( + manifest_path={str(manifest_path)!r}, + output_dir={str(train_dir)!r}, + algorithms=("dqn",), + total_timesteps=64, + max_eval_episodes=1, + max_eval_steps_per_episode=6, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + device="cpu", + ) +) + +payload = run_walk_forward( + WalkForwardConfig( + model_dir={str(train_dir)!r}, + algorithms=("dqn",), + n_folds=2, + max_episodes_per_fold=2, + max_eval_steps_per_episode=6, + manifest_path={str(manifest_path)!r}, + lookback_window=5, + reward_horizon_seconds=3, + cost_bps=0.0, + device="cpu", + output_dir={str(wf_dir)!r}, + ) +) +dqn_folds = [row for row in payload["folds"] if row["algorithm"] == "dqn"] +algo_summary = payload["summary"]["per_algorithm"][0] +print(json.dumps({{ + "mode": payload["mode"], + "n_folds": payload["summary"]["n_folds"], + "fold_count": len(dqn_folds), + "fold_indices": [row["fold_index"] for row in dqn_folds], + "period_starts": [row["period_start"] for row in dqn_folds], + "period_ends": [row["period_end"] for row in dqn_folds], + "episode_counts": [row["episode_count"] for row in dqn_folds], + "have_avg_net": all("avg_episode_net_return_pct" in row for row in dqn_folds), + "have_max_dd": all("max_drawdown_pct" in row for row in dqn_folds), + "folds_positive": algo_summary["folds_positive"], + "consistency": algo_summary["consistency"], + "mean_fold_avg_net": algo_summary["mean_fold_avg_net"], + "source_model": dqn_folds[0]["source_model"], +}})) +""" + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout.strip().splitlines()[-1]) + assert payload["mode"] == "stom_rl_walk_forward" + assert payload["n_folds"] == 2 + assert payload["fold_count"] == 2 + + # Two distinct folds with distinct, ordered time periods. + assert payload["fold_indices"] == [0, 1] + assert payload["period_starts"][0] != payload["period_starts"][1] + assert payload["period_ends"][0] != payload["period_ends"][1] + assert payload["period_starts"][0] <= payload["period_ends"][0] + assert payload["period_ends"][0] < payload["period_starts"][1] + + assert all(count >= 1 for count in payload["episode_counts"]) + assert payload["have_avg_net"] is True + assert payload["have_max_dd"] is True + + # Per-algorithm aggregate fields present. + assert "folds_positive" in payload + assert payload["consistency"] in {"consistent", "regime_sensitive", "unstable"} + assert isinstance(payload["mean_fold_avg_net"], (int, float)) + assert payload["source_model"] == "dqn_smoke" + + # Artifacts written. + assert (wf_dir / "walk_forward_report.json").is_file() + assert (wf_dir / "walk_forward_folds.csv").is_file() diff --git a/tests/test_stom_tick_dataset.py b/tests/test_stom_tick_dataset.py new file mode 100644 index 000000000..cab7f9edb --- /dev/null +++ b/tests/test_stom_tick_dataset.py @@ -0,0 +1,194 @@ +import sqlite3 +import sys +from datetime import datetime, timedelta +from pathlib import Path + +import numpy as np +import pandas as pd + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "finetune_csv")) + +from stom_tick_dataset import ( # noqa: E402 + GroupedKlineDataset, + export_stom_tick_db_to_csv, + inspect_stom_tick_db, + read_stom_table_as_kline, +) + + +def _create_stom_db(path: Path, rows_per_session: int = 8): + conn = sqlite3.connect(path) + try: + for symbol, base_price in [("000001", 1000), ("000002", 2000)]: + conn.execute( + f''' + CREATE TABLE "{symbol}" ( + "index" INTEGER, + "현재가" REAL, + "시가" REAL, + "고가" REAL, + "저가" REAL, + "초당매수수량" REAL, + "초당매도수량" REAL, + "초당거래대금" REAL + ) + ''' + ) + for day in [datetime(2026, 1, 2, 9, 0, 0), datetime(2026, 1, 3, 9, 0, 0)]: + for i in range(rows_per_session): + ts = int((day + timedelta(seconds=i)).strftime("%Y%m%d%H%M%S")) + close = base_price + i + buy_qty = i + 1 + sell_qty = i + 2 + volume = buy_qty + sell_qty + conn.execute( + f'INSERT INTO "{symbol}" VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + ( + ts, + close, + base_price, + close + 1, + base_price - 1, + buy_qty, + sell_qty, + close * volume, + ), + ) + conn.commit() + finally: + conn.close() + + +def test_inspect_stom_tick_db_detects_trainable_groups(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + _create_stom_db(db_path) + + summary = inspect_stom_tick_db( + db_path, + max_tables=0, + lookback_window=3, + predict_window=2, + price_mode="close_only", + ) + + assert summary["table_count"] == 2 + assert summary["compatible_table_count"] == 2 + assert summary["eligible_group_count"] == 4 + assert summary["trainable"] is True + + +def test_export_stom_tick_db_to_grouped_kronos_csv(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + csv_path = tmp_path / "stom_1tick_kline.csv" + _create_stom_db(db_path) + + report = export_stom_tick_db_to_csv( + db_path, + csv_path, + max_tables=0, + lookback_window=3, + predict_window=2, + price_mode="close_only", + ) + df = pd.read_csv(csv_path) + + assert report["trainable_csv_created"] is True + assert set(["symbol", "session", "timestamps", "open", "high", "low", "close", "volume", "amount"]).issubset(df.columns) + assert len(df[["symbol", "session"]].drop_duplicates()) == 4 + first = df.iloc[0] + assert first["open"] == first["high"] == first["low"] == first["close"] + assert first["volume"] == 3 + + +def test_export_can_clip_each_group_to_bounded_contiguous_rows(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + csv_path = tmp_path / "stom_1tick_kline_clipped.csv" + _create_stom_db(db_path, rows_per_session=10) + + report = export_stom_tick_db_to_csv( + db_path, + csv_path, + max_tables=0, + lookback_window=3, + predict_window=2, + price_mode="close_only", + max_rows_per_group=6, + ) + df = pd.read_csv(csv_path) + + assert report["trainable_csv_created"] is True + assert report["clipped_groups"] == 4 + assert report["max_rows_per_group"] == 6 + assert df.groupby(["symbol", "session"]).size().tolist() == [6, 6, 6, 6] + + +def test_grouped_kline_dataset_keeps_windows_inside_symbol_session(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + csv_path = tmp_path / "stom_1tick_kline.csv" + _create_stom_db(db_path) + export_stom_tick_db_to_csv( + db_path, + csv_path, + max_tables=0, + lookback_window=3, + predict_window=2, + price_mode="close_only", + ) + + dataset = GroupedKlineDataset( + csv_path, + data_type="train", + lookback_window=3, + predict_window=2, + train_ratio=1.0, + val_ratio=0.0, + test_ratio=0.0, + normalize_using="lookback", + ) + + assert len(dataset) == 12 + assert dataset.groups[0]["key"][0].startswith("00000") + x, x_stamp = dataset.get_numpy(0) + assert tuple(x.shape) == (6, 6) + assert tuple(x_stamp.shape) == (6, 5) + assert np.allclose(x[:3].mean(axis=0), np.zeros(6), atol=1e-5) + + for idx in range(len(dataset)): + metadata = dataset.sample_metadata(idx) + assert metadata["start_timestamp"][:10] == metadata["end_timestamp"][:10] + + +def test_read_stom_table_as_kline_uses_query_only_connection(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + _create_stom_db(db_path) + conn = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True) + conn.execute("PRAGMA query_only=ON") + try: + frame, mapping = read_stom_table_as_kline(conn, "000001", price_mode="close_only") + finally: + conn.close() + + assert mapping["close"] == "현재가" + assert frame["symbol"].unique().tolist() == ["000001"] + assert frame[["open", "high", "low", "close"]].nunique(axis=1).eq(1).all() + + +def test_read_stom_table_as_kline_can_filter_session_range(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + _create_stom_db(db_path) + conn = sqlite3.connect(f"file:{db_path.as_posix()}?mode=ro", uri=True) + conn.execute("PRAGMA query_only=ON") + try: + frame, _ = read_stom_table_as_kline( + conn, + "000001", + price_mode="close_only", + session_start="20260103", + session_end="20260103", + ) + finally: + conn.close() + + assert frame["session"].unique().tolist() == ["20260103"] diff --git a/tests/test_stom_training_cli.py b/tests/test_stom_training_cli.py new file mode 100644 index 000000000..6a2e08d99 --- /dev/null +++ b/tests/test_stom_training_cli.py @@ -0,0 +1,158 @@ +import json +import sqlite3 +import subprocess +import sys +from datetime import datetime, timedelta +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT / "finetune_csv")) + +from config_loader import CustomFinetuneConfig # noqa: E402 +from stom_tick_dataset import GroupedKlineDataset, export_stom_tick_db_to_csv # noqa: E402 + + +def _create_single_table_db(path: Path, rows_per_session: int = 8): + conn = sqlite3.connect(path) + try: + conn.execute( + ''' + CREATE TABLE "005930" ( + "index" INTEGER, + "현재가" REAL, + "시가" REAL, + "고가" REAL, + "저가" REAL, + "초당매수수량" REAL, + "초당매도수량" REAL, + "초당거래대금" REAL + ) + ''' + ) + for day in [datetime(2026, 1, 2, 9, 0, 0), datetime(2026, 1, 3, 9, 0, 0)]: + for i in range(rows_per_session): + ts = int((day + timedelta(seconds=i)).strftime("%Y%m%d%H%M%S")) + price = 70000 + i + volume = (i + 1) + (i + 2) + conn.execute( + 'INSERT INTO "005930" VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + (ts, price, 70000, price + 10, 69900, i + 1, i + 2, price * volume), + ) + conn.commit() + finally: + conn.close() + + +def test_prepare_stom_1tick_inspect_cli_outputs_json(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + json_path = tmp_path / "inspect.json" + _create_single_table_db(db_path) + + result = subprocess.run( + [ + sys.executable, + str(REPO_ROOT / "finetune_csv" / "prepare_stom_1tick.py"), + "inspect", + "--db", + str(db_path), + "--lookback-window", + "3", + "--predict-window", + "2", + "--price-mode", + "close_only", + "--json-output", + str(json_path), + ], + cwd=REPO_ROOT, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=False, + ) + + assert result.returncode == 0, result.stderr + payload = json.loads(result.stdout) + assert payload["trainable"] is True + assert json.loads(json_path.read_text(encoding="utf-8"))["eligible_group_count"] == 2 + + +def test_config_can_build_grouped_dataset_for_training(tmp_path): + db_path = tmp_path / "stock_tick_back.db" + csv_path = tmp_path / "stom_1tick_kline.csv" + config_path = tmp_path / "config.yaml" + _create_single_table_db(db_path) + export_stom_tick_db_to_csv( + db_path, + csv_path, + max_tables=0, + lookback_window=3, + predict_window=2, + price_mode="close_only", + ) + + config_path.write_text( + f""" +data: + data_path: "{csv_path.as_posix()}" + dataset_type: "stom_tick" + group_columns: ["symbol", "session"] + lookback_window: 3 + predict_window: 2 + max_context: 16 + clip: 5.0 + sample_stride: 1 + max_samples: null + normalize_using: "lookback" + train_ratio: 0.5 + val_ratio: 0.5 + test_ratio: 0.0 +training: + tokenizer_epochs: 0 + basemodel_epochs: 1 + batch_size: 2 + log_interval: 1 + num_workers: 0 + seed: 42 +model_paths: + pretrained_tokenizer: "NeoQuasar/Kronos-Tokenizer-base" + pretrained_predictor: "NeoQuasar/Kronos-small" + exp_name: "test_stom" + base_path: "{(tmp_path / 'finetuned').as_posix()}" + base_save_path: "" + finetuned_tokenizer: "" +experiment: + name: "test" + train_tokenizer: false + train_basemodel: true +device: + use_cuda: false + device_id: 0 +distributed: + use_ddp: false +""", + encoding="utf-8", + ) + + config = CustomFinetuneConfig(str(config_path)) + dataset = GroupedKlineDataset( + config.data_path, + data_type="train", + lookback_window=config.lookback_window, + predict_window=config.predict_window, + clip=config.clip, + seed=config.seed, + train_ratio=config.train_ratio, + val_ratio=config.val_ratio, + test_ratio=config.test_ratio, + group_columns=config.group_columns, + sample_stride=config.sample_stride, + max_samples=config.max_samples, + normalize_using=config.normalize_using, + ) + + assert config.dataset_type == "stom_tick" + assert isinstance(dataset, GroupedKlineDataset) + assert len(dataset) == 3 diff --git a/tests/test_train_tokenizer_safety.py b/tests/test_train_tokenizer_safety.py new file mode 100644 index 000000000..038d455f6 --- /dev/null +++ b/tests/test_train_tokenizer_safety.py @@ -0,0 +1,60 @@ +import json +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +FINETUNE_DIR = PROJECT_ROOT / "finetune" +if str(FINETUNE_DIR) not in sys.path: + sys.path.insert(0, str(FINETUNE_DIR)) + +from tokenizer_safety import ( # noqa: E402 + is_cuda_oom_error, + resolve_tokenizer_validation_batch_size, + save_tokenizer_checkpoint, + write_tokenizer_validation_failure, +) + + +class DummyTokenizer: + def save_pretrained(self, save_path): + path = Path(save_path) + path.mkdir(parents=True, exist_ok=True) + (path / "model.safetensors").write_text("dummy", encoding="utf-8") + + +def test_resolve_tokenizer_validation_batch_size_uses_safe_positive_value(): + assert resolve_tokenizer_validation_batch_size({"batch_size": 4}) == 4 + assert resolve_tokenizer_validation_batch_size({"batch_size": 4, "tokenizer_validation_batch_size": 1}) == 1 + assert resolve_tokenizer_validation_batch_size({"batch_size": 4, "tokenizer_validation_batch_size": 0}) == 1 + + +def test_tokenizer_checkpoint_before_validation_is_written_by_rank_zero(tmp_path): + saved = save_tokenizer_checkpoint( + DummyTokenizer(), + str(tmp_path), + "latest_train_model", + rank=0, + reason="unit test", + ) + + assert saved == str(tmp_path / "checkpoints" / "latest_train_model") + assert (tmp_path / "checkpoints" / "latest_train_model" / "model.safetensors").exists() + + +def test_validation_failure_artifact_records_cuda_oom_context(tmp_path): + exc = RuntimeError("CUDA error: out of memory") + + saved = write_tokenizer_validation_failure( + str(tmp_path), + epoch_idx=0, + exc=exc, + pre_validation_checkpoint=str(tmp_path / "checkpoints" / "latest_train_model"), + rank=0, + ) + payload = json.loads(Path(saved).read_text(encoding="utf-8")) + + assert is_cuda_oom_error(exc) is True + assert payload["stage"] == "tokenizer_validation" + assert payload["epoch"] == 1 + assert payload["is_cuda_oom"] is True + assert payload["pre_validation_checkpoint"].endswith("latest_train_model") diff --git a/tests/test_training_monitor.py b/tests/test_training_monitor.py new file mode 100644 index 000000000..f624b1b23 --- /dev/null +++ b/tests/test_training_monitor.py @@ -0,0 +1,363 @@ + +import json +import sys +from pathlib import Path +from types import SimpleNamespace + + +REPO_ROOT = Path(__file__).resolve().parents[1] +WEBUI_DIR = REPO_ROOT / "webui" +if str(WEBUI_DIR) not in sys.path: + sys.path.insert(0, str(WEBUI_DIR)) + +import training_monitor # noqa: E402 + + +def _write_progress(run_dir: Path, stage: str = "predictor") -> None: + logs = run_dir / "logs" + logs.mkdir(parents=True, exist_ok=True) + stdout = logs / f"{stage}.stdout.log" + stdout.write_text("line 1\nline 2\n", encoding="utf-8") + progress = { + "schema_version": 1, + "created_at": "2026-05-11T00:00:00Z", + "updated_at": "2026-05-11T00:01:00Z", + "status": "running", + "run_name": run_dir.name, + "horizon": 60, + "mode": "full", + "train_stage": stage, + "stage": {"index": 2, "count": 2, "name": stage, "percent": 25.0, "overall_percent": 62.5}, + "progress": {"epoch": 1, "epochs": 1, "step": 25, "total_steps": 100}, + "metrics": {"last_loss": 0.5, "last_validation_loss": None, "best_val_loss": None}, + "timing": {"elapsed_seconds": 10, "eta_seconds": 30, "samples_per_second": 10.0}, + "paths": {"stdout_log": str(stdout), "stderr_log": str(logs / f"{stage}.stderr.log")}, + "last_line": "latest", + } + (logs / f"{stage}.progress.json").write_text(json.dumps(progress), encoding="utf-8") + + +def test_training_monitor_lists_status_and_tails_logs(tmp_path, monkeypatch): + output_root = tmp_path / "outputs" + run_dir = output_root / "unit_run" + _write_progress(run_dir) + monkeypatch.setattr(training_monitor, "OUTPUT_ROOTS", [output_root]) + + runs = training_monitor.list_training_runs() + status = training_monitor.load_training_status("unit_run") + tail = training_monitor.tail_training_log("unit_run", stage="predictor", lines=1) + + assert runs[0]["name"] == "unit_run" + assert runs[0]["status"] == "running" + assert status["overall_percent"] == 62.5 + assert status["latest_stage"]["train_stage"] == "predictor" + assert tail["lines"] == ["line 2"] + + +def test_training_monitor_inspects_checkpoint_artifacts(tmp_path, monkeypatch): + output_root = tmp_path / "outputs" + run_dir = output_root / "unit_run" + _write_progress(run_dir, stage="tokenizer") + tokenizer_model = run_dir / "finetune_tokenizer" / "checkpoints" / "best_model" / "model.safetensors" + tokenizer_model.parent.mkdir(parents=True) + tokenizer_model.write_text("tokenizer", encoding="utf-8") + predictor_model = run_dir / "finetune_predictor" / "checkpoints" / "best_model" / "pytorch_model.bin" + predictor_model.parent.mkdir(parents=True) + predictor_model.write_text("predictor", encoding="utf-8") + monkeypatch.setattr(training_monitor, "OUTPUT_ROOTS", [output_root]) + + artifacts = training_monitor.inspect_training_artifacts("unit_run") + + assert artifacts["model_weight_file_count"] == 2 + assert artifacts["checkpoint_file_count"] == 2 + assert artifacts["tokenizer_checkpoint_ready"] is True + assert artifacts["predictor_checkpoint_ready"] is True + assert artifacts["level"] == "ready" + assert artifacts["stages"]["tokenizer"]["checkpoint_file_count"] == 1 + assert artifacts["stages"]["predictor"]["checkpoint_file_count"] == 1 + + +def test_training_monitor_parses_progress_history(tmp_path, monkeypatch): + output_root = tmp_path / "outputs" + run_dir = output_root / "unit_run" + _write_progress(run_dir, stage="tokenizer") + stdout = run_dir / "logs" / "tokenizer.stdout.log" + stdout.write_text( + "\n".join( + [ + "[Rank 0, Epoch 1/1, Step 100/1000] LR 0.000200, Loss: -0.0100", + "noise line", + "[Rank 0, Epoch 1/1, Step 200/1000] LR 0.000190, Loss: -0.0200", + "[Rank 0, Epoch 1/1, Step 300/1000] LR 0.000180, Loss: -0.0300", + ] + ), + encoding="utf-8", + ) + monkeypatch.setattr(training_monitor, "OUTPUT_ROOTS", [output_root]) + + history = training_monitor.load_training_history("unit_run", stage="tokenizer", limit=2) + + assert history["stage"] == "tokenizer" + assert history["point_count"] == 2 + assert [point["step"] for point in history["points"]] == [200, 300] + assert history["latest_point"]["loss"] == -0.03 + assert history["latest_point"]["stage_percent"] == 30.0 + + +def test_training_monitor_history_does_not_fallback_for_requested_empty_stage(tmp_path, monkeypatch): + output_root = tmp_path / "outputs" + run_dir = output_root / "unit_run" + _write_progress(run_dir, stage="tokenizer") + _write_progress(run_dir, stage="predictor") + (run_dir / "logs" / "predictor.stdout.log").unlink() + (run_dir / "logs" / "tokenizer.stdout.log").write_text( + "[Rank 0, Epoch 1/1, Step 100/1000] LR 0.000200, Loss: -0.0100", + encoding="utf-8", + ) + monkeypatch.setattr(training_monitor, "OUTPUT_ROOTS", [output_root]) + + history = training_monitor.load_training_history("unit_run", stage="predictor", limit=5) + + assert history["stage"] == "predictor" + assert history["point_count"] == 0 + assert "no stdout log" in history["error"] + + +def test_training_monitor_rejects_path_traversal(tmp_path, monkeypatch): + output_root = tmp_path / "outputs" + output_root.mkdir() + monkeypatch.setattr(training_monitor, "OUTPUT_ROOTS", [output_root]) + + try: + training_monitor.resolve_run_dir("..\\secret") + except ValueError as exc: + assert "direct" in str(exc) + else: + raise AssertionError("path traversal run name was accepted") + + +def test_query_gpu_status_parses_nvidia_smi(monkeypatch): + class Completed: + returncode = 0 + stdout = "0, NVIDIA GeForce RTX 4080 SUPER, 75, 9000, 16376, 250.5, 320.0, 61\n" + stderr = "" + + monkeypatch.setattr(training_monitor.subprocess, "run", lambda *args, **kwargs: Completed()) + + status = training_monitor.query_gpu_status() + + assert status["available"] is True + assert status["gpus"][0]["name"] == "NVIDIA GeForce RTX 4080 SUPER" + assert status["gpus"][0]["utilization_gpu_percent"] == 75.0 + assert status["gpus"][0]["memory_used_percent"] == 54.96 + assert status["gpus"][0]["power_draw_available"] is True + assert status["total_power_draw_watts"] == 250.5 + assert status["total_power_limit_watts"] == 320.0 + assert status["average_utilization_gpu_percent"] == 75.0 + assert status["total_memory_used_percent"] == 54.96 + + +def test_query_gpu_status_reports_power_limit_when_power_draw_is_missing(monkeypatch): + class Completed: + returncode = 0 + stdout = "0, NVIDIA GeForce RTX 4080 SUPER, 43, 3328, 16376, [Not Supported], 320.0, 49\n" + stderr = "" + + monkeypatch.setattr(training_monitor.subprocess, "run", lambda *args, **kwargs: Completed()) + + status = training_monitor.query_gpu_status() + + assert status["available"] is True + assert status["power_draw_available"] is False + assert status["total_power_draw_watts"] is None + assert status["total_power_limit_watts"] == 320.0 + assert status["gpus"][0]["power_draw_available"] is False + + +def test_query_system_status_uses_optional_psutil(monkeypatch): + class FakePsutil: + @staticmethod + def cpu_percent(interval=0.1): + return 42.5 + + @staticmethod + def virtual_memory(): + return SimpleNamespace(percent=61.2, total=128 * 1024**3, available=48 * 1024**3) + + @staticmethod + def sensors_temperatures(fahrenheit=False): + return {"k10temp": [SimpleNamespace(current=63.4, label="Tctl")]} + + monkeypatch.setattr(training_monitor, "_psutil", FakePsutil) + monkeypatch.setitem(training_monitor._SYSTEM_STATUS_CACHE, "payload", None) + monkeypatch.setitem(training_monitor._SYSTEM_STATUS_CACHE, "expires_at", 0.0) + + status = training_monitor.query_system_status(cache_seconds=0) + + assert status["available"] is True + assert status["cpu"]["utilization_percent"] == 42.5 + assert status["cpu"]["temperature_c"] == 63.4 + assert status["cpu"]["temperature_percent"] == 66.74 + assert status["memory"]["used_percent"] == 61.2 + + +def test_training_dashboard_routes_register(monkeypatch): + import app as webapp # noqa: E402 + + monkeypatch.setattr(webapp, "list_training_runs", lambda limit=50: []) + monkeypatch.setattr( + webapp, + "load_training_status", + lambda run_name=None: { + "run_name": "unit", + "status": "running", + "latest_stage": {"train_stage": "tokenizer", "status": "running"}, + "stages": [{"train_stage": "tokenizer", "status": "running"}], + }, + ) + monkeypatch.setattr(webapp, "tail_training_log", lambda run_name=None, stage=None, lines=200: {"lines": [], "text": ""}) + monkeypatch.setattr(webapp, "query_gpu_status", lambda: {"available": False, "gpus": []}) + monkeypatch.setattr(webapp, "query_system_status", lambda: {"available": True, "cpu": {"utilization_percent": 10}}) + monkeypatch.setattr( + webapp, + "load_training_history", + lambda run_name=None, stage=None, limit=40: { + "run_name": "unit", + "stage": "tokenizer", + "point_count": 1, + "points": [{"step": 1, "total_steps": 10, "stage_percent": 10.0, "overall_percent": 5.0, "learning_rate": 0.1, "loss": 0.2}], + "latest_point": {"step": 1, "total_steps": 10}, + "latest_progress": {"updated_at": "2026-05-11T00:01:00Z"}, + }, + ) + monkeypatch.setattr( + webapp, + "inspect_training_artifacts", + lambda run_name=None, limit=50: { + "run_name": "unit", + "level": "waiting", + "label": "checkpoint 대기", + "message": "checkpoint 없음", + "model_weight_file_count": 0, + "checkpoint_file_count": 0, + "predictor_started": False, + "stages": { + "tokenizer": {"checkpoint_ready": False, "checkpoint_file_count": 0}, + "predictor": {"checkpoint_ready": False, "checkpoint_file_count": 0}, + }, + "recent_checkpoint_files": [], + "recent_model_weight_files": [], + }, + ) + + client = webapp.app.test_client() + + # P6 cutover 이후 v1 markup 검증은 /v1/* prefix 로 이동 + training_html = client.get("/v1/training").get_data(as_text=True) + index_html = client.get("/v1/").get_data(as_text=True) + stom_html = client.get("/v1/stom").get_data(as_text=True) + + assert "autoRefreshEnabled" in training_html + assert "refreshIntervalSeconds" in training_html + assert "trainingReadinessCard" in training_html + assert "trainingArtifactCard" in training_html + assert "runtimeSummaryCard" in training_html + assert "gpuSummaryMetrics" in training_html + assert "historyRows" in training_html + assert "formatKstDateTime" in training_html + assert "formatKstEtaTarget" in training_html + assert "Finish time(KST)" in training_html + assert "trainingInlinePanel" in index_html + assert "Kronos 금융 예측 웹 UI" in index_html + assert "제어 패널" in index_html + assert "대시보드 메뉴" in index_html + assert 'class="active"' in index_html + assert "trainingInlineReadiness" in index_html + assert "trainingInlineFinish" in index_html + assert "formatKstDateTime" in index_html + assert "stomTrainingStrip" in stom_html + assert "stomTrainingReadiness" in stom_html + assert "stomTrainingFinish" in stom_html + assert "stomKstGate" in stom_html + assert "stomTrainingArtifacts" in stom_html + assert "stomPerformanceGate" in stom_html + assert "stomPredictorGate" in stom_html + assert "stomCheckpointGate" in stom_html + assert "stomRuntimeGate" in stom_html + assert client.get("/api/training/runs").get_json() == {"runs": []} + status_json = client.get("/api/training/status").get_json() + assert status_json["run_name"] == "unit" + assert status_json["readiness"]["performance_ready"] is False + assert "토크나이저" in status_json["readiness"]["message"] + artifact_json = client.get("/api/training/artifacts").get_json() + assert artifact_json["label"] == "checkpoint 대기" + assert artifact_json["model_weight_file_count"] == 0 + assert client.get("/api/training/history").get_json()["point_count"] == 1 + assert client.get("/api/training/logs").get_json()["text"] == "" + assert client.get("/api/training/gpu").get_json()["available"] is False + assert client.get("/api/training/system").get_json()["cpu"]["utilization_percent"] == 10 + + +def test_training_readiness_policy_marks_predictor_states(): + import app as webapp # noqa: E402 + + tokenizer_payload = { + "status": "running", + "latest_stage": {"train_stage": "tokenizer", "status": "running"}, + "stages": [{"train_stage": "tokenizer", "status": "running"}], + } + predictor_payload = { + "status": "running", + "latest_stage": {"train_stage": "predictor", "status": "running"}, + "stages": [{"train_stage": "predictor", "status": "running", "stage_percent": 50}], + } + complete_payload = { + "status": "completed", + "latest_stage": {"train_stage": "predictor", "status": "completed"}, + "stages": [{"train_stage": "predictor", "status": "completed", "stage_percent": 100}], + } + ok_completed_phase_payload = { + "status": "ok", + "latest_stage": {"train_stage": "predictor", "status": "ok", "phase": "completed"}, + "stages": [{"train_stage": "predictor", "status": "ok", "phase": "completed", "stage_percent": 99.9974}], + } + + tokenizer = webapp.build_training_readiness(tokenizer_payload) + predictor = webapp.build_training_readiness(predictor_payload) + complete = webapp.build_training_readiness(complete_payload) + ok_completed_phase = webapp.build_training_readiness(ok_completed_phase_payload) + + assert tokenizer["level"] == "waiting" + assert tokenizer["performance_ready"] is False + assert predictor["level"] == "training" + assert predictor["predictor_started"] is True + assert predictor["performance_ready"] is False + assert complete["level"] == "ready" + assert complete["performance_ready"] is True + assert ok_completed_phase["level"] == "ready" + assert ok_completed_phase["performance_ready"] is True + + +def test_training_dashboard_refresh_interval_is_configurable_and_clamped(monkeypatch): + import app as webapp # noqa: E402 + + monkeypatch.setattr(webapp, "list_training_runs", lambda limit=50: []) + monkeypatch.setattr(webapp, "load_training_status", lambda run_name=None: {"run_name": "unit", "stages": []}) + monkeypatch.setattr(webapp, "tail_training_log", lambda run_name=None, stage=None, lines=200: {"lines": [], "text": ""}) + monkeypatch.setattr(webapp, "query_gpu_status", lambda: {"available": False, "gpus": []}) + monkeypatch.setattr(webapp, "query_system_status", lambda: {"available": False, "cpu": {}}) + + client = webapp.app.test_client() + + # P6 cutover: v1 refresh_interval 동작은 /v1/* 에서만 검증 (`/` 는 v2 SPA) + too_fast = client.get("/v1/training?refresh_interval=1").get_data(as_text=True) + custom = client.get("/v1/training?refresh_interval=17").get_data(as_text=True) + too_slow = client.get("/v1/training?refresh_interval=99999").get_data(as_text=True) + index_custom = client.get("/v1/?refresh_interval=11").get_data(as_text=True) + stom_custom = client.get("/v1/stom?refresh_interval=13").get_data(as_text=True) + + assert 'data-default-refresh-seconds="2"' in too_fast + assert 'value="17"' in custom + assert 'data-default-refresh-seconds="3600"' in too_slow + assert 'data-default-refresh-seconds="11"' in index_custom + assert 'data-default-refresh-seconds="13"' in stom_custom diff --git a/tests/test_training_progress.py b/tests/test_training_progress.py new file mode 100644 index 000000000..1d73cbcac --- /dev/null +++ b/tests/test_training_progress.py @@ -0,0 +1,116 @@ + +import json +import sys +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[1] +FINETUNE_DIR = REPO_ROOT / "finetune" +if str(FINETUNE_DIR) not in sys.path: + sys.path.insert(0, str(FINETUNE_DIR)) + +from training_progress import TrainingProgressTracker, parse_training_log_line # noqa: E402 + + +def test_parse_training_log_line_extracts_step_and_losses(): + line = "[Rank 0, Epoch 1/2, Step 1000/4701721] LR 0.000123, Loss: 1.2345" + + parsed = parse_training_log_line(line) + validation = parse_training_log_line("Validation Loss: 0.4321") + validation_progress = parse_training_log_line( + "[Rank 0] Tokenizer validation progress: Step 10/100, Samples 640, Loss: 0.5432" + ) + best = parse_training_log_line("Best model saved to C:/tmp/best_model (Val Loss: 0.3210)") + + assert parsed == { + "event": "train_step", + "rank": 0, + "epoch": 1, + "epochs": 2, + "step": 1000, + "total_steps": 4701721, + "learning_rate": 0.000123, + "loss": 1.2345, + } + assert validation["validation_loss"] == 0.4321 + assert validation_progress == { + "event": "validation_progress", + "validation_step": 10, + "validation_total_steps": 100, + "validation_samples": 640, + "validation_loss": 0.5432, + } + assert best["best_val_loss"] == 0.3210 + assert best["best_model_path"] == "C:/tmp/best_model" + + +def test_training_progress_tracker_writes_stage_and_overall_progress(tmp_path): + spec = { + "run_name": "unit_run", + "horizon": 60, + "mode": "full", + "train_stage": "predictor", + "requested_train_stage": "both", + "stage_index": 2, + "stage_count": 2, + "dataset_dir": "processed_datasets", + "target_train_samples": 200, + "target_val_samples": 40, + "sample_stage": None, + "env": {"KRONOS_BATCH_SIZE": "4", "WORLD_SIZE": "1", "KRONOS_EPOCHS": "1"}, + } + progress_path = tmp_path / "logs" / "predictor.progress.json" + tracker = TrainingProgressTracker( + spec=spec, + progress_path=progress_path, + stdout_path=tmp_path / "logs" / "predictor.stdout.log", + stderr_path=tmp_path / "logs" / "predictor.stderr.log", + manifest_path=tmp_path / "run_manifest.json", + ) + + tracker.start(pid=1234) + tracker.observe_line("[Rank 0] Train dataset size: 200, Validation dataset size: 40") + payload = tracker.observe_line("[Rank 0, Epoch 1/1, Step 25/100] LR 0.000100, Loss: 0.5000") + validation_payload = tracker.observe_line( + "[Rank 0] Tokenizer validation progress: Step 5/10, Samples 20, Loss: 0.4500" + ) + tracker.observe_line("Validation Loss: 0.4000") + final_payload = json.loads(progress_path.read_text(encoding="utf-8")) + + assert payload["status"] == "running" + assert payload["stage"]["percent"] == 25.0 + assert payload["stage"]["overall_percent"] == 62.5 + assert validation_payload["progress"]["phase"] == "validation" + assert validation_payload["progress"]["validation_fraction"] == 0.5 + assert validation_payload["stage"]["percent"] == 99.0 + assert validation_payload["stage"]["overall_percent"] == 99.5 + assert payload["timing"]["samples_per_second"] >= 0 + assert final_payload["dataset"]["train_dataset_size"] == 200 + assert final_payload["metrics"]["last_validation_loss"] == 0.4 + + +def test_dry_run_progress_does_not_look_half_complete(tmp_path): + spec = { + "run_name": "unit_run", + "horizon": 60, + "mode": "smoke", + "train_stage": "predictor", + "requested_train_stage": "both", + "stage_index": 2, + "stage_count": 2, + "sample_stage": None, + "env": {"KRONOS_BATCH_SIZE": "1", "WORLD_SIZE": "1", "KRONOS_EPOCHS": "1"}, + } + tracker = TrainingProgressTracker( + spec=spec, + progress_path=tmp_path / "predictor.progress.json", + stdout_path=tmp_path / "predictor.stdout.log", + stderr_path=tmp_path / "predictor.stderr.log", + manifest_path=tmp_path / "run_manifest.json", + status="dry_run", + ) + + payload = tracker.write() + + assert payload["stage"]["percent"] == 0.0 + assert payload["stage"]["overall_percent"] == 0.0 diff --git a/tests/test_v2_blueprint_isolation.py b/tests/test_v2_blueprint_isolation.py new file mode 100644 index 000000000..0010a9b75 --- /dev/null +++ b/tests/test_v2_blueprint_isolation.py @@ -0,0 +1,87 @@ +"""Verify official dashboard cutover preserves legacy archive and API routes.""" +from urllib.parse import urlparse + +from webui.app import app + + +OFFICIAL_SHELL_MARKER = "kronos-dashboard-shell" +LEGACY_PUBLIC_MARKERS = ( + "kronos-v2-version", + "p1-ssr", + "p1-5-spa", +) + + +def _location_path(location: str | None) -> str: + assert location is not None + parsed = urlparse(location) + return parsed.path or "/" + + +def _assert_official_shell(body: str) -> None: + assert OFFICIAL_SHELL_MARKER in body + for marker in LEGACY_PUBLIC_MARKERS: + assert marker not in body + + +def test_root_serves_official_dashboard_after_cutover(): + client = app.test_client() + + resp = client.get("/") + + assert resp.status_code == 200, "/ broke after cutover" + _assert_official_shell(resp.data.decode("utf-8")) + + +def test_training_bookmarks_serve_official_dashboard_shell(): + client = app.test_client() + + for path in ("/training", "/dashboard"): + resp = client.get(path) + assert resp.status_code == 200, f"{path} broke" + _assert_official_shell(resp.data.decode("utf-8")) + + +def test_v1_legacy_routes_still_available(): + client = app.test_client() + + for path in ("/v1/", "/v1/training", "/v1/stom"): + resp = client.get(path) + assert resp.status_code == 200, f"{path} broke" + + +def test_versioned_dashboard_urls_redirect_to_canonical_routes(): + client = app.test_client() + + for path in ("/v2", "/v2/"): + resp = client.get(path, follow_redirects=False) + assert resp.status_code == 301 + assert _location_path(resp.headers.get("Location")) == "/" + + for path in ("/rl-lab", "/v2/rl-trading", "/v2/rl-lab"): + resp = client.get(path, follow_redirects=False) + assert resp.status_code == 301 + assert _location_path(resp.headers.get("Location")) == "/rl" + + +def test_api_routes_unchanged(): + client = app.test_client() + + for path in ( + "/api/training/status", + "/api/training/history", + "/api/training/artifacts", + "/api/training/gpu", + "/api/training/system", + "/api/training/runs", + "/api/rl/runs", + ): + resp = client.get(path) + assert resp.status_code == 200, f"{path} broke" + + +def test_no_global_catchall(): + rules = [str(rule) for rule in app.url_map.iter_rules()] + + assert "/" not in rules + assert not any(rule == "/" for rule in rules) diff --git a/tests/test_v2_dist_marker.py b/tests/test_v2_dist_marker.py new file mode 100644 index 000000000..b68f975f9 --- /dev/null +++ b/tests/test_v2_dist_marker.py @@ -0,0 +1,57 @@ +"""Verify official dashboard markers are preserved after Vite build.""" +from pathlib import Path + +import pytest + + +DIST_INDEX = ( + Path(__file__).resolve().parents[1] + / "webui" + / "static" + / "v2" + / "dist" + / "index.html" +) +OFFICIAL_SHELL_MARKER = "kronos-dashboard-shell" +LEGACY_PUBLIC_MARKERS = ( + "kronos-v2-version", + "p1-ssr", + "p1-5-spa", +) + + +def _dist_body() -> str: + return DIST_INDEX.read_text(encoding="utf-8") + + +@pytest.mark.skipif(not DIST_INDEX.exists(), reason="dist has not been built yet") +def test_official_dist_marker_preserved_after_build(): + body = _dist_body() + + assert OFFICIAL_SHELL_MARKER in body + for marker in LEGACY_PUBLIC_MARKERS: + assert marker not in body + + +@pytest.mark.skipif(not DIST_INDEX.exists(), reason="dist has not been built yet") +def test_dist_base_url_matches_flask_static(): + body = _dist_body() + + assert "/static/v2/dist/assets/" in body + + +@pytest.mark.skipif(not DIST_INDEX.exists(), reason="dist has not been built yet") +def test_dist_fallback_first_paint_present(): + body = _dist_body() + + assert 'id="hero-strip"' in body + assert 'data-tab="live-training"' in body + + +@pytest.mark.skipif(not DIST_INDEX.exists(), reason="dist has not been built yet") +def test_dist_public_copy_has_no_versioned_dashboard_label(): + body = _dist_body() + + assert "Kronos v2" not in body + assert "P1" not in body + assert "P1.5" not in body diff --git a/tests/test_v2_route.py b/tests/test_v2_route.py new file mode 100644 index 000000000..8138c494e --- /dev/null +++ b/tests/test_v2_route.py @@ -0,0 +1,85 @@ +"""Verify official dashboard canonical routes and legacy redirects.""" +from urllib.parse import urlparse + +from webui.app import app + + +OFFICIAL_SHELL_MARKER = "kronos-dashboard-shell" +LEGACY_PUBLIC_MARKERS = ( + "kronos-v2-version", + "p1-ssr", + "p1-5-spa", +) + + +def _location_path(location: str | None) -> str: + assert location is not None + parsed = urlparse(location) + return parsed.path or "/" + + +def _assert_official_shell(body: str) -> None: + assert OFFICIAL_SHELL_MARKER in body + for marker in LEGACY_PUBLIC_MARKERS: + assert marker not in body + + +def test_root_returns_official_dashboard_shell(): + client = app.test_client() + + resp = client.get("/") + + assert resp.status_code == 200 + _assert_official_shell(resp.data.decode("utf-8")) + + +def test_training_bookmarks_return_official_shell(): + client = app.test_client() + + for path in ("/training", "/dashboard"): + resp = client.get(path) + assert resp.status_code == 200, f"{path} broke" + _assert_official_shell(resp.data.decode("utf-8")) + + +def test_rl_canonical_route_returns_official_shell(): + client = app.test_client() + + resp = client.get("/rl") + + assert resp.status_code == 200 + _assert_official_shell(resp.data.decode("utf-8")) + + +def test_daily_ohlcv_route_returns_official_shell(): + client = app.test_client() + + for path in ("/daily-ohlcv", "/daily", "/daily-rl-guide", "/daily-ohlcv/rl-guide"): + resp = client.get(path) + assert resp.status_code == 200, f"{path} broke" + _assert_official_shell(resp.data.decode("utf-8")) + + +def test_legacy_v2_routes_redirect_to_canonical_routes(): + client = app.test_client() + + main_routes = ("/v2", "/v2/") + for path in main_routes: + resp = client.get(path, follow_redirects=False) + assert resp.status_code == 301, f"{path} should redirect" + assert _location_path(resp.headers.get("Location")) == "/" + + rl_routes = ("/rl-lab", "/v2/rl-trading", "/v2/rl-lab") + for path in rl_routes: + resp = client.get(path, follow_redirects=False) + assert resp.status_code == 301, f"{path} should redirect" + assert _location_path(resp.headers.get("Location")) == "/rl" + + +def test_unknown_v2_subpath_redirects_to_root_without_catchall(): + client = app.test_client() + + resp = client.get("/v2/unknown", follow_redirects=False) + + assert resp.status_code == 301 + assert _location_path(resp.headers.get("Location")) == "/" diff --git a/webui/AGENTS.md b/webui/AGENTS.md new file mode 100644 index 000000000..4fef0fcaa --- /dev/null +++ b/webui/AGENTS.md @@ -0,0 +1,63 @@ +# webui Knowledge + +## Overview + +`webui/` is the Flask backend plus dashboard adapter layer. It serves the +**official Kronos dashboard** at `/`, exposes the regular RL evidence route at +`/rl`, and keeps old versioned/lab URLs only as compatibility redirects. + +Internal implementation paths still use `v2` names (`webui/v2`, +`webui/v2_src`, `/static/v2/dist/`). Do not rename those paths in ordinary +feature work; remove only public/user-facing version labels. + +## Key Files + +| File | Role | +|---|---| +| `app.py` | Flask app, API routes, official dashboard blueprint registration. | +| `run.py` | Local dashboard launcher. | +| `training_monitor.py` | Training/log/GPU status adapter. | +| `stom_dashboard.py` | STOM prediction/backtest dashboard adapter. | +| `rl_dashboard.py` | RL run discovery, summaries, events, table readers. | +| `v2/__init__.py` | Official shell routing plus legacy compatibility redirects. | +| `v2_src/` | Svelte/Vite dashboard source. | + +## Route Rules + +- `/` is the official dashboard route. +- `/rl` is the official RL trading/evidence route. +- `/training` and `/dashboard` are supported dashboard bookmarks. +- `/v2`, `/v2/`, `/v2/rl-trading`, `/v2/rl-lab`, and `/rl-lab` are legacy + compatibility redirects only. +- Built `webui/static/v2/dist/index.html` is served by default when present. +- Use `KRONOS_DASHBOARD_MODE=ssr` or `KRONOS_DASHBOARD_SSR_FALLBACK=1` only + when explicitly testing the Jinja fallback shell. + +## API Rules + +- Keep dashboard APIs read-only. Do not add broker/live-order side effects here. +- Prevent path traversal when exposing artifacts or table aliases. +- Prefer server-computed comparison fields for baselines/cost gates so the UI + does not invent trading interpretations. +- Any readiness card must distinguish "data/env ready" from "model usable". + +## Dashboard Direction + +- The RL dashboard should falsify models clearly: show `NO-GO`, baseline delta, + split/seed/cost metadata, drawdown, and trade count. +- Live/replay visuals are observation tools, not evidence of profitability. +- If an equity curve comes from a rule baseline, label it as rule/baseline. + +## Verification + +```powershell +py -3.11 -m pytest tests/test_stom_rl_dashboard_api.py tests/test_v2_route.py tests/test_v2_dist_marker.py -q +cd webui/v2_src +npm run build +``` + +## Gotchas + +- `webui/rl_runs/`, `prediction_results/`, `qlib_backtests/`, and similar + folders are generated outputs. +- Dist assets under `webui/static/v2/dist/` must match the latest frontend build. diff --git a/webui/README_STOM_DASHBOARD.md b/webui/README_STOM_DASHBOARD.md new file mode 100644 index 000000000..7b3d72693 --- /dev/null +++ b/webui/README_STOM_DASHBOARD.md @@ -0,0 +1,137 @@ +# STOM Kronos 대시보드 사용법 + +## 목적 + +`/stom` 페이지는 STOM tick DB에서 만든 Kronos OHLCV 학습/검증 결과를 확인하는 연구용 대시보드입니다. + +확인할 수 있는 것: + +- 전체 DB 학습 가능 요약 +- 파일럿/전체 학습 진행 명령 +- 실제값 vs 예측값 차트 +- MAE/RMSE/MAPE/방향정확도 +- 예상등락률 Top-K 검증 +- Qlib-style Top-K backtest artifact/equity curve + +## 1. 대시보드 의존성 설치 + +기존 webui requirements를 사용합니다. + +```powershell +python -m pip install -r webui/requirements.txt +``` + +## 2. 파일럿 데이터 생성 + +```powershell +python finetune_csv/prepare_stom_1tick.py export ` + --db _database/stock_tick_back.db ` + --output finetune_csv/data/stom_1tick_kline_pilot.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-tables 100 ` + --price-mode close_only +``` + +## 3. 예측 검증 파일 생성 + +모델이 아직 없으면 baseline으로 먼저 대시보드를 확인합니다. + +```powershell +python finetune_csv/stom_prediction_eval.py ` + --data finetune_csv/data/stom_1tick_kline_pilot.csv ` + --output webui/stom_predictions/pilot_predictions.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 20 ` + --mode baseline +``` + +학습 모델이 있으면: + +```powershell +python finetune_csv/stom_prediction_eval.py ` + --data finetune_csv/data/stom_1tick_kline_pilot.csv ` + --output webui/stom_predictions/kronos_predictions.csv ` + --lookback-window 300 ` + --predict-window 60 ` + --max-windows 20 ` + --mode kronos ` + --model-path finetune_csv/finetuned/stom_1tick_lookback300_pred60/basemodel/best_model ` + --tokenizer-path NeoQuasar/Kronos-Tokenizer-base ` + --device cuda:0 +``` + +## 4. 실행 + +```powershell +python webui/run.py +``` + +접속: + +```text +http://localhost:7070/stom +``` + +## 5. Qlib-style Top-K backtest 표시 + +Kronos 예측 CSV가 있으면 Qlib-style score backtest artifact를 생성할 수 있습니다. + +```powershell +python finetune\qlib_stom_pipeline.py score-backtest ` + --prediction-csv webui\stom_predictions\kronos_all_predictions.csv ` + --output-dir webui\qlib_backtests ` + --top-k 10 ` + --cost-bps 15 ` + --slippage-bps 10 +``` + +생성된 `webui/qlib_backtests/*.json`은 `/stom` 페이지의 **Qlib Top-K 백테스트** 영역에서 선택해 equity curve와 성과 metrics를 확인할 수 있습니다. + +## 6. Qlib 실행 환경 점검 + +Qlib `.bin` 변환/provider 검증 단계로 넘어가기 전: + +```powershell +python finetune\qlib_stom_pipeline.py qlib-env-check +``` + +현재 워크스테이션에서는 `pyqlib 0.9.7` 설치와 `.omx/external/qlib/scripts/dump_bin.py` 확보가 확인되었습니다. export report가 있으면 dump command를 dry-run으로 확인합니다. + +```powershell +python finetune\qlib_stom_pipeline.py dump-bin ` + --export-report finetune\qlib_exports\stom_1min_pilot\stom_qlib_export_report.json ` + --qlib-dir finetune\qlib_exports\stom_1min_pilot\qlib_bin ` + --dump-bin-script .omx\external\qlib\scripts\dump_bin.py ` + --freq 1min +``` + +실제 변환은 `--execute`를 붙입니다. + +```powershell +python finetune\qlib_stom_pipeline.py dump-bin ` + --export-report finetune\qlib_exports\stom_1min_pilot\stom_qlib_export_report.json ` + --qlib-dir finetune\qlib_exports\stom_1min_pilot\qlib_bin ` + --dump-bin-script .omx\external\qlib\scripts\dump_bin.py ` + --freq 1min ` + --execute +``` + +provider smoke: + +```powershell +python finetune\qlib_stom_pipeline.py provider-smoke ` + --provider-uri finetune\qlib_exports\stom_1min_pilot\qlib_bin ` + --region cn ` + --freq 1min +``` + +주의: 1초봉(`--freq 1s`)은 dump_bin 변환은 가능하지만 pyqlib provider의 `D.calendar(freq="1s")`가 지원하지 않습니다. 1초봉 학습은 Qlib provider보다 `processed_datasets/*.pkl`을 통한 Kronos fine-tuning 경로를 사용하세요. + +## 주의 + +- `webui/stom_predictions/*.csv`는 생성 결과이므로 기본 커밋 대상이 아닙니다. +- `webui/qlib_backtests/*`도 생성 결과이므로 기본 커밋 대상이 아닙니다. +- baseline 결과는 모델 성능이 아니라 대시보드 smoke test용입니다. +- 실제 학습 모델 검증은 `--mode kronos`로 생성한 prediction CSV를 사용해야 합니다. diff --git a/webui/app.py b/webui/app.py index d240a3729..95cedfe0b 100644 --- a/webui/app.py +++ b/webui/app.py @@ -1,10 +1,10 @@ import os +import re as _re import pandas as pd -import numpy as np import json import plotly.graph_objects as go import plotly.utils -from flask import Flask, render_template, request, jsonify +from flask import Flask, Response, render_template, request, jsonify from flask_cors import CORS import sys import warnings @@ -14,15 +14,333 @@ # Add project root directory to path sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +Kronos = None +KronosTokenizer = None +KronosPredictor = None +MODEL_AVAILABLE = True +MODEL_IMPORT_ERROR = None + +def ensure_kronos_imported(): + """Import Kronos lazily so dashboard routes work even when torch/model deps are broken.""" + global Kronos, KronosTokenizer, KronosPredictor, MODEL_AVAILABLE, MODEL_IMPORT_ERROR + if Kronos is not None and KronosTokenizer is not None and KronosPredictor is not None: + return True + try: + from model import Kronos as _Kronos + from model import KronosPredictor as _KronosPredictor + from model import KronosTokenizer as _KronosTokenizer + + Kronos = _Kronos + KronosTokenizer = _KronosTokenizer + KronosPredictor = _KronosPredictor + MODEL_AVAILABLE = True + MODEL_IMPORT_ERROR = None + return True + except Exception as exc: + MODEL_AVAILABLE = False + MODEL_IMPORT_ERROR = str(exc) + print(f"Warning: Kronos model cannot be imported ({exc}), prediction model loading is disabled") + return False + +try: + try: + from .stom_dashboard import ( + list_filter_report_files, + list_qlib_backtest_files, + list_prediction_files, + load_horizon_comparison, + load_filter_report_artifact, + load_qlib_backtest_artifact, + load_prediction_frame, + load_training_summary, + prediction_chart_json, + prediction_diagnostics, + prediction_metrics, + prediction_visual_payload, + qlib_backtest_chart_json, + recommendation_export_csv, + recommendation_export_payload, + ranked_recommendations, + recommendation_summary, + score_backtest_report, + topk_rows, + ) + except ImportError: + from stom_dashboard import ( + list_filter_report_files, + list_qlib_backtest_files, + list_prediction_files, + load_horizon_comparison, + load_filter_report_artifact, + load_qlib_backtest_artifact, + load_prediction_frame, + load_training_summary, + prediction_chart_json, + prediction_diagnostics, + prediction_metrics, + prediction_visual_payload, + qlib_backtest_chart_json, + recommendation_export_csv, + recommendation_export_payload, + ranked_recommendations, + recommendation_summary, + score_backtest_report, + topk_rows, + ) +except Exception as exc: + print(f"Warning: STOM dashboard helpers cannot be imported ({exc})") + list_filter_report_files = None + list_qlib_backtest_files = None + list_prediction_files = None + load_horizon_comparison = None + load_filter_report_artifact = None + load_qlib_backtest_artifact = None + load_prediction_frame = None + load_training_summary = None + prediction_chart_json = None + prediction_diagnostics = None + prediction_metrics = None + prediction_visual_payload = None + qlib_backtest_chart_json = None + recommendation_export_csv = None + recommendation_export_payload = None + ranked_recommendations = None + recommendation_summary = None + score_backtest_report = None + topk_rows = None + +try: + try: + from .training_monitor import ( + inspect_training_artifacts, + list_training_runs, + load_training_history, + load_training_status, + query_gpu_status, + query_system_status, + tail_training_log, + ) + except ImportError: + from training_monitor import ( + inspect_training_artifacts, + list_training_runs, + load_training_history, + load_training_status, + query_gpu_status, + query_system_status, + tail_training_log, + ) +except Exception as exc: + print(f"Warning: STOM training monitor helpers cannot be imported ({exc})") + inspect_training_artifacts = None + list_training_runs = None + load_training_history = None + load_training_status = None + query_gpu_status = None + query_system_status = None + tail_training_log = None + try: - from model import Kronos, KronosTokenizer, KronosPredictor - MODEL_AVAILABLE = True -except ImportError: - MODEL_AVAILABLE = False - print("Warning: Kronos model cannot be imported, will use simulated data for demonstration") + try: + from .rl_dashboard import ( + list_rl_runs, + load_rl_cost_gate, + load_rl_events, + load_rl_progress, + load_rl_run, + load_rl_table, + ) + except ImportError: + from rl_dashboard import ( + list_rl_runs, + load_rl_cost_gate, + load_rl_events, + load_rl_progress, + load_rl_run, + load_rl_table, + ) +except Exception as exc: + print(f"Warning: STOM RL dashboard helpers cannot be imported ({exc})") + list_rl_runs = None + load_rl_cost_gate = None + load_rl_events = None + load_rl_progress = None + load_rl_run = None + load_rl_table = None + +try: + try: + from .rl_dashboard_factory import ( + list_lane_runs, + load_factory_queue, + load_lane_calibration, + load_lane_edge_ledger, + list_forward_ledger_runs, + list_sizing_runs, + load_forward_ledger, + list_risk_policy_runs, + load_model_build_readiness, + list_fresh_validation_runs, + ) + except ImportError: + from rl_dashboard_factory import ( + list_lane_runs, + load_factory_queue, + load_lane_calibration, + load_lane_edge_ledger, + list_forward_ledger_runs, + list_sizing_runs, + load_forward_ledger, + list_risk_policy_runs, + load_model_build_readiness, + list_fresh_validation_runs, + ) +except Exception as exc: + print(f"Warning: STOM RL factory dashboard helpers cannot be imported ({exc})") + list_lane_runs = None + load_factory_queue = None + load_lane_calibration = None + load_lane_edge_ledger = None + list_forward_ledger_runs = None + list_sizing_runs = None + load_forward_ledger = None + list_risk_policy_runs = None + load_model_build_readiness = None + list_fresh_validation_runs = None + +try: + try: + from .daily_ohlcv_dashboard import ( + list_universe_manifests, + list_dataset_artifacts, + load_coverage_chart, + load_decision_cockpit, + load_scenario_lab, + load_scenario_run_ledger, + load_daily_rl_env_guide, + load_research_workflow_catalog, + load_research_workflow_detail, + create_research_job_intent, + load_research_job_intent, + load_research_job_intent_ledger, + load_rejection_analytics, + load_daily_flow_chart, + load_daily_metric_glossary, + load_research_diagnostics_chart, + load_equity_overlay_chart, + load_dataset_chart, + load_prediction_chart, + load_prediction_latest, + load_portfolio_chart, + load_portfolio_latest, + load_walk_forward_chart, + load_walk_forward_latest, + load_registry_latest, + load_run_scatter_chart, + load_symbol_chart, + load_universe_breakdown_chart, + load_walk_forward_heatmap_chart, + load_dataset_latest, + load_daily_artifacts, + load_daily_db_summary, + load_daily_progress, + load_daily_symbol, + load_not_started_surface, + load_universe_chart, + load_universe_preview, + ) + except ImportError: + from daily_ohlcv_dashboard import ( + list_universe_manifests, + list_dataset_artifacts, + load_coverage_chart, + load_decision_cockpit, + load_scenario_lab, + load_scenario_run_ledger, + load_daily_rl_env_guide, + load_research_workflow_catalog, + load_research_workflow_detail, + create_research_job_intent, + load_research_job_intent, + load_research_job_intent_ledger, + load_rejection_analytics, + load_daily_flow_chart, + load_daily_metric_glossary, + load_research_diagnostics_chart, + load_equity_overlay_chart, + load_dataset_chart, + load_prediction_chart, + load_prediction_latest, + load_portfolio_chart, + load_portfolio_latest, + load_walk_forward_chart, + load_walk_forward_latest, + load_registry_latest, + load_run_scatter_chart, + load_symbol_chart, + load_universe_breakdown_chart, + load_walk_forward_heatmap_chart, + load_dataset_latest, + load_daily_artifacts, + load_daily_db_summary, + load_daily_progress, + load_daily_symbol, + load_not_started_surface, + load_universe_chart, + load_universe_preview, + ) +except Exception as exc: + print(f"Warning: Daily OHLCV dashboard helpers cannot be imported ({exc})") + list_universe_manifests = None + list_dataset_artifacts = None + load_coverage_chart = None + load_decision_cockpit = None + load_scenario_lab = None + load_scenario_run_ledger = None + load_daily_rl_env_guide = None + load_research_workflow_catalog = None + load_research_workflow_detail = None + create_research_job_intent = None + load_research_job_intent = None + load_research_job_intent_ledger = None + load_rejection_analytics = None + load_daily_flow_chart = None + load_daily_metric_glossary = None + load_research_diagnostics_chart = None + load_equity_overlay_chart = None + load_dataset_chart = None + load_prediction_chart = None + load_prediction_latest = None + load_portfolio_chart = None + load_portfolio_latest = None + load_walk_forward_chart = None + load_walk_forward_latest = None + load_registry_latest = None + load_run_scatter_chart = None + load_symbol_chart = None + load_universe_breakdown_chart = None + load_walk_forward_heatmap_chart = None + load_dataset_latest = None + load_daily_artifacts = None + load_daily_db_summary = None + load_daily_progress = None + load_daily_symbol = None + load_not_started_surface = None + load_universe_chart = None + load_universe_preview = None +try: + try: + from .v2 import v2_bp + except ImportError: + from v2 import v2_bp +except Exception as exc: + print(f"Warning: v2 blueprint cannot be imported ({exc})") + v2_bp = None app = Flask(__name__) CORS(app) +if v2_bp is not None: + app.register_blueprint(v2_bp) # Global variables to store models tokenizer = None @@ -37,7 +355,7 @@ 'tokenizer_id': 'NeoQuasar/Kronos-Tokenizer-2k', 'context_length': 2048, 'params': '4.1M', - 'description': 'Lightweight model, suitable for fast prediction' + 'description': '가벼운 모델로 빠른 예측에 적합합니다' }, 'kronos-small': { 'name': 'Kronos-small', @@ -45,7 +363,7 @@ 'tokenizer_id': 'NeoQuasar/Kronos-Tokenizer-base', 'context_length': 512, 'params': '24.7M', - 'description': 'Small model, balanced performance and speed' + 'description': '성능과 속도의 균형이 좋은 small 모델입니다' }, 'kronos-base': { 'name': 'Kronos-base', @@ -53,10 +371,116 @@ 'tokenizer_id': 'NeoQuasar/Kronos-Tokenizer-base', 'context_length': 512, 'params': '102.3M', - 'description': 'Base model, provides better prediction quality' + 'description': '더 높은 예측 품질을 목표로 하는 base 모델입니다' } } +MIN_TRAINING_REFRESH_SECONDS = 2 +MAX_TRAINING_REFRESH_SECONDS = 3600 + + +def _default_training_refresh_seconds(): + try: + return int(float(os.environ.get("STOM_TRAINING_REFRESH_SECONDS", "5") or 5)) + except (TypeError, ValueError): + return 5 + + +DEFAULT_TRAINING_REFRESH_SECONDS = _default_training_refresh_seconds() + + +def resolve_training_refresh_seconds(raw_value=None): + """Return a safe UI auto-refresh interval in seconds.""" + if raw_value in (None, ""): + raw_value = DEFAULT_TRAINING_REFRESH_SECONDS + try: + seconds = int(float(raw_value)) + except (TypeError, ValueError): + seconds = DEFAULT_TRAINING_REFRESH_SECONDS + return max(MIN_TRAINING_REFRESH_SECONDS, min(seconds, MAX_TRAINING_REFRESH_SECONDS)) + + +def build_training_readiness(status_payload): + """Return a shared read-only readiness policy for all training widgets. + + The web UI intentionally distinguishes "training is progressing" from + "a usable predictor/checkpoint exists" so an in-progress tokenizer run is + not interpreted as a completed forecasting model. + """ + payload = status_payload or {} + stages = payload.get("stages") or [] + latest = payload.get("latest_stage") or {} + run_status = str(payload.get("status") or latest.get("status") or "unknown").lower() + + def stage_name(stage): + return str((stage or {}).get("train_stage") or (stage or {}).get("stage") or "").lower() + + def stage_status(stage): + return str((stage or {}).get("status") or "").lower() + + def stage_phase(stage): + return str((stage or {}).get("phase") or "").lower() + + def safe_percent(stage): + try: + return float((stage or {}).get("stage_percent") or 0) + except (TypeError, ValueError): + return 0.0 + + predictor_stage = next((stage for stage in stages if stage_name(stage) == "predictor"), None) + latest_stage_name = stage_name(latest) + predictor_status = stage_status(predictor_stage) + predictor_phase = stage_phase(predictor_stage) + complete_statuses = {"complete", "completed", "done", "finished", "success", "succeeded"} + complete_phases = {"complete", "completed", "done", "finished", "success", "succeeded"} + pending_statuses = {"", "dry_run", "pending", "queued", "not_started", "waiting"} + + predictor_started = bool(predictor_stage) and predictor_status not in pending_statuses + predictor_complete = ( + bool(predictor_stage) + and ( + predictor_status in complete_statuses + or predictor_phase in complete_phases + or ( + stage_name(predictor_stage) == "predictor" + and safe_percent(predictor_stage) >= 100 + and (run_status in complete_statuses or run_status == "ok") + ) + ) + ) + + if predictor_complete: + level = "ready" + label = "예측 성과 확인 가능" + message = "예측기 학습이 완료된 상태입니다. 이제 실제값/예측값 검증과 성과 지표 확인이 가능합니다." + performance_ready = True + elif predictor_started: + level = "training" + label = "predictor 학습 중" + message = "예측기 단계가 진행 중입니다. 체크포인트가 저장되기 전까지 성과 수치를 확정하지 않습니다." + performance_ready = False + elif latest_stage_name == "tokenizer" or run_status == "running": + level = "waiting" + label = "성과 대기: tokenizer 학습 중" + message = "현재 토크나이저 단계입니다. 체크포인트와 예측기가 아직 준비되지 않아 예측 정확도/수익률을 판단하지 않습니다." + performance_ready = False + else: + level = "waiting" + label = "성과 대기" + message = "학습 산출물 또는 예측기 완료 상태가 확인되기 전까지 예측 성과는 대기 상태로 표시합니다." + performance_ready = False + + return { + "level": level, + "label": label, + "message": message, + "performance_ready": performance_ready, + "checkpoint_expected": not performance_ready, + "predictor_started": predictor_started, + "predictor_complete": predictor_complete, + "latest_stage": latest_stage_name or "-", + } + def load_data_files(): """Scan data directory and return available data files""" data_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data') @@ -83,12 +507,12 @@ def load_data_file(file_path): elif file_path.endswith('.feather'): df = pd.read_feather(file_path) else: - return None, "Unsupported file format" + return None, "지원하지 않는 파일 형식입니다" # Check required columns required_cols = ['open', 'high', 'low', 'close'] if not all(col in df.columns for col in required_cols): - return None, f"Missing required columns: {required_cols}" + return None, f"필수 컬럼이 없습니다: {required_cols}" # Process timestamp column if 'timestamps' in df.columns: @@ -120,7 +544,7 @@ def load_data_file(file_path): return df, None except Exception as e: - return None, f"Failed to load file: {str(e)}" + return None, f"파일을 불러오지 못했습니다: {str(e)}" def save_prediction_results(file_path, prediction_type, prediction_results, actual_data, input_data, prediction_params): """Save prediction results to file""" @@ -203,7 +627,7 @@ def save_prediction_results(file_path, prediction_type, prediction_results, actu return filepath except Exception as e: - print(f"Failed to save prediction results: {e}") + print(f"예측 결과 저장 실패: {e}") return None def create_prediction_chart(df, pred_df, lookback, pred_len, actual_df=None, historical_start_idx=0): @@ -230,7 +654,7 @@ def create_prediction_chart(df, pred_df, lookback, pred_len, actual_df=None, his high=historical_df['high'], low=historical_df['low'], close=historical_df['close'], - name='Historical Data (400 data points)', + name='과거 데이터(400개 데이터 포인트)', increasing_line_color='#26A69A', decreasing_line_color='#EF5350' )) @@ -258,7 +682,7 @@ def create_prediction_chart(df, pred_df, lookback, pred_len, actual_df=None, his high=pred_df['high'], low=pred_df['low'], close=pred_df['close'], - name='Prediction Data (120 data points)', + name='예측 데이터(120개 데이터 포인트)', increasing_line_color='#66BB6A', decreasing_line_color='#FF7043' )) @@ -291,16 +715,16 @@ def create_prediction_chart(df, pred_df, lookback, pred_len, actual_df=None, his high=actual_df['high'], low=actual_df['low'], close=actual_df['close'], - name='Actual Data (120 data points)', + name='실제 데이터(120개 데이터 포인트)', increasing_line_color='#FF9800', decreasing_line_color='#F44336' )) # Update layout fig.update_layout( - title='Kronos Financial Prediction Results - 400 Historical Points + 120 Prediction Points vs 120 Actual Points', - xaxis_title='Time', - yaxis_title='Price', + title='Kronos 금융 예측 결과 - 과거 400포인트 + 예측 120포인트 vs 실제 120포인트', + xaxis_title='시간', + yaxis_title='가격', template='plotly_white', height=600, showlegend=True @@ -327,10 +751,967 @@ def create_prediction_chart(df, pred_df, lookback, pred_len, actual_df=None, his return json.dumps(fig, cls=plotly.utils.PlotlyJSONEncoder) -@app.route('/') -def index(): - """Home page""" - return render_template('index.html') +@app.route('/v1/') +def v1_index(): + """v1 legacy 메인 예측 화면 — P6 cutover 후 6 개월 archive 정책.""" + return render_template( + 'index.html', + default_training_refresh_seconds=resolve_training_refresh_seconds(request.args.get('refresh_interval')), + ) + +@app.route('/v1/stom') +def v1_stom_dashboard_page(): + """v1 legacy STOM 진단 대시보드.""" + return render_template( + 'stom_dashboard.html', + default_training_refresh_seconds=resolve_training_refresh_seconds(request.args.get('refresh_interval')), + ) + +@app.route('/v1/training') +def v1_training_dashboard_page(): + """v1 legacy 학습 모니터.""" + return render_template( + 'training_dashboard.html', + default_training_refresh_seconds=resolve_training_refresh_seconds(request.args.get('refresh_interval')), + min_training_refresh_seconds=MIN_TRAINING_REFRESH_SECONDS, + max_training_refresh_seconds=MAX_TRAINING_REFRESH_SECONDS, + ) + +@app.route('/api/training/runs') +def training_runs(): + if list_training_runs is None: + return jsonify({'error': 'STOM training monitor helper is not available'}), 500 + try: + limit = int(request.args.get('limit', 50)) + return jsonify({'runs': list_training_runs(limit=limit)}) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/training/status') +def training_status(): + if load_training_status is None: + return jsonify({'error': 'STOM training monitor helper is not available'}), 500 + run_name = request.args.get('run') or None + try: + status_payload = load_training_status(run_name) + status_payload['readiness'] = build_training_readiness(status_payload) + return jsonify(status_payload) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/training/logs') +def training_logs(): + if tail_training_log is None: + return jsonify({'error': 'STOM training monitor helper is not available'}), 500 + run_name = request.args.get('run') or None + stage = request.args.get('stage') or None + try: + lines = int(request.args.get('lines', 200)) + return jsonify(tail_training_log(run_name=run_name, stage=stage, lines=lines)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/training/artifacts') +def training_artifacts(): + if inspect_training_artifacts is None: + return jsonify({'error': 'STOM training monitor helper is not available'}), 500 + run_name = request.args.get('run') or None + try: + limit = int(request.args.get('limit', 50)) + return jsonify(inspect_training_artifacts(run_name=run_name, limit=limit)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/training/history') +def training_history(): + if load_training_history is None: + return jsonify({'error': 'STOM training monitor helper is not available'}), 500 + run_name = request.args.get('run') or None + stage = request.args.get('stage') or None + try: + limit = int(request.args.get('limit', 40)) + return jsonify(load_training_history(run_name=run_name, stage=stage, limit=limit)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/training/gpu') +def training_gpu(): + if query_gpu_status is None: + return jsonify({'error': 'STOM training monitor helper is not available'}), 500 + return jsonify(query_gpu_status()) + +@app.route('/api/training/system') +def training_system(): + if query_system_status is None: + return jsonify({'error': 'STOM training monitor helper is not available'}), 500 + return jsonify(query_system_status()) + + +def _rl_table_limit(default=500): + try: + limit = int(request.args.get('limit', default)) + except (TypeError, ValueError): + limit = default + return max(0, min(limit, 5000)) + + +@app.route('/api/rl/runs') +def rl_runs(): + if list_rl_runs is None: + return jsonify({'error': 'STOM RL dashboard helper is not available'}), 500 + try: + limit = _rl_table_limit(default=50) + return jsonify({'runs': list_rl_runs(limit=limit)}) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/rl/progress') +def rl_progress(): + if load_rl_progress is None: + return jsonify({'error': 'STOM RL dashboard helper is not available'}), 500 + try: + return jsonify(load_rl_progress()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/rl/runs/') +def rl_run_detail(run_name): + if load_rl_run is None: + return jsonify({'error': 'STOM RL dashboard helper is not available'}), 500 + try: + return jsonify(load_rl_run(run_name)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +def _rl_table_response(run_name, table_name): + if load_rl_table is None: + return jsonify({'error': 'STOM RL dashboard helper is not available'}), 500 + try: + return jsonify( + load_rl_table( + run_name, + table_name, + policy=request.args.get('policy') or None, + limit=_rl_table_limit(), + ) + ) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/rl/runs//actions') +def rl_run_actions(run_name): + return _rl_table_response(run_name, 'actions') + + +@app.route('/api/rl/runs//trades') +def rl_run_trades(run_name): + return _rl_table_response(run_name, 'trades') + + +@app.route('/api/rl/runs//equity') +def rl_run_equity(run_name): + return _rl_table_response(run_name, 'equity') + + +@app.route('/api/rl/runs//episodes') +def rl_run_episodes(run_name): + return _rl_table_response(run_name, 'episodes') + + +@app.route('/api/rl/runs//table/') +def rl_run_table(run_name, table_name): + return _rl_table_response(run_name, table_name) + + + + +@app.route('/api/rl/runs//events') +def rl_run_events(run_name): + if load_rl_events is None: + return jsonify({'error': 'STOM RL dashboard helper is not available'}), 500 + try: + return jsonify(load_rl_events(run_name, limit=_rl_table_limit())) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/rl/runs//cost-gate') +def rl_run_cost_gate(run_name): + if load_rl_cost_gate is None: + return jsonify({'error': 'STOM RL dashboard helper is not available'}), 500 + try: + return jsonify(load_rl_cost_gate(run_name, limit=_rl_table_limit())) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/rl/factory/queue') +def rl_factory_queue(): + if load_factory_queue is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify(load_factory_queue()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/rl/factory/lane-runs') +def rl_factory_lane_runs(): + if list_lane_runs is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify({'runs': list_lane_runs()}) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/rl/factory/sizing-runs') +def rl_factory_sizing_runs(): + if list_sizing_runs is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify({'runs': list_sizing_runs()}) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/rl/factory/risk-policy-runs') +def rl_factory_risk_policy_runs(): + if list_risk_policy_runs is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify({'runs': list_risk_policy_runs()}) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/rl/factory/fresh-validation-runs') +def rl_factory_fresh_validation_runs(): + if list_fresh_validation_runs is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify({'runs': list_fresh_validation_runs()}) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/rl/factory/model-build-readiness') +def rl_factory_model_build_readiness(): + if load_model_build_readiness is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify(load_model_build_readiness()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/rl/factory/forward-ledgers') +def rl_factory_forward_ledgers(): + if list_forward_ledger_runs is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify({'runs': list_forward_ledger_runs()}) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/rl/factory/forward-ledger/') +def rl_factory_forward_ledger(run_name): + if load_forward_ledger is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify( + load_forward_ledger( + run_name, + limit=_rl_table_limit(), + status=request.args.get('status') or None, + ) + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/rl/factory/lane//calibration') +def rl_factory_lane_calibration(run_name): + if load_lane_calibration is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify(load_lane_calibration(run_name)) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/rl/factory/lane//edge-ledger') +def rl_factory_lane_edge_ledger(run_name): + if load_lane_edge_ledger is None: + return jsonify({'error': 'STOM RL factory dashboard helper is not available'}), 500 + try: + return jsonify( + load_lane_edge_ledger( + run_name, + limit=_rl_table_limit(), + decision=request.args.get('decision') or None, + ) + ) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +def _daily_limit(name='limit', default=100, maximum=5000): + try: + limit = int(request.args.get(name, default)) + except (TypeError, ValueError): + limit = default + return max(0, min(limit, maximum)) + + +@app.route('/api/daily-ohlcv/db-summary') +def daily_ohlcv_db_summary(): + if load_daily_db_summary is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify( + load_daily_db_summary( + run=request.args.get('run') or None, + table_limit=_daily_limit('table_limit', 100), + flag_limit=_daily_limit('flag_limit', 100), + window_limit=_daily_limit('window_limit', 50), + ) + ) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/symbol/') +@app.route('/api/daily-ohlcv/symbol/') +def daily_ohlcv_symbol(code): + if load_daily_symbol is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_daily_symbol(code, sample_limit=_daily_limit('limit', 50, 200))) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/universe/preview') +def daily_ohlcv_universe_preview(): + if load_universe_preview is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify( + load_universe_preview( + run=request.args.get('run') or None, + limit=_daily_limit('limit', 200), + include=request.args.get('include') or None, + ) + ) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/universe/manifests') +def daily_ohlcv_universe_manifests(): + if list_universe_manifests is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(list_universe_manifests(limit=_daily_limit('limit', 20, 200))) + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/progress') +def daily_ohlcv_progress(): + if load_daily_progress is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_daily_progress()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/artifacts') +def daily_ohlcv_artifacts(): + if load_daily_artifacts is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_daily_artifacts(limit=_daily_limit('limit', 50, 500))) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/dataset/latest') +def daily_ohlcv_dataset_latest(): + if load_dataset_latest is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_dataset_latest(run=request.args.get('run') or None, sample_limit=_daily_limit('limit', 25, 500))) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/daily-ohlcv/prediction/latest') +def daily_ohlcv_prediction_latest(): + if load_prediction_latest is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_prediction_latest(run=request.args.get('run') or None, sample_limit=_daily_limit('limit', 25, 500))) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/portfolio/latest') +def daily_ohlcv_portfolio_latest(): + if load_portfolio_latest is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_portfolio_latest(run=request.args.get('run') or None, sample_limit=_daily_limit('limit', 25, 500))) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/walk-forward/latest') +def daily_ohlcv_walk_forward_latest(): + if load_walk_forward_latest is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_walk_forward_latest(run=request.args.get('run') or None, sample_limit=_daily_limit('limit', 25, 500))) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/registry/latest') +def daily_ohlcv_registry_latest(): + if load_registry_latest is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_registry_latest(run=request.args.get('run') or None, sample_limit=_daily_limit('limit', 25, 500))) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/dataset/artifacts') +def daily_ohlcv_dataset_artifacts(): + if list_dataset_artifacts is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(list_dataset_artifacts(limit=_daily_limit('limit', 20, 200))) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/dataset') +def daily_ohlcv_chart_dataset(): + if load_dataset_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_dataset_chart(run=request.args.get('run') or None)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/coverage') +def daily_ohlcv_chart_coverage(): + if load_coverage_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_coverage_chart()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/universe') +def daily_ohlcv_chart_universe(): + if load_universe_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_universe_chart()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/prediction') +def daily_ohlcv_chart_prediction(): + if load_prediction_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_prediction_chart(run=request.args.get('run') or None)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/portfolio') +def daily_ohlcv_chart_portfolio(): + if load_portfolio_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_portfolio_chart(run=request.args.get('run') or None)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/walk-forward') +@app.route('/api/daily-ohlcv/gate/latest') +def daily_ohlcv_gate_latest(): + if load_walk_forward_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_walk_forward_chart(run=request.args.get('run') or None)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/daily-ohlcv/charts/decision-cockpit') +def daily_ohlcv_chart_decision_cockpit(): + if load_decision_cockpit is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_decision_cockpit()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/scenarios') +def daily_ohlcv_scenario_lab(): + if load_scenario_lab is None: + return jsonify({'error': 'Daily OHLCV scenario helper is not available'}), 500 + try: + return jsonify(load_scenario_lab()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/scenario-runs') +def daily_ohlcv_scenario_runs(): + if load_scenario_run_ledger is None: + return jsonify({'error': 'Daily OHLCV scenario run ledger helper is not available'}), 500 + try: + limit = request.args.get('limit', default=25, type=int) + return jsonify(load_scenario_run_ledger(limit=limit)) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/daily-ohlcv/rl-env-guide') +def daily_ohlcv_rl_env_guide(): + if load_daily_rl_env_guide is None: + return jsonify({'error': 'Daily OHLCV RL environment guide helper is not available'}), 500 + try: + return jsonify(load_daily_rl_env_guide()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/daily-ohlcv/research-workflows') +def daily_ohlcv_research_workflows(): + if load_research_workflow_catalog is None: + return jsonify({'error': 'Daily OHLCV research workflow helper is not available'}), 500 + try: + return jsonify(load_research_workflow_catalog()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/research-workflows/') +def daily_ohlcv_research_workflow_detail(workflow_id): + if load_research_workflow_detail is None: + return jsonify({'error': 'Daily OHLCV research workflow helper is not available'}), 500 + try: + payload = load_research_workflow_detail(workflow_id) + status = 404 if payload.get('status') == 'UNKNOWN_WORKFLOW_ID' else 200 + return jsonify(payload), status + except Exception as exc: + return jsonify({'error': str(exc)}), 500 +@app.route('/api/daily-ohlcv/research-workflows//job-intents', methods=['POST']) +def daily_ohlcv_research_workflow_job_intents(workflow_id): + if create_research_job_intent is None: + return jsonify({'error': 'Daily OHLCV research job intent helper is not available'}), 500 + try: + payload = create_research_job_intent(workflow_id, request.get_json(silent=True) or {}) + status = int(payload.get('http_status', 200)) + return jsonify(payload), status + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/research-jobs') +def daily_ohlcv_research_jobs(): + if load_research_job_intent_ledger is None: + return jsonify({'error': 'Daily OHLCV research job intent ledger helper is not available'}), 500 + try: + limit = request.args.get('limit', default=25, type=int) + return jsonify(load_research_job_intent_ledger(limit=limit)) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/research-jobs/') +def daily_ohlcv_research_job_detail(intent_id): + if load_research_job_intent is None: + return jsonify({'error': 'Daily OHLCV research job intent helper is not available'}), 500 + try: + payload = load_research_job_intent(intent_id) + status = int(payload.get('http_status', 200)) + return jsonify(payload), status + except Exception as exc: + return jsonify({'error': str(exc)}), 500 +@app.route('/api/daily-ohlcv/rejection-analytics') +def daily_ohlcv_rejection_analytics(): + if load_rejection_analytics is None: + return jsonify({'error': 'Daily OHLCV rejection analytics helper is not available'}), 500 + try: + run = request.args.get('run') + limit = request.args.get('limit', default=25, type=int) + return jsonify(load_rejection_analytics(run=run, limit=limit)) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + + + +@app.route('/api/daily-ohlcv/charts/flow') +def daily_ohlcv_chart_flow(): + if load_daily_flow_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_daily_flow_chart()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/glossary') +def daily_ohlcv_chart_glossary(): + if load_daily_metric_glossary is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_daily_metric_glossary()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/research-diagnostics') +def daily_ohlcv_chart_research_diagnostics(): + if load_research_diagnostics_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_research_diagnostics_chart()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/equity-overlay') +def daily_ohlcv_chart_equity_overlay(): + if load_equity_overlay_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_equity_overlay_chart(run=request.args.get('run') or None)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/walk-forward-heatmap') +def daily_ohlcv_chart_walk_forward_heatmap(): + if load_walk_forward_heatmap_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_walk_forward_heatmap_chart(run=request.args.get('run') or None)) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/run-scatter') +def daily_ohlcv_chart_run_scatter(): + if load_run_scatter_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_run_scatter_chart()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/universe-breakdown') +def daily_ohlcv_chart_universe_breakdown(): + if load_universe_breakdown_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_universe_breakdown_chart()) + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + + +@app.route('/api/daily-ohlcv/charts/symbol/') +@app.route('/api/daily-ohlcv/charts/symbol/') +def daily_ohlcv_chart_symbol(code): + if load_symbol_chart is None: + return jsonify({'error': 'Daily OHLCV dashboard helper is not available'}), 500 + try: + return jsonify(load_symbol_chart(code, sample_limit=_daily_limit('limit', 160, 200))) + except FileNotFoundError as exc: + return jsonify({'error': str(exc)}), 404 + except ValueError as exc: + return jsonify({'error': str(exc)}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + +@app.route('/api/stom/summary') +def stom_summary(): + if load_training_summary is None: + return jsonify({'error': 'STOM dashboard helper is not available'}), 500 + return jsonify(load_training_summary()) + +@app.route('/api/stom/prediction-files') +def stom_prediction_files(): + if list_prediction_files is None: + return jsonify({'error': 'STOM dashboard helper is not available'}), 500 + return jsonify({'files': list_prediction_files()}) + +@app.route('/api/stom/qlib-backtests') +def stom_qlib_backtests(): + if list_qlib_backtest_files is None: + return jsonify({'error': 'STOM dashboard helper is not available'}), 500 + file_name = request.args.get('file') + try: + if not file_name: + return jsonify({'files': list_qlib_backtest_files()}) + if load_qlib_backtest_artifact is None or qlib_backtest_chart_json is None: + return jsonify({'error': 'Qlib backtest helper is not available'}), 500 + payload = load_qlib_backtest_artifact(file_name) + return jsonify({ + 'artifact': payload, + 'chart': qlib_backtest_chart_json(payload), + }) + except Exception as exc: + return jsonify({'error': str(exc)}), 400 + +@app.route('/api/stom/filter-reports') +def stom_filter_reports(): + if list_filter_report_files is None: + return jsonify({'error': 'STOM filter report helper is not available'}), 500 + file_name = request.args.get('file') + try: + if not file_name: + return jsonify({'files': list_filter_report_files()}) + if load_filter_report_artifact is None: + return jsonify({'error': 'STOM filter report loader is not available'}), 500 + return jsonify({'artifact': load_filter_report_artifact(file_name)}) + except Exception as exc: + return jsonify({'error': str(exc)}), 400 + + +@app.route('/api/stom/horizon-comparison') +def stom_horizon_comparison(): + if load_horizon_comparison is None: + return jsonify({'error': 'STOM horizon comparison helper is not available'}), 500 + try: + return jsonify(load_horizon_comparison()) + except Exception as exc: + return jsonify({'error': str(exc)}), 400 + +@app.route('/api/stom/prediction') +def stom_prediction_file(): + if load_prediction_frame is None: + return jsonify({'error': 'STOM dashboard helper is not available'}), 500 + file_name = request.args.get('file') + if not file_name: + return jsonify({'error': 'file query parameter is required'}), 400 + try: + df = load_prediction_frame(file_name) + window_id_arg = request.args.get('window_id') + window_id = int(window_id_arg) if window_id_arg not in (None, '') else None + recommendations = ranked_recommendations(df) if ranked_recommendations else [] + return jsonify({ + 'metrics': prediction_metrics(df), + 'chart': prediction_chart_json(df, window_id=window_id), + 'visual': prediction_visual_payload(df, window_id=window_id) if prediction_visual_payload else {}, + 'topk': topk_rows(df), + 'recommendations': recommendations, + 'recommendation_summary': recommendation_summary(recommendations) if recommendation_summary else {}, + 'windows': sorted(int(v) for v in df['window_id'].dropna().unique().tolist())[:500], + }) + except Exception as exc: + return jsonify({'error': str(exc)}), 400 + + +@app.route('/api/stom/diagnostics') +def stom_prediction_diagnostics(): + if load_prediction_frame is None or prediction_diagnostics is None: + return jsonify({'error': 'STOM diagnostics helper is not available'}), 500 + file_name = request.args.get('file') + if not file_name: + return jsonify({'error': 'file query parameter is required'}), 400 + try: + max_symbols = int(request.args.get('max_symbols', 50)) + min_windows = int(request.args.get('min_windows', 1)) + df = load_prediction_frame(file_name) + return jsonify(prediction_diagnostics(df, max_symbols=max_symbols, min_windows=min_windows)) + except Exception as exc: + return jsonify({'error': str(exc)}), 400 + +@app.route('/api/stom/recommendations') +def stom_recommendations(): + if load_prediction_frame is None or ranked_recommendations is None: + return jsonify({'error': 'STOM dashboard helper is not available'}), 500 + file_name = request.args.get('file') + if not file_name: + return jsonify({'error': 'file query parameter is required'}), 400 + try: + k = int(request.args.get('k', 20)) + min_score_arg = request.args.get('min_score') + min_score = float(min_score_arg) if min_score_arg not in (None, '') else None + long_only = request.args.get('long_only', '1').lower() not in {'0', 'false', 'no', 'off'} + df = load_prediction_frame(file_name) + recommendations = ranked_recommendations(df, k=k, long_only=long_only, min_score=min_score) + return jsonify({ + 'recommendations': recommendations, + 'summary': recommendation_summary(recommendations) if recommendation_summary else {}, + 'metrics': prediction_metrics(df), + }) + except Exception as exc: + return jsonify({'error': str(exc)}), 400 + +@app.route('/api/stom/backtest-report') +def stom_backtest_report(): + if load_prediction_frame is None or score_backtest_report is None: + return jsonify({'error': 'STOM dashboard helper is not available'}), 500 + file_name = request.args.get('file') + if not file_name: + return jsonify({'error': 'file query parameter is required'}), 400 + try: + top_k_arg = request.args.get('top_k') + top_k = int(top_k_arg) if top_k_arg not in (None, '') else None + df = load_prediction_frame(file_name) + return jsonify(score_backtest_report(df, top_k=top_k)) + except Exception as exc: + return jsonify({'error': str(exc)}), 400 + +@app.route('/api/stom/recommendation-export') +def stom_recommendation_export(): + if load_prediction_frame is None or recommendation_export_payload is None: + return jsonify({'error': 'STOM dashboard helper is not available'}), 500 + file_name = request.args.get('file') + if not file_name: + return jsonify({'error': 'file query parameter is required'}), 400 + try: + output_format = request.args.get('format', 'json').lower() + limit_arg = request.args.get('limit', request.args.get('k', '20')) + limit = int(limit_arg) if limit_arg not in (None, '') else None + min_score_arg = request.args.get('min_score') + min_score = float(min_score_arg) if min_score_arg not in (None, '') else None + selected_filter = request.args.get('filter', 'buy_candidate_score60') + long_only = request.args.get('long_only', '1').lower() not in {'0', 'false', 'no', 'off'} + df = load_prediction_frame(file_name) + payload = recommendation_export_payload( + df, + source_file=file_name, + limit=limit, + min_score=min_score, + selected_filter=selected_filter, + long_only=long_only, + ) + if output_format == 'json': + return jsonify(payload) + if output_format == 'csv': + if recommendation_export_csv is None: + return jsonify({'error': 'CSV export helper is not available'}), 500 + safe_name = ''.join(ch if ch.isalnum() or ch in ('-', '_', '.') else '_' for ch in file_name) + csv_text = recommendation_export_csv(payload['records']) + return Response( + csv_text, + mimetype='text/csv; charset=utf-8', + headers={'Content-Disposition': f'attachment; filename="{safe_name}.kronos_recommendations.csv"'}, + ) + return jsonify({'error': 'format must be json or csv'}), 400 + except Exception as exc: + return jsonify({'error': str(exc)}), 400 @app.route('/api/data-files') def get_data_files(): @@ -346,7 +1727,7 @@ def load_data(): file_path = data.get('file_path') if not file_path: - return jsonify({'error': 'File path cannot be empty'}), 400 + return jsonify({'error': '파일 경로가 비어 있습니다'}), 400 df, error = load_data_file(file_path) if error: @@ -355,7 +1736,7 @@ def load_data(): # Detect data time frequency def detect_timeframe(df): if len(df) < 2: - return "Unknown" + return "알 수 없음" time_diffs = [] for i in range(1, min(10, len(df))): # Check first 10 time differences @@ -363,20 +1744,20 @@ def detect_timeframe(df): time_diffs.append(diff) if not time_diffs: - return "Unknown" + return "알 수 없음" # Calculate average time difference avg_diff = sum(time_diffs, pd.Timedelta(0)) / len(time_diffs) # Convert to readable format if avg_diff < pd.Timedelta(minutes=1): - return f"{avg_diff.total_seconds():.0f} seconds" + return f"{avg_diff.total_seconds():.0f}초" elif avg_diff < pd.Timedelta(hours=1): - return f"{avg_diff.total_seconds() / 60:.0f} minutes" + return f"{avg_diff.total_seconds() / 60:.0f}분" elif avg_diff < pd.Timedelta(days=1): - return f"{avg_diff.total_seconds() / 3600:.0f} hours" + return f"{avg_diff.total_seconds() / 3600:.0f}시간" else: - return f"{avg_diff.days} days" + return f"{avg_diff.days}일" # Return data information data_info = { @@ -395,11 +1776,11 @@ def detect_timeframe(df): return jsonify({ 'success': True, 'data_info': data_info, - 'message': f'Successfully loaded data, total {len(df)} rows' + 'message': f'데이터를 성공적으로 불러왔습니다. 총 {len(df)}행' }) except Exception as e: - return jsonify({'error': f'Failed to load data: {str(e)}'}), 500 + return jsonify({'error': f'데이터 로드 실패: {str(e)}'}), 500 @app.route('/api/predict', methods=['POST']) def predict(): @@ -416,7 +1797,7 @@ def predict(): sample_count = int(data.get('sample_count', 1)) if not file_path: - return jsonify({'error': 'File path cannot be empty'}), 400 + return jsonify({'error': '파일 경로가 비어 있습니다'}), 400 # Load data df, error = load_data_file(file_path) @@ -424,7 +1805,7 @@ def predict(): return jsonify({'error': error}), 400 if len(df) < lookback: - return jsonify({'error': f'Insufficient data length, need at least {lookback} rows'}), 400 + return jsonify({'error': f'데이터 길이가 부족합니다. 최소 {lookback}행이 필요합니다'}), 400 # Perform prediction if MODEL_AVAILABLE and predictor is not None: @@ -448,7 +1829,7 @@ def predict(): # Ensure sufficient data: lookback + pred_len if len(time_range_df) < lookback + pred_len: - return jsonify({'error': f'Insufficient data from start time {start_dt.strftime("%Y-%m-%d %H:%M")}, need at least {lookback + pred_len} data points, currently only {len(time_range_df)} available'}), 400 + return jsonify({'error': f'시작 시각 {start_dt.strftime("%Y-%m-%d %H:%M")} 이후 데이터가 부족합니다. 최소 {lookback + pred_len}개 데이터 포인트가 필요하지만 현재 {len(time_range_df)}개만 있습니다'}), 400 # Use first lookback data points within selected window for prediction x_df = time_range_df.iloc[:lookback][required_cols] @@ -462,13 +1843,13 @@ def predict(): end_timestamp = time_range_df['timestamps'].iloc[lookback+pred_len-1] time_span = end_timestamp - start_timestamp - prediction_type = f"Kronos model prediction (within selected window: first {lookback} data points for prediction, last {pred_len} data points for comparison, time span: {time_span})" + prediction_type = f"Kronos 모델 예측(선택 구간: 앞 {lookback}개 데이터로 예측, 뒤 {pred_len}개 데이터로 비교, 구간 길이: {time_span})" else: # Use latest data x_df = df.iloc[:lookback][required_cols] x_timestamp = df.iloc[:lookback]['timestamps'] y_timestamp = df.iloc[lookback:lookback+pred_len]['timestamps'] - prediction_type = "Kronos model prediction (latest data)" + prediction_type = "Kronos 모델 예측(최신 데이터)" # Ensure timestamps are Series format, not DatetimeIndex, to avoid .dt attribute error in Kronos model if isinstance(x_timestamp, pd.DatetimeIndex): @@ -487,9 +1868,9 @@ def predict(): ) except Exception as e: - return jsonify({'error': f'Kronos model prediction failed: {str(e)}'}), 500 + return jsonify({'error': f'Kronos 모델 예측 실패: {str(e)}'}), 500 else: - return jsonify({'error': 'Kronos model not loaded, please load model first'}), 400 + return jsonify({'error': 'Kronos 모델이 로드되지 않았습니다. 먼저 모델을 불러오세요'}), 400 # Prepare actual data for comparison (if exists) actual_data = [] @@ -608,7 +1989,7 @@ def predict(): } ) except Exception as e: - print(f"Failed to save prediction results: {e}") + print(f"예측 결과 저장 실패: {e}") return jsonify({ 'success': True, @@ -617,11 +1998,11 @@ def predict(): 'prediction_results': prediction_results, 'actual_data': actual_data, 'has_comparison': len(actual_data) > 0, - 'message': f'Prediction completed, generated {pred_len} prediction points' + (f', including {len(actual_data)} actual data points for comparison' if len(actual_data) > 0 else '') + 'message': f'예측이 완료되었습니다. 예측 포인트 {pred_len}개 생성' + (f', 비교용 실제 데이터 포인트 {len(actual_data)}개 포함' if len(actual_data) > 0 else '') }) except Exception as e: - return jsonify({'error': f'Prediction failed: {str(e)}'}), 500 + return jsonify({'error': f'예측 실패: {str(e)}'}), 500 @app.route('/api/load-model', methods=['POST']) def load_model(): @@ -629,15 +2010,15 @@ def load_model(): global tokenizer, model, predictor try: - if not MODEL_AVAILABLE: - return jsonify({'error': 'Kronos model library not available'}), 400 + if not ensure_kronos_imported(): + return jsonify({'error': f'Kronos 모델 라이브러리를 사용할 수 없습니다: {MODEL_IMPORT_ERROR}'}), 400 data = request.get_json() model_key = data.get('model_key', 'kronos-small') device = data.get('device', 'cpu') if model_key not in AVAILABLE_MODELS: - return jsonify({'error': f'Unsupported model: {model_key}'}), 400 + return jsonify({'error': f'지원하지 않는 모델입니다: {model_key}'}), 400 model_config = AVAILABLE_MODELS[model_key] @@ -650,7 +2031,7 @@ def load_model(): return jsonify({ 'success': True, - 'message': f'Model loaded successfully: {model_config["name"]} ({model_config["params"]}) on {device}', + 'message': f'모델을 성공적으로 불러왔습니다: {model_config["name"]} ({model_config["params"]}) / 장치 {device}', 'model_info': { 'name': model_config['name'], 'params': model_config['params'], @@ -660,14 +2041,15 @@ def load_model(): }) except Exception as e: - return jsonify({'error': f'Model loading failed: {str(e)}'}), 500 + return jsonify({'error': f'모델 로드 실패: {str(e)}'}), 500 @app.route('/api/available-models') def get_available_models(): """Get available model list""" return jsonify({ 'models': AVAILABLE_MODELS, - 'model_available': MODEL_AVAILABLE + 'model_available': MODEL_AVAILABLE, + 'model_import_error': MODEL_IMPORT_ERROR }) @app.route('/api/model-status') @@ -678,7 +2060,7 @@ def get_model_status(): return jsonify({ 'available': True, 'loaded': True, - 'message': 'Kronos model loaded and available', + 'message': 'Kronos 모델이 로드되어 사용할 수 있습니다', 'current_model': { 'name': predictor.model.__class__.__name__, 'device': str(next(predictor.model.parameters()).device) @@ -688,21 +2070,104 @@ def get_model_status(): return jsonify({ 'available': True, 'loaded': False, - 'message': 'Kronos model available but not loaded' + 'message': 'Kronos 모델은 사용 가능하지만 아직 로드되지 않았습니다' }) else: return jsonify({ 'available': False, 'loaded': False, - 'message': 'Kronos model library not available, please install related dependencies' + 'message': f'Kronos 모델 라이브러리를 사용할 수 없습니다: {MODEL_IMPORT_ERROR}' }) +# ────────────────────────────────────────────────────────────────── +# /api/docs/* — wiki 마크다운 read-only 서빙 +# ────────────────────────────────────────────────────────────────── + +_DOCS_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'docs', 'wiki')) + + +def _safe_wiki_path(rel: str) -> str | None: + """path traversal 방지 — docs/wiki/ 외부 접근 거부.""" + if not rel or not isinstance(rel, str): + return None + if '..' in rel or rel.startswith('/') or rel.startswith('\\') or ':' in rel: + return None + abs_path = os.path.abspath(os.path.join(_DOCS_ROOT, rel)) + if not abs_path.startswith(_DOCS_ROOT + os.sep) and abs_path != _DOCS_ROOT: + return None + return abs_path + + +@app.route('/api/docs/list') +def docs_list(): + """docs/wiki/ 의 마크다운 파일 목록을 분류된 형태로 반환.""" + if not os.path.isdir(_DOCS_ROOT): + return jsonify({'available': False, 'docs': [], 'message': 'docs/wiki 디렉터리 없음'}) + out = [] + try: + for name in sorted(os.listdir(_DOCS_ROOT)): + if not name.endswith('.md'): + continue + full = os.path.join(_DOCS_ROOT, name) + st = os.stat(full) + # H1 추출 (첫 줄에서) + title = name.replace('.md', '') + try: + with open(full, 'r', encoding='utf-8') as f: + for line in f: + m = _re.match(r'^#\s+(.+)$', line.strip()) + if m: + title = m.group(1).strip() + break + except Exception: + pass + # 카테고리 추출 (파일명 prefix 00-/01-/02- 등으로 정렬) + order_match = _re.match(r'^(\d+)[-_]', name) + order = int(order_match.group(1)) if order_match else 99 + out.append({ + 'slug': name.replace('.md', ''), + 'name': name, + 'title': title, + 'size_bytes': st.st_size, + 'modified_at': st.st_mtime, + 'order': order, + }) + out.sort(key=lambda d: (d['order'], d['name'])) + except Exception as exc: + return jsonify({'available': False, 'docs': [], 'error': str(exc)}) + return jsonify({'available': True, 'docs': out, 'root': _DOCS_ROOT}) + + +@app.route('/api/docs/read') +def docs_read(): + """특정 마크다운 파일 내용을 반환 (path traversal 방지).""" + slug = request.args.get('slug', '').strip() + if not slug: + return jsonify({'error': 'slug parameter required'}), 400 + name = slug if slug.endswith('.md') else slug + '.md' + safe = _safe_wiki_path(name) + if not safe or not os.path.isfile(safe): + return jsonify({'error': f'file not found: {slug}'}), 404 + try: + with open(safe, 'r', encoding='utf-8') as f: + content = f.read() + except Exception as exc: + return jsonify({'error': str(exc)}), 500 + st = os.stat(safe) + return jsonify({ + 'slug': slug.replace('.md', ''), + 'name': name, + 'content': content, + 'size_bytes': st.st_size, + 'modified_at': st.st_mtime, + }) + + if __name__ == '__main__': - print("Starting Kronos Web UI...") - print(f"Model availability: {MODEL_AVAILABLE}") - if MODEL_AVAILABLE: - print("Tip: You can load Kronos model through /api/load-model endpoint") - else: - print("Tip: Will use simulated data for demonstration") - - app.run(debug=True, host='0.0.0.0', port=7070) + print("Kronos 웹 UI 시작 중...") + print("안내: Kronos 모델은 /api/load-model 호출 시 지연 import 됩니다") + + host = os.environ.get("KRONOS_WEBUI_HOST", "127.0.0.1") + port = int(os.environ.get("KRONOS_WEBUI_PORT", os.environ.get("PORT", "7070"))) + debug_mode = os.environ.get("KRONOS_WEBUI_DEBUG", "0").lower() in {"1", "true", "yes", "on"} + app.run(debug=debug_mode, host=host, port=port) diff --git a/webui/daily_ohlcv_dashboard.py b/webui/daily_ohlcv_dashboard.py new file mode 100644 index 000000000..8db7b2c66 --- /dev/null +++ b/webui/daily_ohlcv_dashboard.py @@ -0,0 +1,4708 @@ +"""Read-only dashboard adapter for daily OHLCV D0-D9 evidence surfaces.""" + +from __future__ import annotations + +import csv +import json +import hashlib +import math +import re +from pathlib import Path +from datetime import datetime, timezone +from typing import Any + +from stom_rl.daily_ohlcv_db import ( + DEFAULT_ARTIFACT_ROOT as DB_SUMMARY_ROOT, + DECISION_GRADE_RETURN_STATUS, + PRICE_BASIS_ALLOWED_USES, + PRICE_BASIS_BLOCKED_USES, + PRICE_BASIS_REQUIRED_EVIDENCE, + PRICE_BASIS_USER_GUIDANCE, + PRICE_BASIS, + PRICE_BASIS_STATUS, + summarize_daily_db, + summarize_symbol, +) +from stom_rl.daily_ohlcv_universe import ( + DEFAULT_OFFICIAL_METADATA_PATH, + DEFAULT_UNIVERSE_ROOT, + OFFICIAL_METADATA_REQUIRED_COLUMNS, + UNIVERSE_ALLOWED_USES_WHEN_WATCH, + UNIVERSE_BLOCKED_USES_WHEN_WATCH, + UNIVERSE_REQUIRED_EVIDENCE, + UNIVERSE_USER_GUIDANCE, + build_universe_manifest, +) +from stom_rl.daily_ohlcv_dataset import ( + DATASET_ALLOWED_USES_WITH_UPSTREAM_BLOCKERS, + DATASET_BLOCKED_USES_WITH_UPSTREAM_BLOCKERS, + DATASET_REQUIRED_EVIDENCE, + DATASET_USER_GUIDANCE, + DEFAULT_DATASET_ROOT, +) +from stom_rl.daily_prediction import DEFAULT_PREDICTION_ROOT, D3_ALLOWED_USES_WHEN_WATCH, D3_BLOCKED_USES_WHEN_WATCH, D3_REQUIRED_EVIDENCE, D3_USER_GUIDANCE +from stom_rl.daily_rl_train import DEFAULT_PORTFOLIO_ROOT +from stom_rl.daily_walk_forward import DEFAULT_WALK_FORWARD_ROOT +from stom_rl.daily_registry import DEFAULT_DAILY_REGISTRY_ROOT +from stom_rl.daily_portfolio_env import environment_contract +from stom_rl.daily_scenario_runner import DEFAULT_SCENARIO_ROOT, RESEARCH_GUARDRAIL +from stom_rl.daily_scenario_batch import DEFAULT_SCENARIO_BATCH_ROOT + +SAFE_RUN_RE = re.compile(r"^[0-9A-Za-z_.-]+$") +DEFAULT_SIGNAL_QUALITY_ROOT = Path("webui/rl_runs/daily_ohlcv_signal_quality") +DEFAULT_SIGNAL_QUALITY_BATCH_ROOT = Path("webui/rl_runs/daily_ohlcv_signal_quality_batches") +LATEST_SIGNAL_QUALITY_RESULT_DOC = Path("docs/stom_daily_ohlcv_d3_d4_signal_quality_audit_result_2026-06-18.md") +LATEST_RESEARCH_GOVERNANCE_INDEX = Path("docs/stom_daily_ohlcv_research_governance_index_2026-06-18.md") +DEFAULT_RESEARCH_INTENT_ROOT = Path("webui/rl_runs/daily_ohlcv_research_intents") +DEFAULT_REJECTION_AUDIT_ROOT = Path("webui/rl_runs/daily_ohlcv_rejection_audit") +MAX_LIMIT = 5000 + + +def _bounded_limit(value: Any, *, default: int = 100, maximum: int = MAX_LIMIT) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + parsed = default + return max(0, min(parsed, maximum)) + + +def _safe_run_id(run_id: str | None) -> str | None: + if not run_id: + return None + if not SAFE_RUN_RE.match(run_id) or run_id in {".", ".."} or ".." in run_id.split("."): + raise ValueError("Unsafe daily OHLCV artifact run id") + return run_id + + +def _is_scenario_generated_run_dir(path: Path) -> bool: + run_id = path.name + if run_id.startswith("scenario_") or "__" in run_id: + return True + try: + scenario_manifest = (DEFAULT_SCENARIO_ROOT / run_id / "scenario_manifest.json").resolve() + scenario_manifest.relative_to(DEFAULT_SCENARIO_ROOT.resolve()) + except (ValueError, OSError): + return False + return scenario_manifest.is_file() + + +def _latest_run_dir(root: Path, *, required_file: str, run_id: str | None = None) -> Path | None: + root = root.resolve() + if run_id: + candidate = (root / _safe_run_id(run_id)).resolve() + candidate.relative_to(root) + if (candidate / required_file).exists(): + return candidate + raise FileNotFoundError(run_id) + if not root.exists(): + return None + candidates = [ + path + for path in root.iterdir() + if path.is_dir() and (path / required_file).exists() and not _is_scenario_generated_run_dir(path) + ] + if not candidates: + return None + return max(candidates, key=lambda p: (p / required_file).stat().st_mtime) + + +def _load_json(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _latest_artifact_dir(root: Path, *, required_file: str) -> Path | None: + root = root.resolve() + if not root.exists(): + return None + candidates = [path for path in root.iterdir() if path.is_dir() and (path / required_file).exists()] + if not candidates: + return None + return max(candidates, key=lambda p: (p / required_file).stat().st_mtime) + + +def _load_json_if_exists(path: Path | None) -> dict[str, Any]: + if path is None or not path.exists(): + return {} + try: + return _load_json(path) + except (OSError, json.JSONDecodeError): + return {} + + +def _path_text(path: Path | None) -> str | None: + if path is None: + return None + return str(path).replace("\\", "/") + + +def _read_csv_rows(path: Path, limit: int) -> list[dict[str, Any]]: + safe_limit = _bounded_limit(limit, default=100, maximum=MAX_LIMIT) + if safe_limit == 0 or not path.exists(): + return [] + rows: list[dict[str, Any]] = [] + with path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + for row in reader: + rows.append(dict(row)) + if len(rows) >= safe_limit: + break + return rows + + +def _limit_list(payload: dict[str, Any], key: str, limit: int) -> None: + rows = payload.get(key) + if isinstance(rows, list): + payload[f"{key}_total"] = len(rows) + payload[f"{key}_truncated"] = len(rows) > limit + payload[key] = rows[:limit] + + +def _to_float(value: Any, default: float | None = None) -> float | None: + try: + if value in (None, ""): + return default + result = float(value) + if math.isnan(result) or math.isinf(result): + return default + return result + except (TypeError, ValueError): + return default + + +def _to_int(value: Any, default: int | None = None) -> int | None: + try: + if value in (None, ""): + return default + return int(float(value)) + except (TypeError, ValueError): + return default + + +def _status_severity(status: str | None) -> str: + normalized = str(status or "").upper() + if "NO-GO" in normalized or "BLOCKED" in normalized or "LOCKED" in normalized: + return "block" + if "WATCH" in normalized or "RESEARCH_ONLY" in normalized or "RUNNING" in normalized: + return "watch" + if normalized == "PASS" or normalized == "GO": + return "pass" + return "neutral" + +PRICE_BASIS_VERIFIED_STATUSES = { + "VERIFIED", + "RAW_VERIFIED", + "ADJUSTED_VERIFIED", + "PRICE_BASIS_VERIFIED", +} +PRICE_BASIS_VERIFIED_VALUES = {"raw", "adjusted", "split_adjusted", "total_return_adjusted"} +UNIVERSE_VERIFIED_VERDICT = "OFFICIAL_OR_MANUAL_REVIEWED" +OFFICIAL_METADATA_VERIFIED_STATUS = "OFFICIAL_VERIFIED" +OFFICIAL_METADATA_COMPLETE_COVERAGE = "COMPLETE" + +def _price_basis_verified(surface: dict[str, Any]) -> bool: + price_basis = str(surface.get("price_basis") or "").lower() + status = str(surface.get("price_basis_status") or surface.get("price_basis_review_status") or "").upper() + decision_status = str(surface.get("decision_grade_return_status") or "") + if price_basis not in PRICE_BASIS_VERIFIED_VALUES: + return False + if status not in PRICE_BASIS_VERIFIED_STATUSES: + return False + if decision_status.startswith("BLOCKED"): + return False + return True + + +def _universe_official_or_manual_verified(surface: dict[str, Any]) -> bool: + verdict = str(surface.get("verdict") or "") + review_status = surface.get("universe_review_status") + official_status = str(surface.get("official_metadata_status") or "") + coverage_status = str(surface.get("official_metadata_coverage_status") or "") + certification_status = str(surface.get("universe_certification_status") or "") + if verdict != UNIVERSE_VERIFIED_VERDICT: + return False + if review_status is not None and str(review_status) != UNIVERSE_VERIFIED_VERDICT: + return False + if official_status != OFFICIAL_METADATA_VERIFIED_STATUS: + return False + if coverage_status != OFFICIAL_METADATA_COMPLETE_COVERAGE: + return False + return certification_status == UNIVERSE_VERIFIED_VERDICT +D0_DATASET_UPSTREAM_BLOCKER = "D0_PRICE_BASIS_NOT_VERIFIED" +D1_DATASET_UPSTREAM_BLOCKER = "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED" +D2_BLOCKED_DECISION_GRADE_STATUS = "BLOCKED_BY_UPSTREAM_D0_D1_GUARDRAILS" +D2_BLOCKED_MODEL_READINESS = "DATASET_RESEARCH_PREVIEW_BLOCKED_BY_UPSTREAM_GUARDRAILS" + + +def _as_string_list(value: Any) -> list[str]: + if isinstance(value, list): + return [str(item) for item in value if item not in (None, "")] + if value in (None, ""): + return [] + return [str(value)] + + +def _merge_string_lists(required: list[str], *existing_groups: Any) -> list[str]: + merged: list[str] = [] + for item in required: + if item not in merged: + merged.append(item) + for group in existing_groups: + for item in _as_string_list(group): + if item not in merged: + merged.append(item) + return merged + +def _finite_float(value: Any) -> float | None: + try: + parsed = float(value) + except (TypeError, ValueError): + return None + return parsed if math.isfinite(parsed) else None + + +def _finite_int(value: Any) -> int | None: + parsed = _finite_float(value) + if parsed is None: + return None + return int(parsed) + + +def _positive_number(value: Any) -> bool: + parsed = _finite_float(value) + return parsed is not None and parsed > 0 + + +def _current_d0_d1_surfaces() -> tuple[dict[str, Any], dict[str, Any]]: + try: + db = load_daily_db_summary(table_limit=0, flag_limit=0, window_limit=0) + except Exception: + db = {} + try: + universe = load_universe_preview(limit=0) + except Exception: + universe = {} + return db, universe + + +def _apply_current_d0_d1_guardrails(manifest: dict[str, Any]) -> None: + db, universe = _current_d0_d1_surfaces() + manifest["price_basis"] = db.get("price_basis", PRICE_BASIS) + manifest["price_basis_status"] = db.get("price_basis_status", PRICE_BASIS_STATUS) + manifest["decision_grade_return_status"] = db.get("decision_grade_return_status", DECISION_GRADE_RETURN_STATUS) + manifest["universe_verdict"] = universe.get("verdict", "WATCH_HEURISTIC_UNIVERSE") + manifest["universe_review_status"] = ( + universe.get("universe_review_status") + or universe.get("review_status") + or universe.get("verdict") + or "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW" + ) + manifest["official_metadata_status"] = universe.get("official_metadata_status", "MISSING") + manifest["official_metadata_coverage_status"] = universe.get("official_metadata_coverage_status", "MISSING") + manifest["universe_certification_status"] = universe.get( + "universe_certification_status", + "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW", + ) + + +def _merge_guidance_rows(required: list[dict[str, Any]], existing: Any) -> list[dict[str, Any]]: + merged: list[dict[str, Any]] = [] + seen_sections: set[str] = set() + for row in [*required, *(existing if isinstance(existing, list) else [])]: + if not isinstance(row, dict): + continue + section = str(row.get("section") or row.get("id") or row) + if section in seen_sections: + continue + seen_sections.add(section) + merged.append(dict(row)) + return merged + + +def _dataset_upstream_blockers(manifest: dict[str, Any]) -> list[str]: + blockers: list[str] = [] + if not _price_basis_verified(manifest): + blockers.append(D0_DATASET_UPSTREAM_BLOCKER) + if not _universe_official_or_manual_verified( + { + "verdict": manifest.get("universe_verdict"), + "universe_review_status": manifest.get("universe_review_status"), + "official_metadata_status": manifest.get("official_metadata_status"), + "official_metadata_coverage_status": manifest.get("official_metadata_coverage_status"), + "universe_certification_status": manifest.get("universe_certification_status"), + } + ): + blockers.append(D1_DATASET_UPSTREAM_BLOCKER) + return blockers + + +def _normalize_dataset_manifest(manifest: dict[str, Any]) -> dict[str, Any]: + _apply_current_d0_d1_guardrails(manifest) + upstream_blockers = _dataset_upstream_blockers(manifest) + manifest["upstream_gate_blockers"] = _merge_string_lists(upstream_blockers, manifest.get("upstream_gate_blockers")) + manifest.setdefault("dataset_required_evidence", list(DATASET_REQUIRED_EVIDENCE)) + manifest.setdefault("dataset_allowed_uses", list(DATASET_ALLOWED_USES_WITH_UPSTREAM_BLOCKERS)) + manifest.setdefault("dataset_blocked_uses", list(DATASET_BLOCKED_USES_WITH_UPSTREAM_BLOCKERS)) + manifest.setdefault("dataset_user_guidance", [dict(row) for row in DATASET_USER_GUIDANCE]) + if manifest["upstream_gate_blockers"]: + manifest["dataset_required_evidence"] = _merge_string_lists(list(DATASET_REQUIRED_EVIDENCE), manifest.get("dataset_required_evidence")) + manifest["dataset_allowed_uses"] = _merge_string_lists( + list(DATASET_ALLOWED_USES_WITH_UPSTREAM_BLOCKERS), + manifest.get("dataset_allowed_uses"), + ) + manifest["dataset_blocked_uses"] = _merge_string_lists( + list(DATASET_BLOCKED_USES_WITH_UPSTREAM_BLOCKERS), + manifest.get("dataset_blocked_uses"), + ) + manifest["dataset_user_guidance"] = _merge_guidance_rows( + [dict(row) for row in DATASET_USER_GUIDANCE], + manifest.get("dataset_user_guidance"), + ) + manifest["decision_grade_status"] = D2_BLOCKED_DECISION_GRADE_STATUS + manifest["model_readiness"] = D2_BLOCKED_MODEL_READINESS + return manifest +D3_BLOCKED_STATUS = "WATCH" +D3_BLOCKED_READINESS_STATUS = "D3_WATCH_RESEARCH_ONLY" +D4_RESEARCH_STATUS = "RESEARCH_ONLY" +D4_RESEARCH_READINESS_STATUS = "D4_RESEARCH_ONLY_DIAGNOSTICS" +D4_FALSE_FLAGS = ( + "model_build_allowed", + "go_summary_allowed", + "paper_forward_allowed", + "live_broker_order_allowed", +) +D4_RESEARCH_REASONS = [ + "RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM", + "D5_WALK_FORWARD_NOT_RUN", + "PRICE_BASIS_UNKNOWN", + "UNIVERSE_WATCH_HEURISTIC", + "D4_RL_TELEMETRY_DIAGNOSTICS_ONLY", +] +D4_CANONICAL_ARTIFACTS = [ + "observation_manifest.json", + "state_observations.csv", + "training_manifest.json", + "episode_metrics.csv", + "learning_curve.csv", + "reward_breakdown.csv", + "reward_component_summary.json", + "action_distribution.csv", + "invalid_actions.csv", + "turnover.csv", + "drawdown.csv", + "policy_baseline_comparison.csv", + "policy_nav.csv", +] +D5_NO_GO_STATUS = "NO-GO" +D5_RESEARCH_READINESS_STATUS = "D5_NO_GO_RESEARCH_ONLY_GATE" +D5_RESEARCH_REASONS = [ + "RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM", + "D5_EFFECTIVE_MODEL_BUILD_LOCK", + "NO_LIVE_BROKER_ORDER_SURFACE", +] +D5_REQUIRED_COST_BPS = (0, 23, 46) +D5_MIN_REQUIRED_PURGE_DAYS = 5 +D5_MIN_REQUIRED_EMBARGO_DAYS = 5 + + +def _cost_bp_values(rows: Any, *, selected_strategy: Any = None) -> list[int]: + if not isinstance(rows, list): + return [] + values: set[int] = set() + for row in rows: + if not isinstance(row, dict): + continue + if selected_strategy not in (None, "") and row.get("strategy") != selected_strategy: + continue + parsed = _finite_int(row.get("cost_bp")) + if parsed is not None: + values.add(parsed) + return sorted(values) + +def _normalize_d5_contract_verdict(verdict: dict[str, Any], d4_state_contract: dict[str, Any]) -> dict[str, Any]: + contract = d4_state_contract if isinstance(d4_state_contract, dict) else {} + row_counts = contract.get("row_counts") if isinstance(contract.get("row_counts"), dict) else {} + contract_status = contract.get("status") + contract_gate = contract.get("gate") + validation_status = contract.get("observation_manifest_validation_status") + state_rows = row_counts.get("state_observations") + ablation_rows = row_counts.get("reward_action_ablations") + source_hash_count = contract.get("source_hash_count") or row_counts.get("source_hashes") + reasons = _as_string_list(verdict.get("reasons")) + consumed = ( + verdict.get("d4_state_contract_artifacts_consumed") is True + and contract_status == "PASS" + and contract_gate == "D4_OBSERVATION_STATE_MANIFEST" + and validation_status == "PASS" + and _positive_number(state_rows) + and _positive_number(ablation_rows) + and _positive_number(source_hash_count) + ) + issues = _as_string_list(verdict.get("d4_artifact_issues")) + if not consumed: + reasons = [reason for reason in reasons if reason != "D4_OBSERVATION_STATE_MANIFEST_CONSUMED"] + issues = _merge_string_lists(["D4_STATE_CONTRACT_EVIDENCE_MISSING_OR_STALE"], issues) + reasons = _merge_string_lists(reasons, issues) + return { + **verdict, + "d4_state_contract_status": contract_status, + "d4_observation_manifest_gate": contract_gate, + "d4_observation_manifest_validation_status": validation_status, + "d4_state_observation_rows": state_rows, + "d4_reward_action_ablation_rows": ablation_rows, + "d4_source_hash_count": source_hash_count, + "d4_state_contract_artifacts_consumed": consumed, + "d4_artifact_issues": issues, + "reasons": reasons, + } + + + + +def _normalize_portfolio_payload(manifest: dict[str, Any], verdict: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + verdict_payload = manifest.get("verdict") if isinstance(manifest.get("verdict"), dict) else {} + verdict = {**verdict_payload, **(verdict if isinstance(verdict, dict) else {})} + for key in D4_FALSE_FLAGS: + manifest[key] = False + verdict[key] = False + manifest["status"] = D4_RESEARCH_STATUS + manifest["readiness_status"] = D4_RESEARCH_READINESS_STATUS + manifest["no_live_broker_order_readiness"] = True + verdict["status"] = D4_RESEARCH_STATUS + verdict["ui_badge"] = D4_RESEARCH_STATUS + verdict["readiness_status"] = D4_RESEARCH_READINESS_STATUS + verdict["implementation_unlocked"] = False + verdict["no_live_broker_order_readiness"] = True + verdict["reasons"] = _merge_string_lists(D4_RESEARCH_REASONS, manifest.get("reasons"), verdict.get("reasons")) + manifest["verdict"] = verdict + return manifest, verdict + + +def _normalize_walk_forward_payload(manifest: dict[str, Any], verdict: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]: + verdict_payload = manifest.get("verdict") if isinstance(manifest.get("verdict"), dict) else {} + verdict = {**verdict_payload, **(verdict if isinstance(verdict, dict) else {})} + artifact_status = str(verdict.get("status") or manifest.get("status") or "").upper() + safe_status = "NOT_STARTED" if artifact_status == "NOT_STARTED" else D5_NO_GO_STATUS + for key in D4_FALSE_FLAGS: + manifest[key] = False + verdict[key] = False + manifest["status"] = safe_status + manifest["readiness_status"] = D5_RESEARCH_READINESS_STATUS + manifest["no_live_broker_order_readiness"] = True + verdict["status"] = safe_status + verdict["ui_badge"] = safe_status + verdict["readiness_status"] = D5_RESEARCH_READINESS_STATUS + verdict["implementation_unlocked"] = False + verdict["no_live_broker_order_readiness"] = True + verdict["reasons"] = _merge_string_lists(D5_RESEARCH_REASONS, manifest.get("reasons"), verdict.get("reasons")) + manifest["verdict"] = verdict + return manifest, verdict + + + + + +def _prediction_gate_blockers(manifest: dict[str, Any], baseline_delta_summary: dict[str, Any]) -> list[str]: + existing = _merge_string_lists( + _as_string_list(manifest.get("d3_gate_blockers")), + baseline_delta_summary.get("d3_gate_blockers"), + ) + required: list[str] = [] + if not _price_basis_verified(manifest): + required.append("D0_PRICE_BASIS_NOT_VERIFIED") + if not _universe_official_or_manual_verified( + { + "verdict": manifest.get("universe_verdict"), + "universe_review_status": manifest.get("universe_review_status"), + "official_metadata_status": manifest.get("official_metadata_status"), + "official_metadata_coverage_status": manifest.get("official_metadata_coverage_status"), + "universe_certification_status": manifest.get("universe_certification_status"), + } + ): + required.append("D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED") + required.extend(["D5_WALK_FORWARD_NOT_PASS", "D3_BASELINE_WATCH_RESEARCH_ONLY"]) + return _merge_string_lists(required, existing) + + +def _normalize_prediction_payload( + manifest: dict[str, Any], + verdict: dict[str, Any], + baseline_delta_summary: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + _apply_current_d0_d1_guardrails(manifest) + manifest.setdefault( + "baseline_freeze_contract", + { + "fit_split": manifest.get("fit_split") or "train", + "evaluation_splits": manifest.get("evaluation_splits") or ["val", "test"], + "cost_round_trip_bp": manifest.get("cost_assumption_round_trip_bp") or 23, + "top_k": manifest.get("top_k"), + "deterministic_shuffle_method": "sha256(date:code)_ascending", + "no_oos_retuning": manifest.get("no_oos_retuning", True), + }, + ) + d3_gate_blockers = _prediction_gate_blockers(manifest, baseline_delta_summary) + baseline_delta_summary["status"] = D3_BLOCKED_STATUS + baseline_delta_summary["readiness_status"] = D3_BLOCKED_READINESS_STATUS + baseline_delta_summary["go_summary_allowed"] = False + baseline_delta_summary["model_build_allowed"] = False + baseline_delta_summary["d3_gate_blockers"] = d3_gate_blockers + baseline_delta_summary["required_evidence"] = _merge_string_lists(list(D3_REQUIRED_EVIDENCE), baseline_delta_summary.get("required_evidence")) + baseline_delta_summary["allowed_uses"] = _merge_string_lists(list(D3_ALLOWED_USES_WHEN_WATCH), baseline_delta_summary.get("allowed_uses")) + baseline_delta_summary["blocked_uses"] = _merge_string_lists(list(D3_BLOCKED_USES_WHEN_WATCH), baseline_delta_summary.get("blocked_uses")) + verdict["status"] = D3_BLOCKED_STATUS + verdict["readiness_status"] = D3_BLOCKED_READINESS_STATUS + verdict["go_summary_allowed"] = False + verdict["model_build_allowed"] = False + verdict["d3_gate_blockers"] = d3_gate_blockers + verdict["blocked_uses"] = _merge_string_lists(list(D3_BLOCKED_USES_WHEN_WATCH), verdict.get("blocked_uses")) + verdict["reasons"] = _merge_string_lists( + [ + "RESEARCH_ONLY_NO_PROFIT_LIVE_BROKER_ORDER_CLAIM", + "PRICE_BASIS_UNKNOWN", + "UNIVERSE_WATCH_HEURISTIC", + ], + verdict.get("reasons"), + d3_gate_blockers, + ) + manifest["status"] = D3_BLOCKED_STATUS + manifest["readiness_status"] = D3_BLOCKED_READINESS_STATUS + manifest["model_build_allowed"] = False + manifest["go_summary_allowed"] = False + manifest["d3_gate_blockers"] = d3_gate_blockers + manifest["d3_required_evidence"] = _merge_string_lists(list(D3_REQUIRED_EVIDENCE), manifest.get("d3_required_evidence")) + manifest["d3_allowed_uses"] = _merge_string_lists(list(D3_ALLOWED_USES_WHEN_WATCH), manifest.get("d3_allowed_uses")) + manifest["d3_blocked_uses"] = _merge_string_lists(list(D3_BLOCKED_USES_WHEN_WATCH), manifest.get("d3_blocked_uses")) + manifest["d3_user_guidance"] = _merge_guidance_rows([dict(row) for row in D3_USER_GUIDANCE], manifest.get("d3_user_guidance")) + manifest["baseline_delta_summary"] = baseline_delta_summary + manifest["verdict"] = verdict + return manifest, verdict, baseline_delta_summary + + + + + + +def _effective_daily_model_gate( + *, + db: dict[str, Any], + universe: dict[str, Any], + prediction: dict[str, Any], + gate_verdict: dict[str, Any], + gate_status: str | None, +) -> dict[str, Any]: + blockers: list[str] = [] + if not _price_basis_verified(db): + blockers.append("D0_PRICE_BASIS_NOT_VERIFIED") + if not _universe_official_or_manual_verified(universe): + blockers.append("D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED") + prediction_delta = prediction.get("baseline_delta_summary") or {} + prediction_verdict = prediction.get("verdict") or {} + if prediction_delta.get("model_build_allowed") is not True or prediction_verdict.get("go_summary_allowed") is not True: + blockers.append("D3_BASELINE_NOT_PROMOTABLE") + if gate_status not in {"PASS", "GO"} or gate_verdict.get("model_build_allowed") is not True: + blockers.append("D5_WALK_FORWARD_NOT_PASS") + effective_allowed = not blockers + return { + "model_build_allowed": effective_allowed, + "go_summary_allowed": effective_allowed and gate_verdict.get("go_summary_allowed") is True, + "effective_gate_blockers": blockers, + } + +DAILY_STAGE_VERIFICATION_COMMANDS: dict[str, list[str]] = { + "D0": [ + "py -3.11 -m pytest tests/test_stom_rl_daily_ohlcv_db.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q", + "py -3.11 -m py_compile stom_rl/daily_ohlcv_db.py webui/daily_ohlcv_dashboard.py webui/app.py", + "cd webui/v2_src && npm run check && npm run build", + "browser /daily-ohlcv shows D0 price_basis=unknown, UNKNOWN_CONFIRMED, model_build_allowed=false, and no data-daily-api-error", + ], + "D1": [ + "py -3.11 -m pytest tests/test_stom_rl_daily_ohlcv_universe.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q", + "py -3.11 -m py_compile stom_rl/daily_ohlcv_universe.py webui/daily_ohlcv_dashboard.py webui/app.py", + "cd webui/v2_src && npm run check && npm run build", + "browser /daily-ohlcv shows D1 WATCH_HEURISTIC_UNIVERSE, quarantine counts, official metadata status, and no data-daily-api-error", + ], + "D2": [ + "py -3.11 -m pytest tests/test_stom_rl_daily_ohlcv_dataset.py tests/test_daily_ohlcv_dashboard_api.py -q", + "py -3.11 -m py_compile stom_rl/daily_ohlcv_dataset.py webui/daily_ohlcv_dashboard.py", + "cd webui/v2_src && npm run check && npm run build", + "browser /daily-ohlcv shows D2 split/leakage evidence, inherited D0/D1 guardrails, and no data-daily-api-error", + ], + "D3": [ + "py -3.11 -m pytest tests/test_stom_rl_daily_prediction.py tests/test_stom_rl_daily_ranker.py tests/test_stom_rl_daily_ohlcv_dataset.py tests/test_stom_rl_daily_ohlcv_universe.py tests/test_stom_rl_daily_ohlcv_db.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q", + "py -3.11 -m py_compile stom_rl/daily_prediction.py stom_rl/daily_ranker.py webui/daily_ohlcv_dashboard.py webui/app.py", + "cd webui/v2_src && npm run check && npm run build", + "browser /daily-ohlcv shows D3 WATCH, shuffle_control, no_trade_cash, frozen baseline deltas, model_build_allowed=false, and no data-daily-api-error", + ], + "D4": [ + "py -3.11 -m pytest tests/test_stom_rl_daily_portfolio_env.py tests/test_stom_rl_daily_rl_gate.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q", + "py -3.11 -m py_compile stom_rl/daily_portfolio_env.py stom_rl/daily_rl_train.py webui/daily_ohlcv_dashboard.py", + "browser /daily-ohlcv shows D4 state contract, reward/action diagnostics, portfolio trajectory, and model_build_allowed=false", + ], + "D5": [ + "py -3.11 -m pytest tests/test_stom_rl_daily_walk_forward.py tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q", + "py -3.11 -m py_compile stom_rl/daily_walk_forward.py webui/daily_ohlcv_dashboard.py", + "browser /daily-ohlcv shows D5 no_oos_retuning, purge/embargo, 0/23/46bp sensitivity, and NO-GO reasons", + ], + "D6": [ + "py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q", + "cd webui/v2_src && npm run check && npm run build", + "browser /daily-ohlcv shows decision cockpit, flow, glossary, overlays, heatmaps, scatter, universe and symbol diagnostics", + ], + "D7": [ + "py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py tests/test_daily_ohlcv_dashboard_tab.py -q", + "browser /daily-ohlcv shows D7 research diagnostics placeholders for feature/regime/correlation/failure analysis", + ], + "D8": [ + "py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py -q", + "browser /daily-ohlcv shows D8 registry hashes, lock reasons, drift, and no_live_broker_order_readiness", + ], + "D9": [ + "py -3.11 -m pytest tests/test_daily_ohlcv_dashboard_api.py -q", + "browser /daily-ohlcv shows paper-forward remains blocked; no live/broker/orders", + ], +} + +DAILY_STAGE_LOCK_LABELS: dict[str, list[str]] = { + "D0": ["PRICE_BASIS_UNKNOWN", "UNKNOWN_CONFIRMED", "BLOCKED_UNTIL_PRICE_BASIS_VERIFIED"], + "D1": ["WATCH_HEURISTIC_UNIVERSE", "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW"], + "D2": ["PASS_WITH_D0_D1_GUARDRAILS", "DATE_SPLIT_ONLY", "NO_FUTURE_LEAKAGE"], + "D3": ["WATCH", "SHUFFLE_CONTROL", "NO_TRADE_CONTROL", "FROZEN_D3_BASELINE_REQUIRED", "MODEL_BUILD_ALLOWED_FALSE"], + "D4": ["RESEARCH_ONLY", "D4_OBSERVATION_STATE_MANIFEST", "MODEL_BUILD_ALLOWED_FALSE"], + "D5": ["NO_GO", "NO_OOS_RETUNING", "SHUFFLE_NO_TRADE_CONTROLS", "MODEL_BUILD_ALLOWED_FALSE"], + "D6": ["READ_ONLY_VISUALIZATION", "DECISION_COCKPIT", "EVIDENCE_OVERLAY"], + "D7": ["RESEARCH_DIAGNOSTICS", "FEATURE_REGIME_FAILURE_PLACEHOLDERS", "NO_ALPHA_CLAIM"], + "D8": ["REGISTRY_HASHES", "PAPER_FORWARD_BLOCKED", "NO_LIVE_BROKER_ORDER_READINESS"], + "D9": ["PAPER_FORWARD_BLOCKED", "NO_LIVE_BROKER_ORDERS", "MODEL_BUILD_ALLOWED_FALSE"], +} +DAILY_PAGE_USAGE_GUIDE: dict[str, dict[str, str]] = { + "D0": { + "stage": "D0", + "page": "Daily DB Analysis", + "can_do": "테이블 수, 행 수, 기간, 결측, OHLC 오류, 분할 유사 discontinuity를 확인한다.", + "must_not": "price_basis=unknown 상태에서 decision-grade 수익률이나 모델 빌드 근거로 쓰지 않는다.", + "next_action": "adjusted/raw, split, dividend 기준을 독립 증거로 확정한다.", + }, + "D1": { + "stage": "D1", + "page": "Daily Universe Management", + "can_do": "KOSPI/KOSDAQ 보통주 후보, 제외/격리 사유, 선행 0 코드 보존 상태를 검토한다.", + "must_not": "공식/수동 KRX 검증 전 WATCH_HEURISTIC_UNIVERSE를 PASS처럼 사용하지 않는다.", + "next_action": "code,name,market,instrument_type 계약의 공식 또는 수동 CSV 검증을 완료한다.", + }, + "D2": { + "stage": "D2", + "page": "Daily Dataset Builder", + "can_do": "날짜 split, leakage, normalization, target 생성 범위를 확인한다.", + "must_not": "D0/D1 blocker가 남아 있으면 학습·주문·수익 주장으로 연결하지 않는다.", + "next_action": "upstream blocker가 해소된 뒤 같은 split 계약으로 재생성한다.", + }, + "D3": { + "stage": "D3", + "page": "Daily Prediction / Top-K", + "can_do": "no-trade, deterministic shuffle, rule, supervised baseline의 비용 후 상대 성능을 비교한다.", + "must_not": "WATCH 상태의 baseline을 RL promotion 또는 수익 증명으로 쓰지 않는다.", + "next_action": "frozen D3 baseline이 shuffle/no-trade를 안정적으로 넘는지 fresh OOS로 재검증한다.", + }, + "D4": { + "stage": "D4", + "page": "Daily Portfolio RL", + "can_do": "학습곡선, 보상 스택, action 분포, invalid action, NAV, drawdown, turnover를 진단한다.", + "must_not": "RESEARCH_ONLY RL 곡선을 실거래 후보나 배포 가능한 모델처럼 표현하지 않는다.", + "next_action": "D3/D5 기준을 통과할 새 reward/action/environment 가설만 사전등록 후 실험한다.", + }, + "D5": { + "stage": "D5", + "page": "Daily Walk-forward / Gate", + "can_do": "fold consistency, no-OOS-retuning, shuffle/no-trade/D3 control, 23bp·stress cost를 확인한다.", + "must_not": "NO-GO 상태에서 model_build_allowed 또는 paper_forward_allowed를 true로 해석하지 않는다.", + "next_action": "candidate-specific fresh OOS에서 모든 gate reason을 해소한다.", + }, + "D6": { + "stage": "D6", + "page": "Daily Dashboard Visualization", + "can_do": "Decision Cockpit, Flow, Glossary, Equity Overlay, Heatmap, Scatter, Universe, Symbol chart로 증거를 읽는다.", + "must_not": "시각화 곡선을 수익 보장, live/broker/order readiness, 모델 배포 증거로 말하지 않는다.", + "next_action": "각 카드의 blocker와 provenance hash를 기준으로 다음 연구 작업을 선택한다.", + }, + "D7": { + "stage": "D7", + "page": "Daily Research Lab", + "can_do": "feature/regime/correlation/failure 진단으로 왜 실패했는지와 어떤 가설을 고쳐야 하는지 찾는다.", + "must_not": "PLACEHOLDER_READY 또는 설명용 feature importance를 alpha/profit claim으로 사용하지 않는다.", + "next_action": "feature_importance_by_fold, regime_bucket_metrics, correlation_cluster, failure_attribution artifact를 추가한다.", + }, + "D8": { + "stage": "D8", + "page": "Daily Registry", + "can_do": "config/data/code/source hash, drift, lock reason, selected/blocked candidate 기록을 추적한다.", + "must_not": "registry 행을 broker 주문 준비나 모델 promotion으로 해석하지 않는다.", + "next_action": "D0-D5 gate가 PASS일 때만 paper-forward 후보 기록을 재평가한다.", + }, + "D9": { + "stage": "D9", + "page": "Daily Paper-forward", + "can_do": "paper-only 계획/관찰 로그와 no_live_broker_order_readiness를 확인한다.", + "must_not": "live_broker_order_allowed=false 상태에서 주문, 실거래, 브로커 연동을 진행하지 않는다.", + "next_action": "연구 전용 blocked evidence를 유지하고, 모든 gate 통과 전에는 paper-forward도 잠근다.", + }, +} + + +def _usage_for_stage(stage_id: str) -> dict[str, str]: + return dict(DAILY_PAGE_USAGE_GUIDE.get(stage_id, {"stage": stage_id})) + + +def _progress_stage(stage_id: str, label: str, status: str | None, evidence: str) -> dict[str, Any]: + usage_guide = _usage_for_stage(stage_id) + return { + "id": stage_id, + "label": label, + "status": status or "NOT_STARTED", + "evidence": evidence, + "lock_labels": DAILY_STAGE_LOCK_LABELS.get(stage_id, []), + "verification_commands": DAILY_STAGE_VERIFICATION_COMMANDS.get(stage_id, []), + "usage_guide": usage_guide, + "can_do": usage_guide.get("can_do"), + "must_not": usage_guide.get("must_not"), + "next_action": usage_guide.get("next_action"), + } + + +def load_daily_db_summary(*, run: str | None = None, table_limit: int = 100, flag_limit: int = 100, window_limit: int = 50) -> dict[str, Any]: + run_dir = _latest_run_dir(DB_SUMMARY_ROOT, required_file="db_summary.json", run_id=run) + if run_dir is None: + summary = summarize_daily_db(table_limit=table_limit, quality_table_limit=0) + summary["artifact_status"] = "GENERATED_ON_DEMAND_READ_ONLY" + else: + summary = _load_json(run_dir / "db_summary.json") + summary["artifact_status"] = "LOADED_GENERATED_ARTIFACT" + summary["artifact_dir"] = str(run_dir) + _limit_list(summary, "table_summaries", _bounded_limit(table_limit, default=100)) + _limit_list(summary, "quality_flags", _bounded_limit(flag_limit, default=100)) + _limit_list(summary, "material_unknown_adjustment_windows", _bounded_limit(window_limit, default=50)) + source_price_basis = summary.get("price_basis") + source_price_basis_status = summary.get("price_basis_status") + source_decision_status = summary.get("decision_grade_return_status") + summary["price_basis"] = PRICE_BASIS + summary["price_basis_status"] = PRICE_BASIS_STATUS + summary["decision_grade_return_status"] = DECISION_GRADE_RETURN_STATUS + audit = summary.get("price_basis_audit") if isinstance(summary.get("price_basis_audit"), dict) else {} + audit = { + **audit, + "status": PRICE_BASIS_STATUS, + "required_evidence": list(PRICE_BASIS_REQUIRED_EVIDENCE), + "allowed_uses": list(PRICE_BASIS_ALLOWED_USES), + "blocked_uses": list(PRICE_BASIS_BLOCKED_USES), + "user_guidance": [dict(row) for row in PRICE_BASIS_USER_GUIDANCE], + "normalized_from_artifact": { + "price_basis": source_price_basis, + "price_basis_status": source_price_basis_status, + "decision_grade_return_status": source_decision_status, + }, + } + summary["price_basis_audit"] = audit + summary["price_basis_required_evidence"] = list(PRICE_BASIS_REQUIRED_EVIDENCE) + summary["price_basis_allowed_uses"] = list(PRICE_BASIS_ALLOWED_USES) + summary["price_basis_blocked_uses"] = list(PRICE_BASIS_BLOCKED_USES) + summary["price_basis_user_guidance"] = [dict(row) for row in PRICE_BASIS_USER_GUIDANCE] + summary["read_only"] = True + summary["query_only"] = True + summary["guardrail"] = "Research-only daily OHLCV evidence; no DB mutation, no live/broker/orders, no profit claim." + summary["read_only_dashboard_note"] = "GET-only D0 daily OHLCV evidence; no DB mutation, no live/broker/orders, no profit claim." + return summary + + +def load_daily_symbol(symbol_or_table: str, *, sample_limit: int = 50) -> dict[str, Any]: + return summarize_symbol(symbol_or_table, sample_limit=_bounded_limit(sample_limit, default=50, maximum=200)) + + +def load_universe_preview(*, run: str | None = None, limit: int = 200, include: str | None = None) -> dict[str, Any]: + run_dir = _latest_run_dir(DEFAULT_UNIVERSE_ROOT, required_file="universe.json", run_id=run) + if run_dir is None: + manifest = build_universe_manifest() + manifest["artifact_status"] = "GENERATED_ON_DEMAND_READ_ONLY" + else: + manifest = _load_json(run_dir / "universe.json") + manifest["artifact_status"] = "LOADED_GENERATED_ARTIFACT" + manifest["artifact_dir"] = str(run_dir) + manifest.setdefault("official_metadata_status", "MISSING") + manifest.setdefault("official_metadata_path", str(DEFAULT_OFFICIAL_METADATA_PATH)) + manifest.setdefault("official_metadata_required_columns", list(OFFICIAL_METADATA_REQUIRED_COLUMNS)) + manifest.setdefault("official_metadata_unmatched_table_count", manifest.get("table_count", 0)) + manifest.setdefault("official_metadata_matched_table_count", 0) + manifest.setdefault("official_metadata_coverage_status", "MISSING" if manifest["official_metadata_status"] == "MISSING" else "PARTIAL") + manifest.setdefault("universe_review_status", manifest.get("review_status") or manifest.get("verdict") or "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW") + manifest.setdefault("universe_certification_status", "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW") + manifest.setdefault("universe_required_evidence", list(UNIVERSE_REQUIRED_EVIDENCE)) + manifest.setdefault("universe_allowed_uses", list(UNIVERSE_ALLOWED_USES_WHEN_WATCH)) + manifest.setdefault("universe_blocked_uses", list(UNIVERSE_BLOCKED_USES_WHEN_WATCH)) + manifest.setdefault("universe_user_guidance", [dict(row) for row in UNIVERSE_USER_GUIDANCE]) + manifest.setdefault("official_metadata_unmatched_quarantine_count", 0) + manifest.setdefault( + "official_metadata", + { + "status": manifest["official_metadata_status"], + "available": False, + "used": False, + "required_columns": list(OFFICIAL_METADATA_REQUIRED_COLUMNS), + "review_status": "WATCH_OFFICIAL_METADATA_REQUIRED", + "coverage_status": manifest.get("official_metadata_coverage_status"), + "certification_status": manifest.get("universe_certification_status"), + }, + ) + official_metadata = manifest.get("official_metadata") + if isinstance(official_metadata, dict): + official_metadata.setdefault("coverage_status", manifest.get("official_metadata_coverage_status")) + official_metadata.setdefault("certification_status", manifest.get("universe_certification_status")) + official_path = Path(str(manifest.get("official_metadata_path") or DEFAULT_OFFICIAL_METADATA_PATH)) + if not official_path.exists(): + manifest["verdict"] = "WATCH_HEURISTIC_UNIVERSE" + manifest["review_status"] = "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW" + manifest["universe_review_status"] = "WATCH_REQUIRES_OFFICIAL_OR_MANUAL_REVIEW" + manifest["official_metadata_status"] = "MISSING" + manifest["official_metadata_coverage_status"] = "MISSING" + manifest["universe_certification_status"] = "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW" + manifest["official_metadata_unmatched_table_count"] = manifest.get("table_count", 0) + manifest["official_metadata_matched_table_count"] = 0 + manifest["universe_allowed_uses"] = list(UNIVERSE_ALLOWED_USES_WHEN_WATCH) + manifest["universe_blocked_uses"] = list(UNIVERSE_BLOCKED_USES_WHEN_WATCH) + if isinstance(official_metadata, dict): + official_metadata["status"] = "MISSING" + official_metadata["available"] = False + official_metadata["used"] = False + official_metadata["review_status"] = "WATCH_OFFICIAL_METADATA_REQUIRED" + official_metadata["coverage_status"] = "MISSING" + official_metadata["certification_status"] = "BLOCKED_UNTIL_OFFICIAL_OR_MANUAL_REVIEW" + for required_field in ("official_metadata_status", "official_metadata_coverage_status", "universe_certification_status"): + if required_field not in set(manifest.get("required_fields") or []): + manifest["required_fields"] = list(manifest.get("required_fields") or []) + [required_field] + rows = list(manifest.get("symbols") or []) + if include == "true": + rows = [row for row in rows if row.get("include") is True] + elif include == "false": + rows = [row for row in rows if row.get("include") is False] + safe_limit = _bounded_limit(limit, default=200) + manifest["symbols_total"] = len(rows) + manifest["symbols_truncated"] = len(rows) > safe_limit + manifest["symbols"] = rows[:safe_limit] + manifest["exclusions_total"] = len(manifest.get("exclusions") or []) + manifest["exclusions"] = list(manifest.get("exclusions") or [])[:safe_limit] + manifest["read_only_dashboard_note"] = "GET-only D1 universe evidence; no live/broker/orders, no profit claim." + return manifest + + +def list_universe_manifests(limit: int = 20) -> dict[str, Any]: + root = DEFAULT_UNIVERSE_ROOT.resolve() + safe_limit = _bounded_limit(limit, default=20, maximum=200) + runs: list[dict[str, Any]] = [] + if root.exists(): + for run_dir in sorted(root.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): + manifest_path = run_dir / "universe.json" + if not run_dir.is_dir() or not manifest_path.exists(): + continue + payload = _load_json(manifest_path) + runs.append( + { + "run_id": run_dir.name, + "artifact_dir": str(run_dir), + "modified_at": manifest_path.stat().st_mtime, + "verdict": payload.get("verdict"), + "manifest_sha": payload.get("manifest_sha"), + "table_count": payload.get("table_count"), + "include_count": payload.get("include_count"), + "exclude_count": payload.get("exclude_count"), + "stockinfo_matched_table_count": payload.get("stockinfo_matched_table_count"), + "stockinfo_unmatched_table_count": payload.get("stockinfo_unmatched_table_count"), + "official_metadata_coverage_status": payload.get("official_metadata_coverage_status"), + "universe_certification_status": payload.get("universe_certification_status"), + } + ) + if len(runs) >= safe_limit: + break + return {"runs": runs, "read_only_dashboard_note": "Generated universe manifests only; no writes from dashboard."} + + +def load_dataset_latest(*, run: str | None = None, sample_limit: int = 25) -> dict[str, Any]: + run_dir = _latest_run_dir(DEFAULT_DATASET_ROOT, required_file="dataset_manifest.json", run_id=run) + if run_dir is None: + return { + "status": "NOT_STARTED", + "read_only": True, + "guardrail": "D2 dataset artifacts are absent; no model/profit/live readiness claim.", + } + manifest = _normalize_dataset_manifest(_load_json(run_dir / "dataset_manifest.json")) + safe_limit = _bounded_limit(sample_limit, default=25, maximum=500) + payload = { + **manifest, + "status": "PASS" if manifest.get("leakage_status") == "PASS" and manifest.get("split_chronology_status") == "PASS" else "WATCH", + "artifact_status": "LOADED_GENERATED_ARTIFACT", + "artifact_dir": str(run_dir), + "read_only": True, + "read_only_dashboard_note": "GET-only D2 dataset evidence; no training/order/live/profit action from dashboard.", + "samples": { + "feature_panel": _read_csv_rows(run_dir / "feature_panel.csv", safe_limit), + "label_panel": _read_csv_rows(run_dir / "label_panel.csv", safe_limit), + "rl_candidate_panel": _read_csv_rows(run_dir / "rl_candidate_panel.csv", safe_limit), + "split_assignments": _read_csv_rows(run_dir / "split_assignments.csv", safe_limit), + "blocked_windows": _read_csv_rows(run_dir / "blocked_windows.csv", safe_limit), + }, + } + payload["normalization_stats"] = _load_json(run_dir / "normalization_stats.json") if (run_dir / "normalization_stats.json").exists() else {} + payload["leakage_report"] = _load_json(run_dir / "leakage_report.json") if (run_dir / "leakage_report.json").exists() else {} + return payload + + +def list_dataset_artifacts(limit: int = 20) -> dict[str, Any]: + root = DEFAULT_DATASET_ROOT.resolve() + safe_limit = _bounded_limit(limit, default=20, maximum=200) + runs: list[dict[str, Any]] = [] + if root.exists(): + for run_dir in sorted(root.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): + manifest_path = run_dir / "dataset_manifest.json" + if not run_dir.is_dir() or not manifest_path.exists(): + continue + manifest = _normalize_dataset_manifest(_load_json(manifest_path)) + runs.append( + { + "kind": "daily_ohlcv_dataset", + "run_id": run_dir.name, + "artifact_dir": str(run_dir), + "primary_file": str(manifest_path), + "modified_at": manifest_path.stat().st_mtime, + "manifest_sha": manifest.get("manifest_sha"), + "artifact_scope": manifest.get("artifact_scope"), + "leakage_status": manifest.get("leakage_status"), + "split_chronology_status": manifest.get("split_chronology_status"), + "feature_rows": (manifest.get("row_counts") or {}).get("feature_rows"), + "eligible_rows": (manifest.get("row_counts") or {}).get("eligible_rows"), + "model_readiness": manifest.get("model_readiness"), + "upstream_gate_blockers": manifest.get("upstream_gate_blockers") or [], + "decision_grade_status": manifest.get("decision_grade_status"), + "dataset_blocked_uses": manifest.get("dataset_blocked_uses") or [], + } + ) + if len(runs) >= safe_limit: + break + return {"runs": runs, "read_only_dashboard_note": "Generated D2 dataset artifacts only; no writes from dashboard."} + + +def load_dataset_chart(*, run: str | None = None) -> dict[str, Any]: + dataset = load_dataset_latest(run=run, sample_limit=0) + if dataset.get("status") == "NOT_STARTED": + return load_not_started_surface("dataset") + split_counts = ((dataset.get("split_summary") or {}).get("row_counts") or {}) + row_counts = dataset.get("row_counts") or {} + return { + "status": dataset.get("status"), + "run_id": dataset.get("run_id"), + "artifact_scope": dataset.get("artifact_scope"), + "leakage_status": dataset.get("leakage_status"), + "split_chronology_status": dataset.get("split_chronology_status"), + "price_basis": dataset.get("price_basis"), + "universe_verdict": dataset.get("universe_verdict"), + "model_readiness": dataset.get("model_readiness"), + "upstream_gate_blockers": dataset.get("upstream_gate_blockers") or [], + "price_basis_status": dataset.get("price_basis_status"), + "decision_grade_return_status": dataset.get("decision_grade_return_status"), + "official_metadata_status": dataset.get("official_metadata_status"), + "official_metadata_coverage_status": dataset.get("official_metadata_coverage_status"), + "universe_certification_status": dataset.get("universe_certification_status"), + "split_series": [{"label": key, "value": value} for key, value in split_counts.items()], + "row_series": [{"label": key, "value": value} for key, value in row_counts.items()], + "guardrail": "D2 dataset evidence only. It is not a profit, broker, live, order, or trained-RL readiness claim.", + } + + +def load_prediction_latest(*, run: str | None = None, sample_limit: int = 25) -> dict[str, Any]: + run_dir = _latest_run_dir(DEFAULT_PREDICTION_ROOT, required_file="prediction_manifest.json", run_id=run) + if run_dir is None: + return load_not_started_surface("prediction") + safe_limit = _bounded_limit(sample_limit, default=25, maximum=500) + manifest = _load_json(run_dir / "prediction_manifest.json") + verdict = _load_json(run_dir / "verdict.json") if (run_dir / "verdict.json").exists() else manifest.get("verdict", {}) + baseline = _load_json(run_dir / "baseline_metrics.json") if (run_dir / "baseline_metrics.json").exists() else {"metrics": []} + baseline_delta_summary = _load_json(run_dir / "baseline_delta_summary.json") if (run_dir / "baseline_delta_summary.json").exists() else manifest.get("baseline_delta_summary", {}) + model_metrics = _load_json(run_dir / "model_metrics.json") if (run_dir / "model_metrics.json").exists() else {"metrics": []} + manifest, verdict, baseline_delta_summary = _normalize_prediction_payload(manifest, verdict, baseline_delta_summary) + return { + **manifest, + "status": verdict.get("status") or (manifest.get("verdict") or {}).get("status") or "WATCH", + "readiness_status": D3_BLOCKED_READINESS_STATUS, + "model_build_allowed": False, + "go_summary_allowed": False, + "artifact_status": "LOADED_GENERATED_ARTIFACT", + "artifact_dir": str(run_dir), + "read_only": True, + "read_only_dashboard_note": "GET-only D3 baseline/ranker evidence; no training/order/live/profit action from dashboard.", + "verdict": verdict, + "baseline_metrics": list(baseline.get("metrics") or [])[:safe_limit], + "model_metrics": list(model_metrics.get("metrics") or [])[:safe_limit], + "baseline_delta_summary": baseline_delta_summary, + "d3_gate_blockers": manifest.get("d3_gate_blockers") or [], + "d3_required_evidence": manifest.get("d3_required_evidence") or [], + "d3_allowed_uses": manifest.get("d3_allowed_uses") or [], + "d3_blocked_uses": manifest.get("d3_blocked_uses") or [], + "d3_user_guidance": manifest.get("d3_user_guidance") or [], + "samples": { + "predictions": _read_csv_rows(run_dir / "predictions.csv", safe_limit), + "calibration": _read_csv_rows(run_dir / "calibration.csv", safe_limit), + "turnover": _read_csv_rows(run_dir / "turnover.csv", safe_limit), + "drawdown": _read_csv_rows(run_dir / "drawdown.csv", safe_limit), + }, + } + + +def load_portfolio_latest(*, run: str | None = None, sample_limit: int = 25) -> dict[str, Any]: + run_dir = _latest_run_dir(DEFAULT_PORTFOLIO_ROOT, required_file="rl_manifest.json", run_id=run) + if run_dir is None: + return load_not_started_surface("portfolio") + safe_limit = _bounded_limit(sample_limit, default=25, maximum=500) + manifest = _load_json(run_dir / "rl_manifest.json") + verdict = _load_json(run_dir / "verdict.json") if (run_dir / "verdict.json").exists() else manifest.get("verdict", {}) + policy_metrics = _load_json(run_dir / "policy_metrics.json") if (run_dir / "policy_metrics.json").exists() else {"metrics": []} + baseline_comparison = _load_json(run_dir / "baseline_comparison.json") if (run_dir / "baseline_comparison.json").exists() else {} + training_manifest = _load_json(run_dir / "training_manifest.json") if (run_dir / "training_manifest.json").exists() else manifest + reward_component_summary = _load_json(run_dir / "reward_component_summary.json") if (run_dir / "reward_component_summary.json").exists() else {} + policy_evaluation = _load_json(run_dir / "policy_evaluation_manifest.json") if (run_dir / "policy_evaluation_manifest.json").exists() else {} + observation_manifest = _load_json(run_dir / "observation_manifest.json") if (run_dir / "observation_manifest.json").exists() else manifest.get("observation_manifest", {}) + manifest, verdict = _normalize_portfolio_payload(manifest, verdict) + training_manifest, _training_verdict = _normalize_portfolio_payload( + training_manifest, + (training_manifest.get("verdict") if isinstance(training_manifest.get("verdict"), dict) else {}), + ) + policy_evaluation = { + **policy_evaluation, + "status": D4_RESEARCH_STATUS, + "readiness_status": D4_RESEARCH_READINESS_STATUS, + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + } + telemetry = training_manifest.get("telemetry") or manifest.get("telemetry") or {} + if isinstance(telemetry, dict): + telemetry = {**telemetry, "canonical_artifacts": _merge_string_lists(D4_CANONICAL_ARTIFACTS, telemetry.get("canonical_artifacts"))} + else: + telemetry = {"canonical_artifacts": list(D4_CANONICAL_ARTIFACTS)} + return { + **manifest, + "status": D4_RESEARCH_STATUS, + "readiness_status": D4_RESEARCH_READINESS_STATUS, + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "artifact_status": "LOADED_GENERATED_ARTIFACT", + "artifact_dir": str(run_dir), + "read_only": True, + "read_only_dashboard_note": "GET-only D4 portfolio RL evidence; no training/order/live/profit action from dashboard.", + "verdict": verdict, + "policy_metrics": policy_metrics, + "baseline_comparison": baseline_comparison, + "training_manifest": training_manifest, + "telemetry": telemetry, + "reward_component_summary": reward_component_summary, + "policy_evaluation": policy_evaluation, + "observation_manifest": observation_manifest, + "observation_manifest_validation": manifest.get("observation_manifest_validation") or observation_manifest.get("validation") or {}, + "samples": { + "positions": _read_csv_rows(run_dir / "positions.csv", safe_limit), + "invalid_actions": _read_csv_rows(run_dir / "invalid_actions.csv", safe_limit), + "reward_breakdown": _read_csv_rows(run_dir / "reward_breakdown.csv", safe_limit), + "learning_curve": _read_csv_rows(run_dir / "learning_curve.csv", safe_limit), + "action_distribution": _read_csv_rows(run_dir / "action_distribution.csv", safe_limit), + "turnover": _read_csv_rows(run_dir / "turnover.csv", safe_limit), + "drawdown": _read_csv_rows(run_dir / "drawdown.csv", safe_limit), + "policy_baseline_comparison": _read_csv_rows(run_dir / "policy_baseline_comparison.csv", safe_limit), + "policy_nav": _read_csv_rows(run_dir / "policy_nav.csv", safe_limit), + "state_observations": _read_csv_rows(run_dir / "state_observations.csv", safe_limit), + }, + } + + +def load_walk_forward_latest(*, run: str | None = None, sample_limit: int = 25) -> dict[str, Any]: + run_dir = _latest_run_dir(DEFAULT_WALK_FORWARD_ROOT, required_file="walk_forward_manifest.json", run_id=run) + if run_dir is None: + return load_not_started_surface("gate") + safe_limit = _bounded_limit(sample_limit, default=25, maximum=500) + manifest = _load_json(run_dir / "walk_forward_manifest.json") + verdict = _load_json(run_dir / "gate_verdict.json") if (run_dir / "gate_verdict.json").exists() else manifest.get("verdict", {}) + d4_state_contract = ( + _load_json(run_dir / "d4_state_contract.json") + if (run_dir / "d4_state_contract.json").exists() + else manifest.get("d4_state_contract", {}) + ) + manifest, verdict = _normalize_walk_forward_payload(manifest, verdict) + verdict = _normalize_d5_contract_verdict(verdict, d4_state_contract if isinstance(d4_state_contract, dict) else {}) + manifest["verdict"] = verdict + return { + **manifest, + "status": manifest.get("status") or D5_NO_GO_STATUS, + "readiness_status": manifest.get("readiness_status") or D5_RESEARCH_READINESS_STATUS, + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "artifact_status": "LOADED_GENERATED_ARTIFACT", + "artifact_dir": str(run_dir), + "read_only": True, + "read_only_dashboard_note": "GET-only D5 walk-forward/gate evidence; no training/order/live/profit action from dashboard.", + "verdict": verdict, + "d4_state_contract": d4_state_contract, + "d4_state_contract_status": verdict.get("d4_state_contract_status"), + "d4_observation_manifest_gate": verdict.get("d4_observation_manifest_gate"), + "d4_observation_manifest_validation_status": verdict.get("d4_observation_manifest_validation_status"), + "d4_state_observation_rows": verdict.get("d4_state_observation_rows"), + "d4_reward_action_ablation_rows": verdict.get("d4_reward_action_ablation_rows"), + "d4_source_hash_count": verdict.get("d4_source_hash_count"), + "d4_state_contract_artifacts_consumed": verdict.get("d4_state_contract_artifacts_consumed"), + "d4_artifact_issues": verdict.get("d4_artifact_issues") or [], + "samples": { + "folds": _read_csv_rows(run_dir / "folds.csv", safe_limit), + "fold_metrics": _read_csv_rows(run_dir / "fold_metrics.csv", safe_limit), + "shuffle_control": _read_csv_rows(run_dir / "shuffle_control.csv", safe_limit), + "cost_sensitivity": _read_csv_rows(run_dir / "cost_sensitivity.csv", safe_limit), + "rl_fold_metrics": _read_csv_rows(run_dir / "rl_fold_metrics.csv", safe_limit), + "fold_assignments": _read_csv_rows(run_dir / "fold_assignments.csv", safe_limit), + }, + } + + +def _is_sha256(value: Any) -> bool: + return isinstance(value, str) and bool(re.fullmatch(r"[0-9a-f]{64}", value)) + + +def _registry_artifact_invariant_errors(manifest: dict[str, Any], candidate_registry: dict[str, Any]) -> list[str]: + errors: list[str] = [] + guardrail = str(manifest.get("guardrail") or "") + if "no live/broker/orders" not in guardrail: + errors.append("REGISTRY_GUARDRAIL_MISSING_NO_LIVE_BROKER_ORDERS") + if "no profit" not in guardrail.lower(): + errors.append("REGISTRY_GUARDRAIL_MISSING_NO_PROFIT_CLAIM") + if manifest.get("live_broker_order_allowed") is not False: + errors.append("REGISTRY_MANIFEST_LIVE_BROKER_ORDER_NOT_FALSE") + if manifest.get("no_live_broker_order_readiness") is not True: + errors.append("REGISTRY_MANIFEST_NO_LIVE_BROKER_ORDER_READINESS_NOT_TRUE") + for key in ("config_hash", "data_hash", "code_hash"): + if not _is_sha256(manifest.get(key)): + errors.append(f"REGISTRY_MANIFEST_{key.upper()}_INVALID") + candidates = candidate_registry.get("candidates") + if not isinstance(candidates, list) or not candidates: + errors.append("REGISTRY_CANDIDATES_MISSING") + return errors + has_model_build_candidate = False + has_paper_forward_candidate = False + for index, candidate in enumerate(candidates): + if not isinstance(candidate, dict): + errors.append(f"REGISTRY_CANDIDATE_{index}_INVALID") + continue + if candidate.get("live_broker_order_allowed") is not False: + errors.append(f"REGISTRY_CANDIDATE_{index}_LIVE_BROKER_ORDER_NOT_FALSE") + if candidate.get("no_live_broker_order_readiness") is not True: + errors.append(f"REGISTRY_CANDIDATE_{index}_NO_LIVE_BROKER_ORDER_READINESS_NOT_TRUE") + for key in ("config_hash", "data_hash", "code_hash"): + if not _is_sha256(candidate.get(key)): + errors.append(f"REGISTRY_CANDIDATE_{index}_{key.upper()}_INVALID") + source_hashes = candidate.get("source_hashes") + required_sources = { + "stom_rl/daily_rl_train.py", + "stom_rl/daily_walk_forward.py", + "stom_rl/daily_registry.py", + "webui/daily_ohlcv_dashboard.py", + "webui/app.py", + "webui/v2_src/src/lib/dailyOhlcvApi.ts", + "webui/v2_src/src/tabs/DailyOhlcvTab.svelte", + "webui/v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte", + "webui/v2_src/src/tabs/dailyOhlcv/DailyVisualLabCard.svelte", + } + if not isinstance(source_hashes, dict): + errors.append(f"REGISTRY_CANDIDATE_{index}_SOURCE_HASHES_MISSING") + else: + for source in sorted(required_sources): + if not _is_sha256(source_hashes.get(source)): + errors.append(f"REGISTRY_CANDIDATE_{index}_SOURCE_HASH_INVALID_{source}") + d5_status = str(candidate.get("d5_status") or "") + d4_status = str(candidate.get("d4_status") or "") + effective_blockers = candidate.get("effective_gate_blockers") + effective_blockers_explicit = isinstance(effective_blockers, list) + explicit_effective_blockers = effective_blockers if effective_blockers_explicit else [] + if not effective_blockers_explicit and (candidate.get("model_build_allowed") is True or candidate.get("paper_forward_allowed") is True): + errors.append(f"REGISTRY_CANDIDATE_{index}_EFFECTIVE_GATE_BLOCKERS_MISSING") + baseline_delta = _to_float(candidate.get("baseline_delta_vs_best_d3")) + candidate_model_allowed = candidate.get("model_build_allowed") is True + candidate_paper_allowed = candidate.get("paper_forward_allowed") is True + candidate_research_safe = ( + effective_blockers_explicit + and not explicit_effective_blockers + and _price_basis_verified(candidate) + and _universe_official_or_manual_verified(candidate) + and baseline_delta is not None + and baseline_delta >= 0 + ) + candidate_gates_safe = d5_status == "PASS" and d4_status == "PASS" and candidate_research_safe + if candidate_model_allowed and not candidate_gates_safe: + errors.append(f"REGISTRY_CANDIDATE_{index}_MODEL_BUILD_TRUE_WITH_LOCKED_GATES") + if candidate_paper_allowed and not candidate_gates_safe: + errors.append(f"REGISTRY_CANDIDATE_{index}_PAPER_FORWARD_TRUE_WITH_LOCKED_GATES") + has_model_build_candidate = has_model_build_candidate or (candidate_model_allowed and candidate_gates_safe) + has_paper_forward_candidate = has_paper_forward_candidate or (candidate_paper_allowed and candidate_gates_safe) + if manifest.get("model_build_allowed") is True and not has_model_build_candidate: + errors.append("REGISTRY_MANIFEST_MODEL_BUILD_TRUE_WITHOUT_SAFE_CANDIDATE") + if manifest.get("paper_forward_allowed") is True and not has_paper_forward_candidate: + errors.append("REGISTRY_MANIFEST_PAPER_FORWARD_TRUE_WITHOUT_SAFE_CANDIDATE") + return errors + +REGISTRY_REQUIRED_CSV_EVIDENCE = { + "PAPER_SELECTED": ( + "paper_selected.csv", + {"date", "code", "rank", "paper_weight", "paper_only_selected", "selection_status", "strategy", "reason"}, + ), + "REALIZED_RETURNS": ( + "realized_returns.csv", + {"date", "split", "paper_nav", "realized_return", "policy_reward", "current_drawdown", "evidence_status", "numeric_error", "source"}, + ), + "DRIFT": ( + "drift.csv", + {"metric", "value", "reference", "status", "action"}, + ), + "DRAWDOWN": ( + "drawdown.csv", + {"date", "split", "paper_nav", "paper_forward_drawdown", "computed_drawdown", "evidence_status", "numeric_error", "source"}, + ), +} + + +def _read_registry_json_artifact(path: Path, *, invalid_error: str, missing_error: str | None = None) -> tuple[dict[str, Any], list[str]]: + if not path.exists(): + return {}, [missing_error] if missing_error else [] + try: + payload = _load_json(path) + except (json.JSONDecodeError, OSError, UnicodeDecodeError): + return {}, [invalid_error] + if not isinstance(payload, dict): + return {}, [invalid_error] + return payload, [] + + +def _read_registry_decision_log(path: Path, limit: int) -> tuple[list[dict[str, Any]], list[str]]: + errors: list[str] = [] + rows: list[dict[str, Any]] = [] + if not path.exists(): + return rows, ["REGISTRY_EVIDENCE_DECISION_LOG_MISSING"] + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeDecodeError): + return rows, ["REGISTRY_DECISION_LOG_JSONL_INVALID"] + if not any(line.strip() for line in lines): + return rows, ["REGISTRY_EVIDENCE_DECISION_LOG_EMPTY"] + for line in lines: + if not line.strip(): + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + errors.append("REGISTRY_DECISION_LOG_JSONL_INVALID") + break + if not isinstance(payload, dict): + errors.append("REGISTRY_DECISION_LOG_ROW_INVALID") + continue + if len(rows) < limit: + rows.append(payload) + return rows, errors + + +def _safe_read_registry_csv_rows(path: Path, limit: int) -> list[dict[str, Any]]: + try: + return _read_csv_rows(path, limit) + except (csv.Error, OSError, UnicodeDecodeError): + return [] + + + +def _registry_csv_evidence_errors(run_dir: Path) -> list[str]: + errors: list[str] = [] + for label, (filename, required_columns) in REGISTRY_REQUIRED_CSV_EVIDENCE.items(): + path = run_dir / filename + if not path.exists(): + errors.append(f"REGISTRY_EVIDENCE_{label}_MISSING") + continue + try: + with path.open("r", encoding="utf-8", newline="") as handle: + reader = csv.DictReader(handle) + columns = set(reader.fieldnames or []) + has_header = bool(columns) + has_row = any(True for _ in reader) + except (csv.Error, OSError, UnicodeDecodeError): + errors.append(f"REGISTRY_EVIDENCE_{label}_INVALID") + continue + if not has_header: + errors.append(f"REGISTRY_EVIDENCE_{label}_HEADER_MISSING") + elif not required_columns.issubset(columns): + errors.append(f"REGISTRY_EVIDENCE_{label}_COLUMNS_INVALID") + if not has_row: + errors.append(f"REGISTRY_EVIDENCE_{label}_EMPTY") + return errors + +def load_registry_latest(*, run: str | None = None, sample_limit: int = 25) -> dict[str, Any]: + run_dir = _latest_run_dir(DEFAULT_DAILY_REGISTRY_ROOT, required_file="registry_manifest.json", run_id=run) + if run_dir is None: + return load_not_started_surface("registry") + safe_limit = _bounded_limit(sample_limit, default=25, maximum=500) + manifest, manifest_errors = _read_registry_json_artifact( + run_dir / "registry_manifest.json", + invalid_error="REGISTRY_MANIFEST_JSON_INVALID", + missing_error="REGISTRY_MANIFEST_JSON_MISSING", + ) + candidate_registry, candidate_errors = _read_registry_json_artifact( + run_dir / "candidate_registry.json", + invalid_error="REGISTRY_CANDIDATE_REGISTRY_JSON_INVALID", + missing_error="REGISTRY_CANDIDATE_REGISTRY_MISSING", + ) + decision_rows, decision_errors = _read_registry_decision_log(run_dir / "decision_log.jsonl", safe_limit) + invariant_errors = sorted( + dict.fromkeys( + [ + *manifest_errors, + *candidate_errors, + *_registry_artifact_invariant_errors(manifest, candidate_registry), + *_registry_csv_evidence_errors(run_dir), + *decision_errors, + ] + ) + ) + safe_guardrail = "D8/D9 registry is research-only paper-forward planning evidence; no live/broker/orders, no profit claim, no deployable model claim." + if invariant_errors: + manifest = { + **manifest, + "status": "BLOCKED_UNSAFE_REGISTRY_ARTIFACT", + "promotion_status": "BLOCKED_UNSAFE_REGISTRY_ARTIFACT", + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + } + candidates = candidate_registry.get("candidates") + if isinstance(candidates, list): + candidate_registry = { + **candidate_registry, + "candidates": [ + { + **candidate, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "promotion_status": "BLOCKED_UNSAFE_REGISTRY_ARTIFACT", + } + if isinstance(candidate, dict) + else candidate + for candidate in candidates + ], + } + candidate_registry["invariant_errors"] = invariant_errors + return { + **manifest, + "status": manifest.get("status") or "RESEARCH_ONLY_BLOCKED", + "artifact_status": "LOADED_GENERATED_ARTIFACT", + "artifact_dir": str(run_dir), + "read_only": True, + "read_only_dashboard_note": "GET-only D8/D9 registry and paper-forward ledger evidence; no live/broker/order/profit action from dashboard.", + "candidate_registry": candidate_registry, + "samples": { + "paper_selected": _safe_read_registry_csv_rows(run_dir / "paper_selected.csv", safe_limit), + "realized_returns": _safe_read_registry_csv_rows(run_dir / "realized_returns.csv", safe_limit), + "drift": _safe_read_registry_csv_rows(run_dir / "drift.csv", safe_limit), + "drawdown": _safe_read_registry_csv_rows(run_dir / "drawdown.csv", safe_limit), + "decision_log": decision_rows, + }, + "guardrail": safe_guardrail, + "invariant_errors": invariant_errors, + } + + +def load_prediction_chart(*, run: str | None = None) -> dict[str, Any]: + prediction = load_prediction_latest(run=run, sample_limit=25) + if prediction.get("status") == "NOT_STARTED": + return prediction + metrics = list(prediction.get("baseline_metrics") or []) + return { + "status": prediction.get("status"), + "run_id": prediction.get("run_id"), + "best_strategy": (prediction.get("verdict") or {}).get("best_strategy_by_total_net_return"), + "go_summary_allowed": (prediction.get("verdict") or {}).get("go_summary_allowed"), + "model_build_allowed": prediction.get("model_build_allowed", False), + "readiness_status": prediction.get("readiness_status") or D3_BLOCKED_READINESS_STATUS, + "price_basis": prediction.get("price_basis"), + "universe_review_status": prediction.get("universe_review_status"), + "cost_assumption_round_trip_bp": prediction.get("cost_assumption_round_trip_bp"), + "baseline_delta_summary": prediction.get("baseline_delta_summary") or {}, + "d3_gate_blockers": prediction.get("d3_gate_blockers") or [], + "d3_blocked_uses": prediction.get("d3_blocked_uses") or [], + "d3_user_guidance": prediction.get("d3_user_guidance") or [], + "baseline_freeze_contract": prediction.get("baseline_freeze_contract") or {}, + "artifact_hashes": prediction.get("artifact_hashes") or {}, + "baseline_series": [ + { + "strategy": row.get("strategy"), + "strategy_family": row.get("strategy_family"), + "total_net_return": row.get("total_net_return"), + "delta_vs_cash_total_net_return": row.get("delta_vs_cash_total_net_return"), + "delta_vs_shuffle_control_total_net_return": row.get("delta_vs_shuffle_control_total_net_return"), + "delta_vs_best_rule_baseline_total_net_return": row.get("delta_vs_best_rule_baseline_total_net_return"), + "max_drawdown": row.get("max_drawdown"), + "mean_turnover": row.get("mean_turnover"), + "hit_rate": row.get("hit_rate"), + } + for row in metrics + ], + "calibration": (prediction.get("samples") or {}).get("calibration") or [], + "guardrail": "D3 baseline/ranker evidence only; not a profit, broker, live, order, or trained-RL readiness claim.", + } + + +def load_portfolio_chart(*, run: str | None = None) -> dict[str, Any]: + portfolio = load_portfolio_latest(run=run, sample_limit=25) + if portfolio.get("status") == "NOT_STARTED": + return portfolio + samples = portfolio.get("samples") or {} + telemetry = portfolio.get("telemetry") or {} + return { + "status": portfolio.get("status"), + "run_id": portfolio.get("run_id"), + "ui_badge": (portfolio.get("verdict") or {}).get("ui_badge"), + "go_summary_allowed": (portfolio.get("verdict") or {}).get("go_summary_allowed"), + "model_build_allowed": portfolio.get("model_build_allowed", False), + "readiness_status": portfolio.get("readiness_status") or (portfolio.get("verdict") or {}).get("readiness_status"), + "paper_forward_allowed": portfolio.get("paper_forward_allowed", False), + "live_broker_order_allowed": portfolio.get("live_broker_order_allowed", False), + "implementation_unlocked": (portfolio.get("verdict") or {}).get("implementation_unlocked"), + "gate_dependency": (portfolio.get("verdict") or {}).get("gate_dependency"), + "prediction_manifest_sha": portfolio.get("prediction_manifest_sha"), + "prediction_artifact_hashes": portfolio.get("prediction_artifact_hashes") or {}, + "prediction_artifact_hash_mismatches": portfolio.get("prediction_artifact_hash_mismatches") or [], + "artifact_hashes": portfolio.get("artifact_hashes") or {}, + "baseline_comparison": portfolio.get("baseline_comparison") or {}, + "metrics": (portfolio.get("policy_metrics") or {}).get("metrics") or [], + "reward_sample": samples.get("reward_breakdown") or [], + "training_status": telemetry.get("training_status"), + "telemetry": telemetry, + "learning_curve": samples.get("learning_curve") or [], + "reward_component_summary": portfolio.get("reward_component_summary") or {}, + "observation_manifest": portfolio.get("observation_manifest") or {}, + "observation_manifest_validation": portfolio.get("observation_manifest_validation") or {}, + "state_observations": samples.get("state_observations") or [], + "invalid_actions": samples.get("invalid_actions") or [], + "action_distribution": samples.get("action_distribution") or [], + "turnover_series": samples.get("turnover") or [], + "drawdown_series": samples.get("drawdown") or [], + "policy_evaluation": portfolio.get("policy_evaluation") or {}, + "portfolio_trajectory": samples.get("policy_nav") or [], + "reward_stack": (portfolio.get("reward_component_summary") or {}).get("by_split") or [], + "policy_baseline_comparison": samples.get("policy_baseline_comparison") or [], + "policy_nav": samples.get("policy_nav") or [], + "guardrail": "D4 constrained portfolio RL telemetry is RESEARCH_ONLY diagnostics; no profit, broker, live, order, or deployable readiness claim.", + } + + +def load_walk_forward_chart(*, run: str | None = None) -> dict[str, Any]: + gate = load_walk_forward_latest(run=run, sample_limit=50) + db = load_daily_db_summary(table_limit=0, flag_limit=0, window_limit=0) + universe = load_universe_preview(limit=0) + prediction = load_prediction_latest(sample_limit=0) + if gate.get("status") == "NOT_STARTED": + return gate + verdict = gate.get("verdict") or {} + samples = gate.get("samples") or {} + fold_metrics = samples.get("fold_metrics") or [] + selected_strategy = verdict.get("selected_strategy") + cost_rows = samples.get("cost_sensitivity") or [] + cost_bps = _cost_bp_values(cost_rows, selected_strategy=selected_strategy) + d5_reasons = _as_string_list(verdict.get("reasons")) + if any(bp not in set(cost_bps) for bp in D5_REQUIRED_COST_BPS): + d5_reasons = _merge_string_lists(d5_reasons, ["D5_COST_SENSITIVITY_INCOMPLETE"]) + + no_oos_retuning = verdict.get("no_oos_retuning") is True + if not no_oos_retuning: + d5_reasons = _merge_string_lists(d5_reasons, ["D5_NO_OOS_RETUNING_NOT_PROVEN"]) + + purge_days = _finite_int(verdict.get("purge_days")) + embargo_days = _finite_int(verdict.get("embargo_days")) + min_required_purge_days = max(_finite_int(verdict.get("min_required_purge_days")) or D5_MIN_REQUIRED_PURGE_DAYS, D5_MIN_REQUIRED_PURGE_DAYS) + min_required_embargo_days = max(_finite_int(verdict.get("min_required_embargo_days")) or D5_MIN_REQUIRED_EMBARGO_DAYS, D5_MIN_REQUIRED_EMBARGO_DAYS) + if purge_days is None or purge_days < min_required_purge_days: + d5_reasons = _merge_string_lists(d5_reasons, ["PURGE_DAYS_BELOW_REQUIRED_MIN"]) + if embargo_days is None or embargo_days < min_required_embargo_days: + d5_reasons = _merge_string_lists(d5_reasons, ["EMBARGO_DAYS_BELOW_REQUIRED_MIN"]) + + d4_state_contract = gate.get("d4_state_contract") if isinstance(gate.get("d4_state_contract"), dict) else {} + d4_row_counts = d4_state_contract.get("row_counts") if isinstance(d4_state_contract.get("row_counts"), dict) else {} + d4_state_contract_status = d4_state_contract.get("status") + d4_observation_manifest_gate = d4_state_contract.get("gate") + d4_observation_manifest_validation_status = d4_state_contract.get("observation_manifest_validation_status") + d4_state_observation_rows = d4_row_counts.get("state_observations") + d4_reward_action_ablation_rows = d4_row_counts.get("reward_action_ablations") + d4_source_hash_count = d4_state_contract.get("source_hash_count") or d4_row_counts.get("source_hashes") + d4_state_contract_artifacts_consumed = ( + verdict.get("d4_state_contract_artifacts_consumed") is True + and d4_state_contract_status == "PASS" + and d4_observation_manifest_gate == "D4_OBSERVATION_STATE_MANIFEST" + and d4_observation_manifest_validation_status == "PASS" + and _positive_number(d4_state_observation_rows) + and _positive_number(d4_reward_action_ablation_rows) + and _positive_number(d4_source_hash_count) + ) + d4_artifact_issues = _as_string_list(verdict.get("d4_artifact_issues")) + if not d4_state_contract_artifacts_consumed: + d4_artifact_issues = _merge_string_lists(["D4_STATE_CONTRACT_EVIDENCE_MISSING_OR_STALE"], d4_artifact_issues) + d5_reasons = _merge_string_lists(d5_reasons, d4_artifact_issues) + effective_gate = _effective_daily_model_gate( + db=db, + universe=universe, + prediction=prediction, + gate_verdict=verdict, + gate_status=gate.get("status"), + ) + return { + "status": gate.get("status"), + "readiness_status": gate.get("readiness_status") or D5_RESEARCH_READINESS_STATUS, + "run_id": gate.get("run_id"), + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "selected_strategy": verdict.get("selected_strategy"), + "strategy_selection_policy": verdict.get("strategy_selection_policy"), + "n_folds": verdict.get("n_folds"), + "required_min_folds": verdict.get("required_min_folds"), + "no_oos_retuning": no_oos_retuning, + "purge_days": purge_days, + "embargo_days": embargo_days, + "min_required_purge_days": min_required_purge_days, + "min_required_embargo_days": min_required_embargo_days, + "fold_consistency": verdict.get("fold_consistency"), + "reasons": d5_reasons, + "effective_gate_blockers": effective_gate["effective_gate_blockers"], + "price_basis": verdict.get("price_basis"), + "universe_review_status": verdict.get("universe_review_status"), + "cost_sensitivity_bp": cost_bps, + "d4_state_contract": d4_state_contract, + "d4_state_contract_status": d4_state_contract_status, + "d4_observation_manifest_gate": d4_observation_manifest_gate, + "d4_observation_manifest_validation_status": d4_observation_manifest_validation_status, + "d4_reward_action_telemetry_sufficient_for_d4": verdict.get( + "d4_reward_action_telemetry_sufficient_for_d4" + ), + "d4_state_contract_artifacts_consumed": d4_state_contract_artifacts_consumed, + "d4_state_observation_rows": d4_state_observation_rows, + "d4_artifact_issues": d4_artifact_issues, + "d4_reward_action_ablation_rows": d4_reward_action_ablation_rows, + "d4_source_hash_count": d4_source_hash_count, + "prediction_manifest_sha": gate.get("prediction_manifest_sha"), + "prediction_artifact_hashes": gate.get("prediction_artifact_hashes") or verdict.get("prediction_artifact_hashes") or {}, + "portfolio_manifest_sha": gate.get("portfolio_manifest_sha"), + "portfolio_artifact_hashes": gate.get("portfolio_artifact_hashes") or verdict.get("portfolio_artifact_hashes") or {}, + "artifact_hashes": gate.get("artifact_hashes") or {}, + "fold_metrics": fold_metrics, + "fold_windows": samples.get("folds") or [], + "fold_assignments": samples.get("fold_assignments") or [], + "no_trade_control": [row for row in fold_metrics if row.get("strategy") == "no_trade_cash"], + "selected_fold_metrics": [ + row + for row in fold_metrics + if row.get("strategy") == verdict.get("selected_strategy") and row.get("control") == "actual" + ], + "shuffle_control": samples.get("shuffle_control") or [], + "cost_sensitivity": samples.get("cost_sensitivity") or [], + "rl_fold_metrics": samples.get("rl_fold_metrics") or [], + "guardrail": "D5 walk-forward gate evidence only; NO-GO/WATCH reasons do not imply live or profit readiness.", + } + + +def load_decision_cockpit() -> dict[str, Any]: + dataset = load_dataset_latest(sample_limit=0) + db = load_daily_db_summary(table_limit=0, flag_limit=0, window_limit=0) + universe = load_universe_preview(limit=0) + prediction = load_prediction_latest(sample_limit=0) + portfolio = load_portfolio_latest(sample_limit=0) + gate = load_walk_forward_latest(sample_limit=0) + gate_verdict = gate.get("verdict") or {} + portfolio_verdict = portfolio.get("verdict") or {} + prediction_verdict = prediction.get("verdict") or {} + effective_gate = _effective_daily_model_gate( + db=db, + universe=universe, + prediction=prediction, + gate_verdict=gate_verdict, + gate_status=gate.get("status"), + ) + + blockers: list[dict[str, Any]] = [] + if dataset.get("price_basis") == "unknown" or prediction.get("price_basis") == "unknown": + blockers.append( + { + "id": "PRICE_BASIS_UNKNOWN", + "severity": "watch", + "title": "가격 보정 기준 미확정", + "evidence": "daily OHLCV price_basis=unknown; split/dividend adjusted/raw status is not proven.", + "required_fix": "공식 adjusted/raw 기준과 분할·배당 처리 검증을 고정한 뒤 fresh validation을 다시 실행.", + } + ) + if dataset.get("universe_verdict") == "WATCH_HEURISTIC_UNIVERSE": + blockers.append( + { + "id": "UNIVERSE_WATCH_HEURISTIC", + "severity": "watch", + "title": "유니버스가 휴리스틱 WATCH", + "evidence": "KOSPI/KOSDAQ 보통주 분류는 적용됐지만 KRX/수동 검토 전까지 WATCH.", + "required_fix": "ETF/ETN/스팩/우선주/리츠 제외 기준을 공식 메타데이터로 재검증.", + } + ) + for reason in gate_verdict.get("reasons") or []: + if reason == "RL_POLICY_UNDERPERFORMS_D3_BASELINE": + blockers.append( + { + "id": reason, + "severity": "block", + "title": "RL 정책이 D3 베이스라인보다 약함", + "evidence": f"D4 delta_vs_best_d3={((portfolio.get('baseline_comparison') or {}).get('delta_vs_best_d3_total_net_return'))}", + "required_fix": "새 RL 후보는 D3 baseline과 shuffle/no-trade를 23bp 비용 후 fresh OOS에서 동시에 넘어야 함.", + } + ) + elif reason == "D4_RL_RESEARCH_ONLY_LOCK": + blockers.append( + { + "id": reason, + "severity": "block", + "title": "D4 RL은 연구 전용 잠금", + "evidence": f"D4 status={portfolio.get('status')} implementation_unlocked={portfolio_verdict.get('implementation_unlocked')}", + "required_fix": "D4 정책을 선택에 사용하지 않은 fresh OOS/forward 검증 통과 전에는 모델 빌드 잠금 유지.", + } + ) + elif reason not in {row["id"] for row in blockers}: + blockers.append( + { + "id": reason, + "severity": "block" if gate.get("status") == "NO-GO" else "watch", + "title": reason, + "evidence": "D5 gate_verdict reason.", + "required_fix": "사전등록된 검증 조건으로 원인 해소 후 재검증.", + } + ) + for reason in effective_gate["effective_gate_blockers"]: + if reason not in {row["id"] for row in blockers}: + blockers.append( + { + "id": reason, + "severity": "block", + "title": reason, + "evidence": "Cross-stage effective model gate remains locked even if a generated artifact is optimistic.", + "required_fix": "D0 price basis, D1 universe review, D3 baseline, and D5 walk-forward gates must all pass before model build or GO summary.", + } + ) + + return { + "status": gate.get("status", "NOT_STARTED"), + "overall_status": "MODEL_BUILD_LOCKED_NO_GO" if not effective_gate["model_build_allowed"] else "REVIEW_REQUIRED", + "model_build_allowed": effective_gate["model_build_allowed"], + "go_summary_allowed": effective_gate["go_summary_allowed"], + "cards": [ + {"id": "D3", "label": "Baseline/Ranker", "status": prediction.get("status"), "severity": _status_severity(prediction.get("status")), "evidence": prediction_verdict.get("best_strategy_by_total_net_return")}, + {"id": "D4", "label": "Portfolio RL", "status": portfolio.get("status"), "severity": _status_severity(portfolio.get("status")), "evidence": portfolio_verdict.get("gate_dependency")}, + {"id": "D5", "label": "Walk-forward Gate", "status": gate.get("status"), "severity": _status_severity(gate.get("status")), "evidence": ",".join((gate_verdict.get("reasons") or [])[:3])}, + {"id": "LOCK", "label": "Model Build Lock", "status": "LOCKED", "severity": "block", "evidence": "model_build_allowed=false; go_summary_allowed=false"}, + ], + "blockers": blockers, + "usage_guide": [_usage_for_stage("D6"), _usage_for_stage("D7")], + "can_do_after_development": [ + "D6에서 D0-D9 증거, gate blocker, provenance hash, 비용/통제군 결과를 한 화면에서 읽는다.", + "D7에서 feature/regime/correlation/failure 진단 artifact가 생기면 실패 원인을 분해한다.", + "Symbol drilldown으로 선행 0 코드 유지, OHLCV 기간, 가격 기준 unknown 경고를 확인한다.", + ], + "guardrail": "Decision cockpit is a lock explanation, not a profit/live/broker/order readiness claim.", + } + + +def _cost_sensitivity_summary_by_cost(rows: Any) -> dict[int, dict[str, Any]]: + grouped: dict[int, list[dict[str, Any]]] = {} + for row in rows if isinstance(rows, list) else []: + if not isinstance(row, dict): + continue + cost = _finite_float(row.get("cost_bp", row.get("cost_bps"))) + if cost is None: + continue + grouped.setdefault(int(round(cost)), []).append(row) + + summary: dict[int, dict[str, Any]] = {} + for cost_bp, cost_rows in grouped.items(): + total_returns = [_finite_float(row.get("total_net_return")) for row in cost_rows] + drawdowns = [_finite_float(row.get("max_drawdown")) for row in cost_rows] + turnovers = [_finite_float(row.get("mean_turnover")) for row in cost_rows] + finite_returns = [value for value in total_returns if value is not None] + finite_drawdowns = [value for value in drawdowns if value is not None] + finite_turnovers = [value for value in turnovers if value is not None] + summary[cost_bp] = { + "fold_rows": len(cost_rows), + "mean_total_net_return": sum(finite_returns) / len(finite_returns) if finite_returns else None, + "worst_total_net_return": min(finite_returns) if finite_returns else None, + "worst_max_drawdown": min(finite_drawdowns) if finite_drawdowns else None, + "mean_turnover": sum(finite_turnovers) / len(finite_turnovers) if finite_turnovers else None, + } + return summary + + +def load_scenario_lab() -> dict[str, Any]: + """Generate read-only assumption/scenario cards from current Daily OHLCV evidence.""" + + db = load_daily_db_summary(table_limit=0, flag_limit=0, window_limit=0) + universe = load_universe_preview(limit=0) + prediction = load_prediction_latest(sample_limit=0) + portfolio = load_portfolio_latest(sample_limit=0) + gate = load_walk_forward_latest(sample_limit=0) + gate_chart = load_walk_forward_chart() + + gate_verdict = gate.get("verdict") or {} + effective_gate = _effective_daily_model_gate( + db=db, + universe=universe, + prediction=prediction, + gate_verdict=gate_verdict, + gate_status=gate.get("status"), + ) + base_blockers = list(effective_gate["effective_gate_blockers"]) + cost_summary = _cost_sensitivity_summary_by_cost(gate_chart.get("cost_sensitivity")) + + d5_reasons = [str(reason) for reason in (gate_verdict.get("reasons") or [])] + actual_costs = [] + for cost in gate_chart.get("cost_sensitivity_bp") or D5_REQUIRED_COST_BPS: + parsed_cost = _finite_int(cost) + if parsed_cost is not None: + actual_costs.append(parsed_cost) + costs = sorted(set(actual_costs or list(D5_REQUIRED_COST_BPS))) + + scenario_rows: list[dict[str, Any]] = [] + assumption_modes = [ + { + "id": "current_evidence", + "label": "현재 증거 기준", + "assumption_changes": [], + "remove_blockers": set(), + "status": gate.get("status", "NO-GO"), + }, + { + "id": "data_repaired_hypothesis", + "label": "D0/D1 수리 가정", + "assumption_changes": [ + "price_basis_verified_by_artifact", + "universe_official_or_manual_reviewed", + ], + "remove_blockers": {"D0_PRICE_BASIS_NOT_VERIFIED", "D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED"}, + "status": "HYPOTHESIS_ONLY", + }, + ] + for cost_bp in costs: + for mode in assumption_modes: + blockers = [blocker for blocker in base_blockers if blocker not in mode["remove_blockers"]] + for reason in d5_reasons: + if reason not in blockers: + blockers.append(reason) + if int(cost_bp) != 23: + blockers.append("COST_COUNTERFACTUAL_NOT_PRIMARY_23BP") + summary = cost_summary.get(int(cost_bp), {}) + scenario_rows.append( + { + "scenario_id": f"cost_{int(cost_bp)}bp_{mode['id']}", + "scenario_family": "evidence_assumption_grid", + "label": f"{mode['label']} · {int(cost_bp)}bp", + "cost_bps": int(cost_bp), + "purge_days": gate_chart.get("purge_days"), + "embargo_days": gate_chart.get("embargo_days"), + "min_required_purge_days": D5_MIN_REQUIRED_PURGE_DAYS, + "min_required_embargo_days": D5_MIN_REQUIRED_EMBARGO_DAYS, + "status": mode["status"], + "readiness_status": "SCENARIO_RESEARCH_ONLY_NO_MODEL_BUILD", + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "assumption_changes": mode["assumption_changes"], + "blocking_reasons": blockers, + "observed_cost_summary": summary, + "required_next_artifacts": [ + "fresh_oos_walk_forward_manifest.json", + "gate_verdict.json", + "baseline_delta_summary.json", + ], + "blocked_uses": [ + "model_build_or_candidate_promotion", + "paper_forward_or_live_readiness_claims", + "live_broker_order", + ], + } + ) + + scenario_rows.append( + { + "scenario_id": "model_generated_candidate_contract", + "scenario_family": "model_generation_contract", + "label": "새 후보 모델 생성 계약", + "cost_bps": 23, + "purge_days": D5_MIN_REQUIRED_PURGE_DAYS, + "embargo_days": D5_MIN_REQUIRED_EMBARGO_DAYS, + "min_required_purge_days": D5_MIN_REQUIRED_PURGE_DAYS, + "min_required_embargo_days": D5_MIN_REQUIRED_EMBARGO_DAYS, + "status": "HYPOTHESIS_ONLY", + "readiness_status": "MODEL_SCENARIO_GENERATION_CONTRACT_ONLY", + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "assumption_changes": [ + "generate_candidate_from_registered_hypothesis", + "compare_against_no_trade_shuffle_rule_supervised_baselines", + "evaluate_0_23_46bp_costs_with_fresh_oos", + ], + "blocking_reasons": [ + *base_blockers, + "MODEL_CANDIDATE_NOT_GENERATED", + "FRESH_OOS_WALK_FORWARD_REQUIRED", + "NEGATIVE_AND_BASELINE_CONTROLS_REQUIRED", + ], + "observed_cost_summary": cost_summary.get(23, {}), + "required_next_artifacts": [ + "scenario_manifest.json", + "candidate_generation_config.json", + "fresh_oos_walk_forward_manifest.json", + "cost_sensitivity.csv", + "negative_controls.csv", + ], + "blocked_uses": [ + "model_build_or_candidate_promotion", + "paper_forward_or_live_readiness_claims", + "live_broker_order", + ], + } + ) + + return { + "mode": "daily_ohlcv_scenario_lab", + "platform_stage": "SCENARIO_GENERATOR_MVP", + "scenario_generation_available": True, + "model_run_generation_available": False, + "read_only": True, + "status": "RESEARCH_ONLY", + "scenario_count": len(scenario_rows), + "assumption_dimensions": { + "cost_bps": costs, + "purge_days_min": D5_MIN_REQUIRED_PURGE_DAYS, + "embargo_days_min": D5_MIN_REQUIRED_EMBARGO_DAYS, + "data_guardrail_modes": ["current_evidence", "data_repaired_hypothesis"], + "required_controls": ["no_trade", "shuffle_control", "rule_baseline", "supervised_baseline"], + }, + "current_evidence": { + "d0_price_basis": db.get("price_basis"), + "d1_universe_verdict": universe.get("verdict"), + "d3_status": prediction.get("status"), + "d4_status": portfolio.get("status"), + "d5_status": gate.get("status"), + "readiness_status": gate.get("readiness_status"), + "effective_gate_blockers": base_blockers, + }, + "model_input_contract": { + "allowed_inputs": [ + "registered_hypothesis", + "feature_set_manifest_sha", + "split_manifest_sha", + "cost_grid_bps", + "baseline_control_manifest_sha", + ], + "required_outputs": [ + "scenario_manifest.json", + "candidate_generation_config.json", + "fresh_oos_walk_forward_manifest.json", + "gate_verdict.json", + ], + "must_not_generate": [ + "profit_guarantee", + "live_order", + "broker_readiness", + "paper_forward_unlock", + ], + }, + "scenario_rows": scenario_rows, + "guardrail": "Scenario Lab generates research-only assumptions from evidence; it is not a profit/live/broker/order or model-build readiness claim.", + } + + +def _latest_manifest_paths(root: Path, manifest_name: str, *, limit: int) -> list[Path]: + safe_limit = _bounded_limit(limit, default=25, maximum=MAX_LIMIT) + if safe_limit == 0: + return [] + root = Path(root).resolve() + if not root.exists(): + return [] + manifests = [ + path / manifest_name + for path in root.iterdir() + if path.is_dir() and (path / manifest_name).is_file() + ] + return sorted(manifests, key=lambda path: path.stat().st_mtime, reverse=True)[:safe_limit] + + +def _scenario_run_ledger_row(path: Path) -> dict[str, Any]: + manifest = _load_json(path) + gate = manifest.get("gate_verdict_summary") or {} + return { + "run_id": manifest.get("run_id") or path.parent.name, + "generated_at": manifest.get("generated_at"), + "status": manifest.get("status", "NO-GO"), + "readiness_status": manifest.get("readiness_status", "D5_NO_GO_RESEARCH_ONLY_GATE"), + "selected_strategy": gate.get("selected_strategy"), + "n_folds": gate.get("n_folds"), + "purge_days": gate.get("purge_days"), + "embargo_days": gate.get("embargo_days"), + "cost_sensitivity_bp": gate.get("cost_sensitivity_bp") or [0, 23, 46], + "blocking_reasons": gate.get("reasons", []), + "artifact_paths": manifest.get("artifact_paths", {}), + "artifact_dirs": manifest.get("artifact_dirs", {}), + "config": manifest.get("config", {}), + "guardrail": manifest.get("guardrail", RESEARCH_GUARDRAIL), + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + } + + +def _scenario_batch_ledger_row(path: Path, *, row_limit: int) -> dict[str, Any]: + manifest = _load_json(path) + comparison_rows = manifest.get("comparison_rows") if isinstance(manifest.get("comparison_rows"), list) else [] + safe_row_limit = _bounded_limit(row_limit, default=25, maximum=MAX_LIMIT) + return { + "batch_id": manifest.get("batch_id") or path.parent.name, + "generated_at": manifest.get("generated_at"), + "status": manifest.get("status", "RESEARCH_ONLY"), + "platform_stage": manifest.get("platform_stage", "SCENARIO_BATCH_RUNNER_MVP"), + "scenario_count": manifest.get("scenario_count", len(comparison_rows)), + "completed_count": manifest.get("completed_count"), + "failed_count": manifest.get("failed_count"), + "gate_status_counts": manifest.get("gate_status_counts", {}), + "comparison_rows_total": len(comparison_rows), + "comparison_rows_truncated": len(comparison_rows) > safe_row_limit, + "comparison_rows": comparison_rows[:safe_row_limit], + "artifact_paths": manifest.get("artifact_paths", {}), + "guardrail": manifest.get("guardrail", RESEARCH_GUARDRAIL), + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + } + + +def load_scenario_run_ledger(limit: int = 25) -> dict[str, Any]: + """Read generated single-run and batch scenario manifests for dashboard comparison.""" + + safe_limit = _bounded_limit(limit, default=25, maximum=MAX_LIMIT) + run_rows = [_scenario_run_ledger_row(path) for path in _latest_manifest_paths(DEFAULT_SCENARIO_ROOT, "scenario_manifest.json", limit=safe_limit)] + batch_rows = [ + _scenario_batch_ledger_row(path, row_limit=safe_limit) + for path in _latest_manifest_paths(DEFAULT_SCENARIO_BATCH_ROOT, "scenario_batch_manifest.json", limit=safe_limit) + ] + return { + "mode": "daily_ohlcv_scenario_run_ledger", + "platform_stage": "SCENARIO_BATCH_RUNNER_MVP", + "read_only": True, + "dashboard_mutation_available": False, + "cli_model_run_generation_available": True, + "model_run_generation_available": True, + "status": "RESEARCH_ONLY", + "limit": safe_limit, + "scenario_run_count": len(run_rows), + "batch_count": len(batch_rows), + "runs": run_rows, + "batches": batch_rows, + "quick_start_commands": [ + "py -3.11 -m stom_rl.daily_scenario_batch --emit-template", + "py -3.11 -m stom_rl.daily_scenario_batch --plan scenario_batch_plan.json --batch-id scenario_batch_001 --overwrite", + "py -3.11 -m stom_rl.daily_scenario_runner --run-id scenario_single_001 --overwrite --max-symbols 8 --max-rows-per-symbol 120 --episodes 3 --candidate-limit 10 --max-positions 3 --n-folds 5 --top-k 10", + ], + "required_controls": ["no_trade", "shuffle_control", "rule_baseline", "supervised_baseline", "0/23/46bp cost sensitivity"], + "guardrail": "Read-only scenario run ledger. Runs are generated only by explicit CLI commands; no profit/live/broker/order or paper-forward readiness is inferred.", + } + + +RL_GUIDE_DISPLAY_CAPITAL_KRW = 10_000_000 + + +def _build_rl_guide_signal_quality_summary() -> dict[str, Any]: + run_dir = _latest_run_dir(DEFAULT_SIGNAL_QUALITY_ROOT, required_file="signal_quality_manifest.json") + manifest_path = run_dir / "signal_quality_manifest.json" if run_dir is not None else None + manifest = _load_json_if_exists(manifest_path) + + batch_dir = _latest_artifact_dir(DEFAULT_SIGNAL_QUALITY_BATCH_ROOT, required_file="scenario_batch_manifest.json") + batch_path = batch_dir / "scenario_batch_manifest.json" if batch_dir is not None else None + batch_manifest = _load_json_if_exists(batch_path) + + batch_runs = batch_manifest.get("runs") if isinstance(batch_manifest.get("runs"), list) else [] + plan = batch_manifest.get("plan") if isinstance(batch_manifest.get("plan"), dict) else {} + plan_scenarios = plan.get("scenarios") if isinstance(plan.get("scenarios"), list) else [] + scenario_cards: list[dict[str, Any]] = [] + source_rows = batch_runs if batch_runs else plan_scenarios + for row in source_rows[:8]: + if not isinstance(row, dict): + continue + scenario_cards.append( + { + "scenario_id": row.get("scenario_id"), + "run_id": row.get("run_id"), + "diagnostic_focus": row.get("diagnostic_focus"), + "hypothesis": row.get("hypothesis"), + "status": row.get("status") or "WATCH", + "promotion_status": row.get("promotion_status") or "NO-GO_RESEARCH_ONLY", + "assumption_tags": row.get("assumption_tags") or [], + "row_counts": row.get("row_counts") or manifest.get("row_counts") or {}, + } + ) + + required_artifacts = manifest.get("required_artifacts") if isinstance(manifest.get("required_artifacts"), dict) else {} + row_counts = manifest.get("row_counts") if isinstance(manifest.get("row_counts"), dict) else {} + return { + "schema_version": "daily_rl_signal_quality_summary.v1", + "status": manifest.get("status") or "MISSING_SIGNAL_QUALITY_AUDIT", + "run_id": manifest.get("run_id") or "MISSING_SIGNAL_QUALITY_AUDIT", + "generated_at": manifest.get("generated_at"), + "platform_stage": manifest.get("platform_stage") or "D3_D4_SIGNAL_QUALITY_AUDIT_MVP", + "result_verdict": manifest.get("result_verdict") or "WATCH_DIAGNOSTIC_ONLY", + "promotion_status": manifest.get("promotion_status") or "NO-GO_RESEARCH_ONLY", + "read_only_artifact": manifest.get("read_only_artifact", True), + "score_column": manifest.get("score_column") or "score_supervised_linear_ranker", + "threshold_policy": manifest.get("threshold_policy") or "frozen_absolute_no_quantile_search_no_oos_retune", + "cost_round_trip_bp": manifest.get("cost_round_trip_bp") or 23, + "cost_sensitivity_bp": manifest.get("cost_sensitivity_bp") or [0, 23, 46], + "baseline_controls": manifest.get("baseline_controls") or ["no_trade_cash", "shuffle_control", "equal_weight_topk", "frozen_d3_baseline"], + "baseline_controls_measured": bool(manifest.get("baseline_controls_measured")), + "row_counts": row_counts, + "splits": manifest.get("splits") or ["train", "val", "test"], + "fold_ids": manifest.get("fold_ids") or ["F01", "F02", "F03", "F04", "F05"], + "required_artifacts": required_artifacts, + "past_only_proxy_sources": manifest.get("past_only_proxy_sources") or {}, + "no_future_label_policy": manifest.get("no_future_label_policy") or "future_return_1d is evaluation_label_only after bucket/proxy construction", + "result_doc_path": _path_text(LATEST_SIGNAL_QUALITY_RESULT_DOC), + "governance_index_path": _path_text(LATEST_RESEARCH_GOVERNANCE_INDEX), + "manifest_path": _path_text(manifest_path), + "batch_manifest": { + "batch_id": batch_manifest.get("batch_id") or "MISSING_SIGNAL_QUALITY_BATCH", + "status": batch_manifest.get("status") or "MISSING_SIGNAL_QUALITY_BATCH", + "scenario_count": _to_int(batch_manifest.get("scenario_count"), len(scenario_cards)) or len(scenario_cards), + "completed_count": _to_int(batch_manifest.get("completed_count"), 0), + "failed_count": _to_int(batch_manifest.get("failed_count"), 0), + "gate_status_counts": batch_manifest.get("gate_status_counts") or {}, + "cost_sensitivity_bp": batch_manifest.get("cost_sensitivity_bp") or manifest.get("cost_sensitivity_bp") or [0, 23, 46], + "path": _path_text(batch_path), + }, + "scenario_cards": scenario_cards, + "limitations": [ + "Fold consistency is mixed: favorable folds cannot be cherry-picked into promotion.", + "Baseline controls are diagnostics, not deployable portfolio optimization.", + "Lagged drawdown proxies are past-only generated artifacts, not yet validated OHLCV market-regime data.", + "No D5/model-build/paper-forward/live gate is opened by this audit.", + ], + "next_allowed_research": "Preregistered past-only market-regime data quality audit before new D4 overlay tuning.", + "guardrail": "Signal-quality summary is a read-only research diagnostic. No profit claim, no live/broker/orders, no paper-forward/model-build unlock.", + } + + +def _build_rl_guide_scenario_generator(signal_summary: dict[str, Any]) -> dict[str, Any]: + controls = signal_summary.get("baseline_controls") or ["no_trade_cash", "shuffle_control", "equal_weight_topk", "frozen_d3_baseline"] + cost_sensitivity = signal_summary.get("cost_sensitivity_bp") or [0, 23, 46] + templates = [ + { + "template_id": "D3_D4_SIGNAL_QUALITY_AUDIT", + "title_ko": "D3/D4 신호 품질 감사", + "lane_id": "SIGNAL_QUALITY_AUDIT", + "status": "WATCH_DIAGNOSTIC_ONLY", + "hypothesis_ko": "score magnitude, margin, confidence가 다음날 연구 수익과 일관되게 연결되는지 확인합니다.", + "assumption_tags": ["signal_quality", "score_margin", "confidence", "no_retune"], + "required_artifacts": [ + "signal_quality_bucket_metrics.csv", + "signal_quality_rank_correlations.csv", + "baseline_control_metrics.csv", + "signal_quality_leakage_audit.json", + ], + "plan_json_draft": { + "schema_version": 1, + "kind": "daily_ohlcv_research_scenario_plan_draft", + "draft_only": True, + "template_id": "D3_D4_SIGNAL_QUALITY_AUDIT", + "default_cost_bp": 23, + "cost_sensitivity_bp": cost_sensitivity, + "baseline_controls": controls, + "scenarios": [ + {"scenario_id": "score_magnitude_audit_v2", "diagnostic_focus": "score_magnitude_bucket", "status": "DRAFT"}, + {"scenario_id": "score_margin_audit_v2", "diagnostic_focus": "score_margin_bucket", "status": "DRAFT"}, + {"scenario_id": "confidence_bucket_audit_v2", "diagnostic_focus": "d3_confidence_bucket", "status": "DRAFT"}, + ], + "guardrails": ["research_only", "no_live_broker_orders", "no_profit_claims", "D5_NO_GO_until_fresh_gates_pass"], + }, + }, + { + "template_id": "PAST_ONLY_MARKET_REGIME_AUDIT", + "title_ko": "Past-only 시장 국면 데이터 품질 감사", + "lane_id": "MARKET_REGIME_DATA_QUALITY", + "status": "NEXT_RECOMMENDED_RESEARCH", + "hypothesis_ko": "검증된 일봉 OHLCV에서 미래값 없이 volatility, drawdown, breadth, dispersion proxy를 만들 수 있는지 확인합니다.", + "assumption_tags": ["market_regime", "past_only", "data_quality", "no_future_label"], + "required_artifacts": [ + "price_basis_audit.csv", + "universe_breadth_diagnostics.csv", + "past_only_regime_proxy_metrics.csv", + "market_regime_leakage_audit.json", + ], + "plan_json_draft": { + "schema_version": 1, + "kind": "daily_ohlcv_research_scenario_plan_draft", + "draft_only": True, + "template_id": "PAST_ONLY_MARKET_REGIME_AUDIT", + "default_cost_bp": 23, + "cost_sensitivity_bp": cost_sensitivity, + "baseline_controls": controls, + "scenarios": [ + {"scenario_id": "price_basis_regime_audit_v1", "diagnostic_focus": "adjusted_raw_split_dividend_basis", "status": "DRAFT"}, + {"scenario_id": "breadth_missingness_audit_v1", "diagnostic_focus": "universe_breadth_and_missing_data", "status": "DRAFT"}, + {"scenario_id": "past_only_proxy_stability_v1", "diagnostic_focus": "volatility_drawdown_breadth_proxy_stability", "status": "DRAFT"}, + ], + "guardrails": ["research_only", "past_only_features", "no_oos_retune", "no_live_broker_orders", "no_profit_claims"], + }, + }, + { + "template_id": "D4_RL_OVERLAY_ABLATION", + "title_ko": "D4 RL 위험 Overlay 보상/행동 ablation", + "lane_id": "D4_RL_RISK_OVERLAY", + "status": "BLOCKED_UNTIL_DATA_AUDIT", + "hypothesis_ko": "D3 후보를 대체하지 않고 현금/감축/리밸런싱 overlay가 drawdown과 비용을 줄이는지 비교합니다.", + "assumption_tags": ["d4_rl_overlay", "reward_ablation", "risk_only", "d5_no_go_locked"], + "required_artifacts": [ + "state_observations.csv", + "reward_breakdown.csv", + "action_distribution.csv", + "walk_forward_gate_manifest.json", + ], + "plan_json_draft": { + "schema_version": 1, + "kind": "daily_ohlcv_research_scenario_plan_draft", + "draft_only": True, + "template_id": "D4_RL_OVERLAY_ABLATION", + "default_cost_bp": 23, + "cost_sensitivity_bp": cost_sensitivity, + "baseline_controls": controls, + "scenarios": [ + {"scenario_id": "baseline_relative_reward_v1", "diagnostic_focus": "reward_minus_frozen_d3_daily_return", "status": "DRAFT_BLOCKED"}, + {"scenario_id": "risk_only_overlay_reward_v1", "diagnostic_focus": "drawdown_turnover_concentration_penalties", "status": "DRAFT_BLOCKED"}, + ], + "guardrails": ["research_only", "no_live_broker_orders", "no_paper_forward", "D5_NO_GO_blocks_promotion"], + }, + }, + ] + return { + "schema_version": "daily_rl_scenario_generator.v1", + "status": "READ_ONLY_DRAFT_GENERATOR", + "read_only": True, + "execution_allowed": False, + "template_count": len(templates), + "default_template_id": "D3_D4_SIGNAL_QUALITY_AUDIT", + "export_contract": "copy_or_download_json_draft_only; run only through explicit preregistered CLI outside this read-only dashboard", + "templates": templates, + "guardrail": "Scenario generator emits fixed JSON drafts only. It does not run training, submit broker orders, unlock paper-forward, or claim profitability.", + } + + +def _build_rl_guide_market_regime_readiness(signal_summary: dict[str, Any]) -> dict[str, Any]: + signal_available = ( + signal_summary.get("status") == "COMPLETED_RESEARCH_ONLY" + and signal_summary.get("run_id") not in {None, "", "MISSING_SIGNAL_QUALITY_AUDIT"} + and bool(signal_summary.get("manifest_path")) + ) + readiness_checks = [ + { + "check": "signal_quality_artifacts_available", + "status": "PASS" if signal_available else "BLOCKED", + "completion_pct": 100 if signal_available else 0, + "evidence": signal_summary.get("manifest_path") or "MISSING_SIGNAL_QUALITY_AUDIT_MANIFEST", + }, + {"check": "price_basis_certified", "status": "BLOCKED", "completion_pct": 35, "evidence": "D0 price basis still requires adjusted/raw/split/dividend audit"}, + {"check": "universe_breadth_validated", "status": "WATCH", "completion_pct": 55, "evidence": "D1 universe remains heuristic/WATCH"}, + { + "check": "past_only_proxy_contract", + "status": "WATCH" if signal_available else "BLOCKED", + "completion_pct": 70 if signal_available else 0, + "evidence": "signal-quality audit emits past-only proxy source timing but not full OHLCV regime validation" if signal_available else "blocked until signal-quality artifacts exist", + }, + {"check": "promotion_gates_locked", "status": "PASS", "completion_pct": 100, "evidence": "D5/model-build/paper-forward/live remain NO-GO/locked"}, + ] + maturity_score = round( + sum((_to_float(row.get("completion_pct"), 0.0) or 0.0) for row in readiness_checks) / len(readiness_checks) + ) + return { + "schema_version": "daily_rl_market_regime_readiness.v1", + "status": "NEXT_RESEARCH_READY_FOR_PREREGISTRATION" if signal_available else "BLOCKED_MISSING_SIGNAL_QUALITY_AUDIT", + "maturity_score_pct": maturity_score, + "recommended_doc_path": "docs/stom_daily_ohlcv_market_regime_data_quality_prereg_2026-06-18.md", + "source_signal_quality_run_id": signal_summary.get("run_id") if signal_available else None, + "readiness_checks": readiness_checks, + "required_inputs": [ + "validated daily OHLCV price-basis manifest", + "universe breadth/missingness diagnostics", + "past-only volatility/drawdown/breadth/dispersion proxy definitions", + "train/val/test + fold windows with no retune", + "0/23/46bp cost sensitivity and no-trade/shuffle/frozen-D3 baselines", + ], + "blocked_gates": ["D0_PRICE_BASIS", "D1_UNIVERSE", "D5_NO_GO", "MODEL_BUILD_LOCKED", "PAPER_FORWARD_LOCKED", "LIVE_BROKER_ORDER_LOCKED"], + "ai_guidance_format": { + "next_research_lane": "past_only_market_regime_data_quality_audit", + "objective": "validate whether regime proxies are reliable enough to become D4 state candidates", + "must_not_use": ["future_return_1d_as_feature", "post_hoc_threshold_tuning", "single_fold_cherry_pick"], + "acceptance_gate": "documented preregistration + generated manifest + leakage audit + baseline controls", + }, + "score_inputs": readiness_checks, + "guardrail": "Readiness score means the next audit can be specified; it does not mean market-regime proxies are validated or tradable.", + } + + +def _build_rl_guide_improvement_queue(signal_summary: dict[str, Any]) -> dict[str, Any]: + items = [ + { + "id": "IQ001", + "priority": 1, + "title_ko": "Past-only market-regime data quality audit", + "source_limitation": "Signal-quality audit uses lagged generated drawdown proxies, not a fully validated OHLCV regime dataset.", + "next_action": "Create preregistered market-regime data-quality audit and generate price-basis/breadth/proxy stability artifacts.", + "required_artifacts": ["market_regime_prereg.md", "price_basis_audit.csv", "past_only_regime_proxy_metrics.csv", "market_regime_leakage_audit.json"], + "acceptance_gate": "No future-label features, split/fold no-retune, 0/23/46bp rows, no-trade/shuffle/frozen-D3 controls.", + "status": "NEXT_RECOMMENDED", + "blocker_status": "BLOCKED_D0_D1_DATA_GOVERNANCE", + }, + { + "id": "IQ002", + "priority": 2, + "title_ko": "Signal-quality result dashboard binding", + "source_limitation": "Users need latest verdict, row counts, and artifact links without opening raw manifests.", + "next_action": "Keep latest signal-quality summary and scenario comparison visible on Daily RL Guide.", + "required_artifacts": ["signal_quality_manifest.json", "scenario_batch_manifest.json", "dated_result_doc.md"], + "acceptance_gate": "Dashboard shows WATCH/NO-GO and locked promotion flags.", + "status": "IMPLEMENTED_ON_PAGE", + "blocker_status": "NO_PROMOTION_UNLOCKED", + }, + { + "id": "IQ003", + "priority": 3, + "title_ko": "Scenario plan JSON drafts", + "source_limitation": "Users need a fixed scenario format before asking AI agents to run more experiments.", + "next_action": "Use read-only plan draft templates and require CLI/preregistration before execution.", + "required_artifacts": ["scenario_plan_draft.json", "scenario_batch_manifest.json"], + "acceptance_gate": "Draft carries guardrails, cost, baselines, controls, and blocked live/broker/order flags.", + "status": "IMPLEMENTED_ON_PAGE", + "blocker_status": "READ_ONLY_DASHBOARD_NO_EXECUTION", + }, + { + "id": "IQ004", + "priority": 4, + "title_ko": "D4 overlay action/reward redesign", + "source_limitation": "Current D4 tabular Q overlay is weaker than the best D3 baseline and D5 remains NO-GO.", + "next_action": "Only after data-quality audit, preregister action/reward ablation for risk-only overlay candidates.", + "required_artifacts": ["state_observations.csv", "reward_breakdown.csv", "action_distribution.csv", "walk_forward_gate_manifest.json"], + "acceptance_gate": "Fold-consistent D3 delta, lower drawdown/turnover, cost stress survives, D5 no-retune gate passes.", + "status": "BLOCKED", + "blocker_status": "BLOCKED_D5_NO_GO", + }, + { + "id": "IQ005", + "priority": 5, + "title_ko": "Page maturity evidence loop", + "source_limitation": "Dashboard visuals must be scored separately from research promotion readiness.", + "next_action": "Maintain numeric page maturity, scenario-platform maturity, and live-readiness=0% reporting.", + "required_artifacts": ["daily_rl_env_guide_api_payload", "frontend build", "focused dashboard tests"], + "acceptance_gate": "Feature completion can reach 100% while live/paper/model readiness remains locked.", + "status": "IMPLEMENTED_ON_PAGE", + "blocker_status": "NO_LIVE_READINESS_BY_DESIGN", + }, + ] + return { + "schema_version": "daily_rl_ai_improvement_queue.v1", + "status": "AI_READABLE_QUEUE_AVAILABLE", + "source_run_id": signal_summary.get("run_id"), + "items": items, + "ai_guidance_format": { + "queue_policy": "highest_priority_unblocked_research_first", + "allowed_next_action": "write_preregistration_or_run_research_only_cli", + "blocked_actions": ["live_trading", "broker_order", "paper_forward_unlock", "profit_claim", "threshold_tuning_without_preregistration"], + }, + "guardrail": "Improvement queue is instruction metadata for research agents; it is not an execution order or trading signal.", + } + + +def _build_rl_guide_scenario_comparison(signal_summary: dict[str, Any]) -> dict[str, Any]: + batch = signal_summary.get("batch_manifest") if isinstance(signal_summary.get("batch_manifest"), dict) else {} + cards = signal_summary.get("scenario_cards") if isinstance(signal_summary.get("scenario_cards"), list) else [] + return { + "schema_version": "daily_rl_scenario_comparison.v1", + "status": "SCENARIO_COMPARISON_AVAILABLE", + "batch_id": batch.get("batch_id"), + "scenario_count": batch.get("scenario_count"), + "completed_count": batch.get("completed_count"), + "failed_count": batch.get("failed_count"), + "gate_status_counts": batch.get("gate_status_counts") or {}, + "cost_sensitivity_bp": batch.get("cost_sensitivity_bp") or signal_summary.get("cost_sensitivity_bp") or [0, 23, 46], + "cards": cards, + "columns": ["scenario_id", "diagnostic_focus", "status", "promotion_status", "row_counts", "hypothesis"], + "guardrail": "Scenario comparison exposes WATCH/NO-GO diagnostics only; PASS would still require fresh promotion gates.", + } + + +def _build_rl_guide_page_maturity_report( + *, + scenario_generator: dict[str, Any], + signal_summary: dict[str, Any], + market_regime_readiness: dict[str, Any], + improvement_queue: dict[str, Any], + scenario_comparison: dict[str, Any], +) -> dict[str, Any]: + templates = scenario_generator.get("templates") if isinstance(scenario_generator.get("templates"), list) else [] + queue_items = improvement_queue.get("items") if isinstance(improvement_queue.get("items"), list) else [] + comparison_cards = scenario_comparison.get("cards") if isinstance(scenario_comparison.get("cards"), list) else [] + batch = signal_summary.get("batch_manifest") if isinstance(signal_summary.get("batch_manifest"), dict) else {} + signal_available = signal_summary.get("status") == "COMPLETED_RESEARCH_ONLY" and signal_summary.get("run_id") not in {None, "", "MISSING_SIGNAL_QUALITY_AUDIT"} + batch_complete = ( + _to_int(batch.get("completed_count"), 0) == _to_int(batch.get("scenario_count"), -1) + and _to_int(batch.get("failed_count"), 1) == 0 + ) + priority_completion = [ + { + "priority": 1, + "feature": "Dashboard Scenario Generator", + "completion_pct": 100 if len(templates) >= 3 and scenario_generator.get("execution_allowed") is False else 0, + "status": "IMPLEMENTED_READ_ONLY" if len(templates) >= 3 and scenario_generator.get("execution_allowed") is False else "MISSING_OR_UNSAFE", + "evidence": "scenario_generator.templates[].plan_json_draft", + }, + { + "priority": 2, + "feature": "Signal-quality Result Integration", + "completion_pct": 100 if signal_available and bool(signal_summary.get("row_counts")) else 0, + "status": "IMPLEMENTED_ARTIFACT_BACKED" if signal_available and bool(signal_summary.get("row_counts")) else "MISSING_SIGNAL_QUALITY_ARTIFACT", + "evidence": "signal_quality_audit_summary", + }, + { + "priority": 3, + "feature": "Past-only Market-regime Readiness", + "completion_pct": 100 if market_regime_readiness.get("readiness_checks") else 0, + "status": "IMPLEMENTED_AS_READINESS" if market_regime_readiness.get("readiness_checks") else "MISSING_READINESS_SECTION", + "evidence": "market_regime_audit_readiness", + }, + { + "priority": 4, + "feature": "AI-readable Improvement Queue", + "completion_pct": 100 if len(queue_items) >= 5 else 0, + "status": "IMPLEMENTED_FIXED_FORMAT" if len(queue_items) >= 5 else "MISSING_QUEUE_ITEMS", + "evidence": "improvement_queue.items", + }, + { + "priority": 5, + "feature": "Scenario Comparison and Maturity Reporting", + "completion_pct": 100 if len(comparison_cards) >= 5 and batch_complete else 0, + "status": "IMPLEMENTED_NUMERIC" if len(comparison_cards) >= 5 and batch_complete else "MISSING_COMPARISON_EVIDENCE", + "evidence": "scenario_comparison + page_maturity_report", + }, + ] + implementation_completion = round( + sum((_to_float(row.get("completion_pct"), 0.0) or 0.0) for row in priority_completion) / len(priority_completion) + ) + data_governance_maturity = _to_int(market_regime_readiness.get("maturity_score_pct"), 0) or 0 + raw_scenario_platform = round( + ( + (_to_float(priority_completion[0]["completion_pct"], 0.0) or 0.0) + + (_to_float(priority_completion[1]["completion_pct"], 0.0) or 0.0) + + (_to_float(priority_completion[4]["completion_pct"], 0.0) or 0.0) + ) + / 3 + ) + no_go_cap_active = signal_summary.get("promotion_status") == "NO-GO_RESEARCH_ONLY" + scenario_platform_maturity = min(raw_scenario_platform, 86 if no_go_cap_active else 100) + raw_research_readiness = round(((100 if signal_available else 0) + data_governance_maturity) / 2) + research_readiness = min(raw_research_readiness, 74 if no_go_cap_active else 100) + page_maturity = round( + implementation_completion * 0.45 + + scenario_platform_maturity * 0.25 + + research_readiness * 0.15 + + data_governance_maturity * 0.15 + ) + score_inputs = { + "priority_completion": priority_completion, + "raw_scenario_platform_maturity_pct": raw_scenario_platform, + "scenario_platform_cap": 86 if no_go_cap_active else 100, + "raw_research_readiness_pct": raw_research_readiness, + "research_readiness_cap": 74 if no_go_cap_active else 100, + "data_governance_source": "market_regime_audit_readiness.maturity_score_pct", + } + return { + "schema_version": "daily_rl_page_maturity_report.v1", + "status": "FEATURES_IMPLEMENTED_RESEARCH_GATES_BLOCKED" if implementation_completion == 100 else "FEATURES_INCOMPLETE_OR_MISSING_EVIDENCE", + "implementation_completion_pct": implementation_completion, + "page_maturity_pct": page_maturity, + "scenario_platform_maturity_pct": scenario_platform_maturity, + "research_readiness_pct": research_readiness, + "data_governance_maturity_pct": data_governance_maturity, + "live_trading_readiness_pct": 0, + "model_build_readiness_pct": 0, + "paper_forward_readiness_pct": 0, + "priority_completion": priority_completion, + "score_inputs": score_inputs, + "score_method": "Scores are derived from payload evidence availability, scenario batch completion, readiness checks, and explicit NO-GO caps; live/model/paper readiness stays 0 while D5 and data-governance blockers remain active.", + "remaining_blockers": ["D0_PRICE_BASIS", "D1_UNIVERSE", "D5_NO_GO", "MODEL_BUILD_LOCKED", "PAPER_FORWARD_LOCKED", "LIVE_BROKER_ORDER_LOCKED"], + "guardrail": "A high page maturity score means the research dashboard is more usable; it is not profitability, paper-forward, or live-trading readiness.", + } +_ALLOWED_RESEARCH_WORKFLOW_IDS = ( + "D0_D1_DATA_GOVERNANCE_REVIEW", + "D3_D4_SIGNAL_QUALITY_AUDIT", + "PAST_ONLY_MARKET_REGIME_AUDIT", + "D4_RL_OVERLAY_ABLATION", + "SCENARIO_BATCH_RESEARCH_ONLY", + "HYPOTHESIS_REJECTION_AUDIT", +) + + +def _build_research_workflow_catalog_payload( + *, + signal_summary: dict[str, Any] | None = None, + scenario_generator: dict[str, Any] | None = None, + market_regime_readiness: dict[str, Any] | None = None, + improvement_queue: dict[str, Any] | None = None, + scenario_comparison: dict[str, Any] | None = None, +) -> dict[str, Any]: + signal_summary = signal_summary or _build_rl_guide_signal_quality_summary() + scenario_generator = scenario_generator or _build_rl_guide_scenario_generator(signal_summary) + market_regime_readiness = market_regime_readiness or _build_rl_guide_market_regime_readiness(signal_summary) + improvement_queue = improvement_queue or _build_rl_guide_improvement_queue(signal_summary) + scenario_comparison = scenario_comparison or _build_rl_guide_scenario_comparison(signal_summary) + batch = signal_summary.get("batch_manifest") if isinstance(signal_summary.get("batch_manifest"), dict) else {} + workflow_items = [ + { + "workflow_id": "D0_D1_DATA_GOVERNANCE_REVIEW", + "title_ko": "D0/D1 데이터 거버넌스 확인", + "stage": "D0-D1", + "status": "BLOCKED_WITH_REVIEW_PATH", + "approval_required": True, + "approval_status": "REQUIRES_DATED_PREREG_OR_APPROVED_PLAN", + "trigger_status": "INTENT_ONLY_NOT_EXECUTED_BY_BROWSER", + "blocked_by": ["D0_PRICE_BASIS", "D1_UNIVERSE_WATCH"], + "artifact_dependencies": [ + "daily_ohlcv_price_basis_audit.json", + "universe.json", + "official_metadata_audit.json", + ], + "next_allowed_action": "Inspect evidence and create a preregistered data-governance audit intent.", + "maturity_pct": 72, + }, + { + "workflow_id": "D3_D4_SIGNAL_QUALITY_AUDIT", + "title_ko": "D3/D4 신호 품질 감사", + "stage": "D3-D4", + "status": signal_summary.get("promotion_status") or "NO-GO_RESEARCH_ONLY", + "approval_required": True, + "approval_status": "COMPLETED_ARTIFACT_BACKED_REFERENCE", + "trigger_status": "INTENT_ONLY_FOR_FOLLOW_UP", + "blocked_by": ["D5_NO_GO", "MODEL_BUILD_LOCKED", "PAPER_FORWARD_LOCKED", "LIVE_BROKER_ORDER_LOCKED"], + "artifact_dependencies": list((signal_summary.get("required_artifacts") or {}).values()), + "next_allowed_action": signal_summary.get("next_allowed_research"), + "maturity_pct": 100 if signal_summary.get("status") == "COMPLETED_RESEARCH_ONLY" else 0, + }, + { + "workflow_id": "PAST_ONLY_MARKET_REGIME_AUDIT", + "title_ko": "Past-only 시장 국면 데이터 품질 감사", + "stage": "D3-D4", + "status": market_regime_readiness.get("status") or "NEXT_RESEARCH_READY_FOR_PREREGISTRATION", + "approval_required": True, + "approval_status": "PREREGISTRATION_REQUIRED", + "trigger_status": "INTENT_ONLY_NOT_EXECUTED_BY_BROWSER", + "blocked_by": market_regime_readiness.get("blocked_gates") or [], + "artifact_dependencies": market_regime_readiness.get("required_inputs") or [], + "next_allowed_action": "Write/read preregistration, then request an approved research-only intent.", + "maturity_pct": market_regime_readiness.get("maturity_score_pct"), + }, + { + "workflow_id": "D4_RL_OVERLAY_ABLATION", + "title_ko": "D4 RL 위험 Overlay ablation", + "stage": "D4-D5", + "status": "BLOCKED_UNTIL_DATA_AUDIT", + "approval_required": True, + "approval_status": "BLOCKED_BY_D0_D1_D5", + "trigger_status": "DISABLED_UNTIL_BLOCKERS_RESOLVE", + "blocked_by": ["D0_PRICE_BASIS", "D1_UNIVERSE_WATCH", "D5_NO_GO"], + "artifact_dependencies": ["state_observations.csv", "reward_breakdown.csv", "action_distribution.csv", "walk_forward_gate_manifest.json"], + "next_allowed_action": "Do not run overlay ablation until market-regime/data quality blockers are addressed.", + "maturity_pct": 35, + }, + { + "workflow_id": "SCENARIO_BATCH_RESEARCH_ONLY", + "title_ko": "Scenario batch research-only 실행 검토", + "stage": "Automation", + "status": "WATCH_RESEARCH_ONLY", + "approval_required": True, + "approval_status": "APPROVAL_REQUIRED_FOR_NEW_BATCH", + "trigger_status": "INTENT_ONLY_NOT_EXECUTED_BY_BROWSER", + "blocked_by": ["NO_LIVE_BROKER_ORDERS", "NO_PROFIT_CLAIMS"], + "artifact_dependencies": ["scenario_plan.json", "scenario_batch_manifest.json"], + "next_allowed_action": "Create fixed scenario draft and approval-linked intent record only.", + "maturity_pct": 86 if _to_int(batch.get("scenario_count"), 0) else 50, + }, + { + "workflow_id": "HYPOTHESIS_REJECTION_AUDIT", + "title_ko": "가설 탈락 / 조기 dropout 감사", + "stage": "Research QA", + "status": "PREREGISTERED_RESEARCH_ONLY", + "approval_required": True, + "approval_status": "PREREGISTRATION_AVAILABLE", + "trigger_status": "INTENT_ONLY_NOT_EXECUTED_BY_BROWSER", + "blocked_by": ["REVIEW_ONLY_FALSE_NEGATIVES", "NO_NO_GO_REVERSAL"], + "artifact_dependencies": [ + "gate_funnel_metrics.csv", + "rejection_reason_taxonomy.csv", + "calibration_metrics.csv", + "threshold_sensitivity.csv", + "false_negative_candidates.csv", + "audit_manifest.json", + ], + "next_allowed_action": "Generate review-only rejection analytics after approved research intent.", + "maturity_pct": 65, + }, + ] + for item in workflow_items: + item["read_only_dashboard"] = True + item["execution_allowed_from_browser"] = False + item["model_build_allowed"] = False + item["paper_forward_allowed"] = False + item["live_broker_order_allowed"] = False + item["default_cost_bp"] = 23 + item["cost_sensitivity_bp"] = [0, 23, 46] + item["guardrail"] = "Dashboard workflow surface is inspection/configuration/intent-only; no live/broker/orders, no paper/model unlock, no profit claim." + completion_pct = round(sum((_to_float(item.get("maturity_pct"), 0.0) or 0.0) for item in workflow_items) / len(workflow_items)) + return { + "schema_version": "daily_ohlcv_research_workflow_catalog.v1", + "status": "DASHBOARD_FIRST_RESEARCH_WORKFLOWS_VISIBLE", + "read_only": True, + "execution_allowed_from_browser": False, + "job_intent_mode": "APPROVAL_GATED_INTENT_RECORD_ONLY", + "workflow_count": len(workflow_items), + "completion_pct": completion_pct, + "allowed_workflow_ids": list(_ALLOWED_RESEARCH_WORKFLOW_IDS), + "workflows": workflow_items, + "dashboard_contract": [ + "catalog", + "inspector", + "safe_config_preview", + "approval_blocker_or_intent_record", + "ledger", + "artifact_review", + ], + "forbidden_fields": [ + "command", + "shell", + "argv", + "cwd", + "env", + "broker", + "account", + "order", + "live", + "paper_forward_unlock", + "model_build_unlock", + "profit_target", + "symbol_order", + "paper_forward", + "model_build", + "arbitrary_path", + "model_build_allowed", + "paper_forward_allowed", + "live_broker_order_allowed", + "go_summary_allowed", + "execution_allowed_from_browser", + ], + "source_docs": [ + "docs/stom_daily_ohlcv_dashboard_first_research_platform_adr_2026-06-18.md", + "docs/stom_daily_ohlcv_hypothesis_rejection_audit_prereg_2026-06-18.md", + "docs/stom_daily_ohlcv_research_governance_index_2026-06-18.md", + ], + "guardrail": "Research workflow catalog is dashboard-visible planning/inspection metadata only; browser execution and live/broker/order/model/paper/profit behavior are blocked.", + } + + +def load_research_workflow_catalog() -> dict[str, Any]: + return _build_research_workflow_catalog_payload() + + +def load_research_workflow_detail(workflow_id: str) -> dict[str, Any]: + safe_id = str(workflow_id or "").strip().upper() + catalog = load_research_workflow_catalog() + workflows = catalog.get("workflows") if isinstance(catalog.get("workflows"), list) else [] + workflow = next((item for item in workflows if isinstance(item, dict) and item.get("workflow_id") == safe_id), None) + if workflow is None: + return { + "schema_version": "daily_ohlcv_research_workflow_detail.v1", + "status": "UNKNOWN_WORKFLOW_ID", + "workflow_id": safe_id, + "read_only": True, + "execution_allowed_from_browser": False, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "blockers": ["UNKNOWN_WORKFLOW_ID"], + "guardrail": "Unknown workflow ids fail closed and cannot create research intents.", + } + return { + "schema_version": "daily_ohlcv_research_workflow_detail.v1", + "status": workflow.get("status"), + "workflow": workflow, + "inspector_sections": [ + {"id": "blockers", "title_ko": "현재 blocker", "items": workflow.get("blocked_by") or []}, + {"id": "artifacts", "title_ko": "필요 artifact", "items": workflow.get("artifact_dependencies") or []}, + {"id": "approval", "title_ko": "승인 상태", "items": [workflow.get("approval_status"), workflow.get("trigger_status")]}, + {"id": "guardrails", "title_ko": "금지된 사용", "items": ["no live/broker/orders", "no model/paper unlock", "no profit claim", "no arbitrary shell"]}, + ], + "config_preview_contract": { + "schema_version": "daily_ohlcv_safe_config_preview.v1", + "workflow_id": workflow.get("workflow_id"), + "default_cost_bp": 23, + "cost_sensitivity_bp": [0, 23, 46], + "approval_required": True, + "forbidden_fields": catalog.get("forbidden_fields") or [], + "status": "INTENT_CREATION_AVAILABLE_G003", + "post_endpoint_template": "/api/daily-ohlcv/research-workflows/{workflow_id}/job-intents", + "ledger_endpoint": "/api/daily-ohlcv/research-jobs", + }, + "read_only": True, + "execution_allowed_from_browser": False, + "guardrail": "Workflow detail is read-only inspector metadata; job-intent creation is handled by a later approval-gated story.", + } + +def _json_hash(value: Any) -> str: + payload = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +def _file_sha256(path: Path) -> str: + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def _safe_relative_repo_path(value: Any) -> Path: + raw = str(value or "").replace("\\", "/").strip() + if not raw or raw.startswith("/") or raw.startswith("~") or ":" in raw: + raise ValueError("UNSAFE_PATH") + path = Path(raw) + if any(part in {"", ".", ".."} for part in path.parts): + raise ValueError("UNSAFE_PATH_TRAVERSAL") + return path + + +def _approval_path_allowed(path: Path) -> bool: + normalized = path.as_posix() + return normalized.startswith("docs/") or normalized.startswith(".gjc/plans/ralplan/") or normalized.startswith(".gjc/ultragoal/") + + +def _collect_forbidden_payload_keys(value: Any, forbidden: set[str], *, prefix: str = "") -> list[str]: + found: list[str] = [] + if isinstance(value, dict): + for key, child in value.items(): + key_text = str(key) + child_prefix = f"{prefix}.{key_text}" if prefix else key_text + if key_text.lower() in forbidden: + found.append(child_prefix) + found.extend(_collect_forbidden_payload_keys(child, forbidden, prefix=child_prefix)) + elif isinstance(value, list): + for index, child in enumerate(value): + found.extend(_collect_forbidden_payload_keys(child, forbidden, prefix=f"{prefix}[{index}]")) + return found + + +_ALLOWED_CONFIG_FIELDS = { + "workflow_id", + "hypothesis_id", + "cost_sensitivity_bp", + "fold_policy", + "controls", + "data_window_id", + "artifact_dependencies", + "reviewer_notes", + "default_cost_bp", +} + + +_ALLOWED_INTENT_PAYLOAD_FIELDS = { + "schema_version", + "workflow_id", + "approval_ref", + "approval_ref_sha256", + "approval_status", + "idempotency_key", + "requested_by", + "config", + "authz_mode", + "authz_decision", +} + + +def _validate_intent_payload(payload: dict[str, Any], workflow_id: str) -> tuple[dict[str, Any] | None, list[str]]: + errors: list[str] = [] + catalog = load_research_workflow_catalog() + forbidden = {str(item).lower() for item in catalog.get("forbidden_fields") or []} + forbidden_hits = _collect_forbidden_payload_keys(payload, forbidden) + if forbidden_hits: + errors.append(f"FORBIDDEN_FIELDS:{','.join(forbidden_hits)}") + unsupported_top_level = sorted(str(key) for key in payload if str(key) not in _ALLOWED_INTENT_PAYLOAD_FIELDS) + if unsupported_top_level: + errors.append(f"UNSUPPORTED_TOP_LEVEL_FIELDS:{','.join(unsupported_top_level)}") + safe_workflow_id = str(workflow_id or "").strip().upper() + if safe_workflow_id not in _ALLOWED_RESEARCH_WORKFLOW_IDS: + errors.append("UNKNOWN_WORKFLOW_ID") + body_workflow = str(payload.get("workflow_id") or safe_workflow_id).strip().upper() + if body_workflow != safe_workflow_id: + errors.append("WORKFLOW_ID_MISMATCH") + approval_ref_raw = payload.get("approval_ref") + approval_ref_sha = str(payload.get("approval_ref_sha256") or "") + approval_path: Path | None = None + if not approval_ref_raw or not approval_ref_sha: + errors.append("APPROVAL_REF_AND_SHA_REQUIRED") + else: + try: + approval_path = _safe_relative_repo_path(approval_ref_raw) + if not _approval_path_allowed(approval_path): + errors.append("APPROVAL_REF_OUTSIDE_ALLOWED_ROOTS") + elif not approval_path.is_file(): + errors.append("APPROVAL_REF_NOT_FOUND") + elif _file_sha256(approval_path) != approval_ref_sha: + errors.append("APPROVAL_REF_SHA_MISMATCH") + except ValueError as exc: + errors.append(str(exc)) + idempotency_key = str(payload.get("idempotency_key") or "").strip() + if not SAFE_RUN_RE.match(idempotency_key) or idempotency_key in {"", ".", ".."}: + errors.append("INVALID_IDEMPOTENCY_KEY") + requested_by = str(payload.get("requested_by") or "local-dashboard-user") + approval_status = str(payload.get("approval_status") or "").strip().upper() + if approval_status not in {"APPROVED", "APPROVED_FOR_RESEARCH_INTENT"}: + errors.append("APPROVAL_STATUS_NOT_APPROVED") + authz_mode = str(payload.get("authz_mode") or "local_trusted_dashboard").strip() + authz_decision = str(payload.get("authz_decision") or "ALLOW_RESEARCH_INTENT_RECORD_ONLY").strip().upper() + if authz_mode != "local_trusted_dashboard": + errors.append("UNSUPPORTED_AUTHZ_MODE") + if authz_decision != "ALLOW_RESEARCH_INTENT_RECORD_ONLY": + errors.append("AUTHZ_DECISION_MUST_BE_RESEARCH_INTENT_ONLY") + config = payload.get("config") if isinstance(payload.get("config"), dict) else {} + unexpected_config = sorted(str(key) for key in config if str(key) not in _ALLOWED_CONFIG_FIELDS) + if unexpected_config: + errors.append(f"UNSUPPORTED_CONFIG_FIELDS:{','.join(unexpected_config)}") + config_workflow = str(config.get("workflow_id") or safe_workflow_id).strip().upper() + if config_workflow != safe_workflow_id: + errors.append("CONFIG_WORKFLOW_ID_MISMATCH") + if config.get("cost_sensitivity_bp", [0, 23, 46]) != [0, 23, 46]: + errors.append("COST_SENSITIVITY_MUST_BE_0_23_46") + if config.get("default_cost_bp", 23) != 23: + errors.append("DEFAULT_COST_BP_MUST_BE_23") + artifact_dependencies = config.get("artifact_dependencies") + if artifact_dependencies is not None: + if not isinstance(artifact_dependencies, list): + errors.append("ARTIFACT_DEPENDENCIES_MUST_BE_LIST") + else: + for index, artifact in enumerate(artifact_dependencies): + try: + _safe_relative_repo_path(artifact) + except ValueError as exc: + errors.append(f"UNSAFE_ARTIFACT_DEPENDENCY[{index}]:{exc}") + if errors: + return None, errors + normalized_config = {key: config.get(key) for key in sorted(_ALLOWED_CONFIG_FIELDS) if key in config} + normalized_config.setdefault("cost_sensitivity_bp", [0, 23, 46]) + normalized_config.setdefault("default_cost_bp", 23) + plan_hash = _json_hash( + { + "workflow_id": safe_workflow_id, + "config": normalized_config, + "approval_ref": str(approval_path), + "approval_ref_sha256": approval_ref_sha, + "approval_status": approval_status, + "authz_mode": authz_mode, + "authz_decision": authz_decision, + } + ) + config_hash = _json_hash(normalized_config) + intent_hash = hashlib.sha256(f"{safe_workflow_id}:{idempotency_key}".encode("utf-8")).hexdigest()[:16] + return { + "schema_version": "daily_ohlcv_research_job_intent.v1", + "intent_id": f"{safe_workflow_id.lower()}_{intent_hash}", + "workflow_id": safe_workflow_id, + "approval_ref": str(approval_path), + "approval_ref_sha256": approval_ref_sha, + "requested_by": requested_by, + "approval_status": approval_status, + "approval_validation": { + "status": "APPROVED_SHA256_CURRENT", + "stale_check": "approval_ref_sha256_must_match_current_file", + "workflow_id": safe_workflow_id, + }, + "requested_at_utc": datetime.now(timezone.utc).replace(microsecond=0).isoformat(), + "idempotency_key": idempotency_key, + "plan_hash": plan_hash, + "config_hash": config_hash, + "normalized_config": normalized_config, + "source_governance_index_path": "docs/stom_daily_ohlcv_research_governance_index_2026-06-18.md", + "default_cost_bp": 23, + "cost_sensitivity_bp": [0, 23, 46], + "baseline_controls": ["no_trade", "shuffle_control", "frozen_d3_baseline"], + "no_retune": True, + "authz_mode": authz_mode, + "authz_decision": authz_decision, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "artifact_root": str(DEFAULT_RESEARCH_INTENT_ROOT), + "status": "INTENT_RECORDED", + "guardrails": ["research_only", "no_live_broker_orders", "no_profit_claims", "no_paper_forward", "no_model_build", "no_arbitrary_shell"], + }, [] + + +def create_research_job_intent(workflow_id: str, payload: dict[str, Any]) -> dict[str, Any]: + if not isinstance(payload, dict): + return {"schema_version": "daily_ohlcv_research_job_intent_response.v1", "status": "VALIDATION_FAILED", "errors": ["JSON_OBJECT_REQUIRED"], "http_status": 400, "model_build_allowed": False, "paper_forward_allowed": False, "live_broker_order_allowed": False} + normalized, errors = _validate_intent_payload(payload, workflow_id) + if errors or normalized is None: + return {"schema_version": "daily_ohlcv_research_job_intent_response.v1", "status": "VALIDATION_FAILED", "errors": errors, "http_status": 400, "model_build_allowed": False, "paper_forward_allowed": False, "live_broker_order_allowed": False, "guardrail": "Invalid research intent requests fail closed and never execute."} + root = DEFAULT_RESEARCH_INTENT_ROOT.resolve() + intent_dir = (root / normalized["intent_id"]).resolve() + intent_dir.relative_to(root) + intent_path = intent_dir / "intent.json" + if intent_path.exists(): + existing = _load_json_if_exists(intent_path) + if existing.get("plan_hash") == normalized["plan_hash"] and existing.get("config_hash") == normalized["config_hash"]: + existing["http_status"] = 200 + existing["idempotency_result"] = "existing_intent_returned" + return existing + return {"schema_version": "daily_ohlcv_research_job_intent_response.v1", "status": "IDEMPOTENCY_CONFLICT", "errors": ["IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PLAN_OR_CONFIG"], "http_status": 409, "model_build_allowed": False, "paper_forward_allowed": False, "live_broker_order_allowed": False} + try: + intent_dir.relative_to(root) + except ValueError: + return {"schema_version": "daily_ohlcv_research_job_intent_response.v1", "status": "UNSAFE_INTENT_PATH", "errors": ["UNSAFE_INTENT_PATH"], "http_status": 400, "model_build_allowed": False, "paper_forward_allowed": False, "live_broker_order_allowed": False} + intent_dir.mkdir(parents=True, exist_ok=True) + normalized["idempotency_result"] = "created" + normalized["intent_path"] = str(intent_path) + intent_path.write_text(json.dumps(normalized, ensure_ascii=False, indent=2), encoding="utf-8") + normalized["http_status"] = 201 + return normalized + + +def load_research_job_intent_ledger(*, limit: int = 25) -> dict[str, Any]: + root = DEFAULT_RESEARCH_INTENT_ROOT.resolve() + safe_limit = _bounded_limit(limit, default=25, maximum=200) + intents: list[dict[str, Any]] = [] + if root.exists(): + for path in sorted(root.glob("*/intent.json"), key=lambda p: p.stat().st_mtime, reverse=True): + try: + path.resolve().relative_to(root) + except (OSError, ValueError): + continue + payload = _load_json_if_exists(path) + if not payload: + continue + intents.append( + { + "intent_id": payload.get("intent_id"), + "workflow_id": payload.get("workflow_id"), + "status": payload.get("status"), + "requested_at_utc": payload.get("requested_at_utc"), + "requested_by": payload.get("requested_by"), + "approval_ref": payload.get("approval_ref"), + "approval_ref_sha256": payload.get("approval_ref_sha256"), + "approval_status": payload.get("approval_status"), + "authz_mode": payload.get("authz_mode"), + "authz_decision": payload.get("authz_decision"), + "plan_hash": payload.get("plan_hash"), + "config_hash": payload.get("config_hash"), + "intent_path": _path_text(path), + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "next_allowed_action": "Review immutable intent and wait for separately approved internal worker consumption.", + "artifact_refs": [ + { + "id": "intent_json", + "kind": "immutable-job-intent", + "path": _path_text(path), + "description": "Approval-gated research intent record; this is not an execution transcript.", + } + ], + } + ) + if len(intents) >= safe_limit: + break + return { + "schema_version": "daily_ohlcv_research_job_intent_ledger.v1", + "status": "READY" if intents else "EMPTY", + "read_only": True, + "execution_allowed_from_browser": False, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "intent_root": _path_text(DEFAULT_RESEARCH_INTENT_ROOT), + "count": len(intents), + "limit": safe_limit, + "intents": intents, + "guardrail": "The ledger lists immutable approval-gated research intents only; recorded intents do not execute CLI, spawn workers, or unlock live/model/paper behavior.", + "next_allowed_action": "Create a new approved research intent or review existing immutable intent records; browser does not execute them.", + "artifact_refs": [ + { + "id": "intent_root", + "kind": "research-intent-root", + "path": _path_text(DEFAULT_RESEARCH_INTENT_ROOT), + "description": "Generated intent record root under webui/rl_runs.", + } + ], + } + + +def load_research_job_intent(intent_id: str) -> dict[str, Any]: + safe_intent_id = str(intent_id or "").strip() + if not SAFE_RUN_RE.match(safe_intent_id) or safe_intent_id in {"", ".", ".."}: + return { + "schema_version": "daily_ohlcv_research_job_intent_detail.v1", + "status": "INVALID_INTENT_ID", + "http_status": 400, + "errors": ["INVALID_INTENT_ID"], + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + root = DEFAULT_RESEARCH_INTENT_ROOT.resolve() + intent_path = (root / safe_intent_id / "intent.json").resolve() + try: + intent_path.relative_to(root) + except ValueError: + return { + "schema_version": "daily_ohlcv_research_job_intent_detail.v1", + "status": "UNSAFE_INTENT_PATH", + "http_status": 400, + "errors": ["UNSAFE_INTENT_PATH"], + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + payload = _load_json_if_exists(intent_path) + if not payload: + return { + "schema_version": "daily_ohlcv_research_job_intent_detail.v1", + "status": "INTENT_NOT_FOUND", + "http_status": 404, + "intent_id": safe_intent_id, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + payload["schema_version"] = "daily_ohlcv_research_job_intent_detail.v1" + payload["http_status"] = 200 + payload["read_only"] = True + payload["execution_allowed_from_browser"] = False + payload["model_build_allowed"] = False + payload["paper_forward_allowed"] = False + payload["live_broker_order_allowed"] = False + payload["guardrail"] = "Intent detail is immutable research metadata; it is not a worker execution, order, model build, or paper-forward unlock." + return payload + + +def _load_rejection_audit_csv(run_dir: Path, filename: str, *, limit: int) -> list[dict[str, Any]]: + path = run_dir / filename + if not path.exists(): + return [] + return _read_csv_rows(path, limit) + + +def _rejection_audit_artifact_hashes(run_dir: Path) -> dict[str, str]: + filenames = [ + "gate_funnel_metrics.csv", + "rejection_reason_taxonomy.csv", + "calibration_metrics.csv", + "threshold_sensitivity.csv", + "false_negative_candidates.csv", + "audit_manifest.json", + ] + return {filename: _file_sha256(run_dir / filename) for filename in filenames if (run_dir / filename).is_file()} + + +def _validate_rejection_audit_payload( + *, + run_dir: Path, + manifest: dict[str, Any], + artifact_hashes: dict[str, str], + row_counts: dict[str, int], + false_negative_candidates: list[dict[str, Any]], +) -> list[str]: + required_files = [ + "gate_funnel_metrics.csv", + "rejection_reason_taxonomy.csv", + "calibration_metrics.csv", + "threshold_sensitivity.csv", + "false_negative_candidates.csv", + "audit_manifest.json", + ] + errors: list[str] = [] + if manifest.get("schema_version") != "daily_ohlcv_rejection_audit_manifest.v1": + errors.append("INVALID_MANIFEST_SCHEMA_VERSION") + if manifest.get("status") != "COMPLETED_RESEARCH_ONLY": + errors.append("INVALID_MANIFEST_STATUS") + for filename in required_files: + if not (run_dir / filename).is_file(): + errors.append(f"MISSING_REQUIRED_ARTIFACT:{filename}") + manifest_hashes = manifest.get("artifact_hashes") if isinstance(manifest.get("artifact_hashes"), dict) else {} + for filename in required_files: + if filename == "audit_manifest.json": + continue + expected = str(manifest_hashes.get(filename) or "") + actual = artifact_hashes.get(filename) + if not expected or expected != actual: + errors.append(f"ARTIFACT_HASH_MISMATCH:{filename}") + manifest_counts = manifest.get("row_counts") if isinstance(manifest.get("row_counts"), dict) else {} + for key, actual_count in row_counts.items(): + if _to_int(manifest_counts.get(key), -1) != actual_count: + errors.append(f"ROW_COUNT_MISMATCH:{key}") + guardrails = set(str(item) for item in manifest.get("guardrails") or []) + for guardrail in ("research_only", "review_only_false_negatives", "no_no_go_reversal", "no_model_build", "no_paper_forward", "no_live_broker_orders"): + if guardrail not in guardrails: + errors.append(f"MISSING_GUARDRAIL:{guardrail}") + if manifest.get("promotion_allowed") is not False: + errors.append("PROMOTION_ALLOWED_NOT_FALSE") + if manifest.get("model_build_allowed") is not False: + errors.append("MODEL_BUILD_ALLOWED_NOT_FALSE") + if manifest.get("paper_forward_allowed") is not False: + errors.append("PAPER_FORWARD_ALLOWED_NOT_FALSE") + if manifest.get("live_broker_order_allowed") is not False: + errors.append("LIVE_BROKER_ORDER_ALLOWED_NOT_FALSE") + for row in false_negative_candidates: + if str(row.get("review_status") or "") != "REVIEW_ONLY": + errors.append("FALSE_NEGATIVE_NOT_REVIEW_ONLY") + if str(row.get("promotion_allowed") or "").lower() not in {"false", "0", ""} and row.get("promotion_allowed") is not False: + errors.append("FALSE_NEGATIVE_PROMOTION_ALLOWED") + if str(row.get("requires_new_preregistration") or "").lower() not in {"true", "1"} and row.get("requires_new_preregistration") is not True: + errors.append("FALSE_NEGATIVE_MISSING_NEW_PREREG") + later_path = str(row.get("later_independent_evidence_manifest_path") or "") + later_sha = str(row.get("later_independent_evidence_sha256") or "") + if not later_path or not later_sha: + errors.append("FALSE_NEGATIVE_MISSING_LATER_EVIDENCE") + continue + try: + evidence_path = _safe_relative_repo_path(later_path) + except ValueError: + errors.append("FALSE_NEGATIVE_UNSAFE_LATER_EVIDENCE_PATH") + continue + if not evidence_path.is_file(): + errors.append("FALSE_NEGATIVE_LATER_EVIDENCE_NOT_FOUND") + continue + if _file_sha256(evidence_path) != later_sha: + errors.append("FALSE_NEGATIVE_LATER_EVIDENCE_SHA_MISMATCH") + continue + evidence_manifest = _load_json_if_exists(evidence_path) + if evidence_manifest.get("schema_version") != "daily_ohlcv_false_negative_followup_evidence.v1": + errors.append("FALSE_NEGATIVE_LATER_EVIDENCE_INVALID_SCHEMA") + if evidence_manifest.get("status") != "FOLLOW_UP_PREREGISTRATION_REQUIRED_REVIEW_ONLY": + errors.append("FALSE_NEGATIVE_LATER_EVIDENCE_INVALID_STATUS") + if row.get("candidate_id") not in (evidence_manifest.get("candidate_ids") or []): + errors.append("FALSE_NEGATIVE_LATER_EVIDENCE_CANDIDATE_MISMATCH") + if evidence_manifest.get("promotion_allowed") is not False: + errors.append("FALSE_NEGATIVE_LATER_EVIDENCE_PROMOTION_ALLOWED") + if evidence_manifest.get("requires_new_preregistration") is not True: + errors.append("FALSE_NEGATIVE_LATER_EVIDENCE_MISSING_NEW_PREREG") + evidence_guardrails = set(str(item) for item in evidence_manifest.get("guardrails") or []) + for guardrail in ("review_only_false_negatives", "no_no_go_reversal", "no_model_build", "no_paper_forward", "no_live_broker_orders"): + if guardrail not in evidence_guardrails: + errors.append(f"FALSE_NEGATIVE_LATER_EVIDENCE_MISSING_GUARDRAIL:{guardrail}") + expected_later_hash = str(manifest_hashes.get(evidence_path.name) or "") + if expected_later_hash and expected_later_hash != later_sha: + errors.append("FALSE_NEGATIVE_LATER_EVIDENCE_MANIFEST_HASH_MISMATCH") + return errors + + +def load_rejection_analytics(*, run: str | None = None, limit: int = 25) -> dict[str, Any]: + safe_limit = _bounded_limit(limit, default=25, maximum=200) + try: + run_dir = _latest_run_dir(DEFAULT_REJECTION_AUDIT_ROOT, run_id=run, required_file="audit_manifest.json") + except (FileNotFoundError, ValueError): + return { + "schema_version": "daily_ohlcv_rejection_analytics.v1", + "status": "INVALID_RUN_ID", + "errors": ["INVALID_RUN_ID"], + "read_only": True, + "promotion_allowed": False, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + } + if run_dir is None: + return { + "schema_version": "daily_ohlcv_rejection_analytics.v1", + "status": "MISSING_REJECTION_AUDIT_ARTIFACTS", + "run_id": None, + "read_only": True, + "promotion_allowed": False, + "false_negative_review_only": True, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "required_artifacts": [ + "gate_funnel_metrics.csv", + "rejection_reason_taxonomy.csv", + "calibration_metrics.csv", + "threshold_sensitivity.csv", + "false_negative_candidates.csv", + "audit_manifest.json", + ], + "guardrail": "Missing rejection-audit artifacts fail closed; no NO-GO reversal or promotion is allowed.", + } + manifest = _load_json_if_exists(run_dir / "audit_manifest.json") + artifact_hashes = _rejection_audit_artifact_hashes(run_dir) + gate_funnel_all = _load_rejection_audit_csv(run_dir, "gate_funnel_metrics.csv", limit=MAX_LIMIT) + taxonomy_all = _load_rejection_audit_csv(run_dir, "rejection_reason_taxonomy.csv", limit=MAX_LIMIT) + calibration_all = _load_rejection_audit_csv(run_dir, "calibration_metrics.csv", limit=MAX_LIMIT) + sensitivity_all = _load_rejection_audit_csv(run_dir, "threshold_sensitivity.csv", limit=MAX_LIMIT) + false_negatives_all = _load_rejection_audit_csv(run_dir, "false_negative_candidates.csv", limit=MAX_LIMIT) + gate_funnel = gate_funnel_all[:safe_limit] + taxonomy = taxonomy_all[:safe_limit] + calibration = calibration_all[:safe_limit] + sensitivity = sensitivity_all[:safe_limit] + false_negatives = false_negatives_all[:safe_limit] + row_counts = { + "gate_funnel_metrics": len(gate_funnel_all), + "rejection_reason_taxonomy": len(taxonomy_all), + "calibration_metrics": len(calibration_all), + "threshold_sensitivity": len(sensitivity_all), + "false_negative_candidates": len(false_negatives_all), + } + rejected_total = sum(_to_int(row.get("rejected_count"), 0) or 0 for row in gate_funnel_all) + early_dropout_total = sum(_to_int(row.get("early_dropout_count"), 0) or 0 for row in gate_funnel_all) + entered_total = sum(_to_int(row.get("entered_count"), 0) or 0 for row in gate_funnel_all) + false_negative_candidates_all = [ + { + **row, + "review_status": row.get("review_status") or "REVIEW_ONLY", + "promotion_allowed": False, + "requires_new_preregistration": True, + } + for row in false_negatives_all + ] + false_negative_candidates = false_negative_candidates_all[:safe_limit] + validation_errors = _validate_rejection_audit_payload( + run_dir=run_dir, + manifest=manifest, + artifact_hashes=artifact_hashes, + row_counts=row_counts, + false_negative_candidates=false_negatives_all, + ) + if validation_errors: + return { + "schema_version": "daily_ohlcv_rejection_analytics.v1", + "status": "INVALID_REJECTION_AUDIT_ARTIFACTS", + "run_id": manifest.get("audit_run_id") or run_dir.name, + "errors": validation_errors, + "read_only": True, + "promotion_allowed": False, + "false_negative_review_only": True, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "artifact_hashes": artifact_hashes, + "row_counts": row_counts, + "guardrail": "Invalid rejection-audit artifacts fail closed; no NO-GO reversal or promotion is allowed.", + } + return { + "schema_version": "daily_ohlcv_rejection_analytics.v1", + "status": manifest.get("status") or "COMPLETED_RESEARCH_ONLY", + "run_id": manifest.get("audit_run_id") or run_dir.name, + "generated_at_utc": manifest.get("generated_at_utc"), + "read_only": True, + "artifact_root": _path_text(DEFAULT_REJECTION_AUDIT_ROOT), + "run_path": _path_text(run_dir), + "manifest_path": _path_text(run_dir / "audit_manifest.json"), + "artifact_hashes": artifact_hashes, + "row_counts": row_counts, + "summary": { + "entered_total": entered_total, + "rejected_total": rejected_total, + "early_dropout_total": early_dropout_total, + "false_negative_candidate_count": len(false_negative_candidates), + "review_only_candidate_count": len(false_negative_candidates), + "promotion_allowed": False, + }, + "denominator_policy": manifest.get("denominator_policy") or "pre-outcome eligible hypotheses/scenarios only", + "timing_policy": manifest.get("timing_policy") or "decision_time_utc is frozen from then-available evidence", + "independent_evidence_policy": manifest.get("independent_evidence_policy") or "later evidence must be separately timestamped and hashed", + "gate_funnel_metrics": gate_funnel, + "rejection_reason_taxonomy": taxonomy, + "calibration_metrics": calibration, + "threshold_sensitivity": sensitivity, + "false_negative_candidates": false_negative_candidates, + "audit_manifest": manifest, + "promotion_allowed": False, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "guardrail": "Rejection analytics are research QA only. False-negative candidates are review-only, require a new preregistration, and cannot reverse NO-GO or unlock model/paper/live behavior.", + } + + +def _build_dashboard_first_completion_report( + *, + workflow_catalog: dict[str, Any], + intent_ledger: dict[str, Any], + rejection_analytics: dict[str, Any], +) -> dict[str, Any]: + workflow_count = _to_int(workflow_catalog.get("workflow_count"), 0) or 0 + completion_items = [ + { + "id": "workflow_center", + "label_ko": "연구 workflow 센터", + "completion_pct": 100 if workflow_count >= 6 and workflow_catalog.get("execution_allowed_from_browser") is False else 0, + "evidence": "/api/daily-ohlcv/research-workflows", + }, + { + "id": "workflow_inspector", + "label_ko": "workflow inspector / safe config", + "completion_pct": 100 if "safe_config_preview" in (workflow_catalog.get("dashboard_contract") or []) else 0, + "evidence": "/api/daily-ohlcv/research-workflows/", + }, + { + "id": "intent_ledger", + "label_ko": "approval-gated intent ledger", + "completion_pct": 100 if intent_ledger.get("schema_version") == "daily_ohlcv_research_job_intent_ledger.v1" and intent_ledger.get("execution_allowed_from_browser") is False else 0, + "evidence": "/api/daily-ohlcv/research-jobs", + }, + { + "id": "rejection_analytics", + "label_ko": "가설 탈락 / 조기 dropout analytics", + "completion_pct": 100 if rejection_analytics.get("status") == "COMPLETED_RESEARCH_ONLY" and rejection_analytics.get("promotion_allowed") is False else 0, + "evidence": "/api/daily-ohlcv/rejection-analytics", + }, + { + "id": "docs_governance", + "label_ko": "ADR / prereg / result / governance 문서", + "completion_pct": 100, + "evidence": "docs/stom_daily_ohlcv_dashboard_first_research_platform_completion_result_2026-06-18.md", + }, + ] + non_live_completion_pct = round(sum((_to_float(item.get("completion_pct"), 0.0) or 0.0) for item in completion_items) / len(completion_items)) + return { + "schema_version": "daily_ohlcv_dashboard_first_completion_report.v1", + "status": "NON_LIVE_RESEARCH_PLATFORM_COMPLETE" if non_live_completion_pct == 100 else "NON_LIVE_RESEARCH_PLATFORM_INCOMPLETE", + "non_live_goal_completion_pct": non_live_completion_pct, + "live_trading_readiness_pct": 0, + "model_build_readiness_pct": 0, + "paper_forward_readiness_pct": 0, + "execution_allowed_from_browser": False, + "model_build_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "completed_surfaces": completion_items, + "can_do": [ + "대시보드에서 workflow/blocker/prerequisite/approval 상태 확인", + "safe config preview와 approval-gated intent ledger 검토", + "가설 탈락, 조기 dropout, false-negative review-only 후보 확인", + "artifact hash, row count, guardrail, next action 확인", + ], + "cannot_do": [ + "실거래/live trading", + "broker/order/account 연결", + "paper-forward unlock", + "model-build unlock", + "수익성/profitability 주장", + "대시보드 arbitrary shell 실행", + ], + "completion_evidence": [ + "G001 ADR/preregistration/governance complete", + "G002 workflow catalog/inspector complete", + "G003 safe config and intent ledger complete", + "G004 rejection analytics complete", + "G005 dashboard/docs/verification completion report integrated", + ], + "source_docs": [ + "docs/stom_daily_ohlcv_dashboard_first_research_platform_adr_2026-06-18.md", + "docs/stom_daily_ohlcv_hypothesis_rejection_audit_prereg_2026-06-18.md", + "docs/stom_daily_ohlcv_dashboard_first_research_platform_completion_result_2026-06-18.md", + "docs/stom_daily_ohlcv_research_governance_index_2026-06-18.md", + ], + "guardrail": "Non-live research/dashboard platform completion can be 100% while live/model/paper readiness remains 0%; this is not profitability or broker/order readiness.", + } + + +def _rl_guide_performance_card( + *, + label: str, + split: str, + total_return: Any, + max_drawdown: Any = None, + mean_turnover: Any = None, + starting_capital_krw: int = RL_GUIDE_DISPLAY_CAPITAL_KRW, +) -> dict[str, Any]: + return_value = _to_float(total_return, 0.0) or 0.0 + final_capital = starting_capital_krw * (1.0 + return_value) + return { + "label": label, + "split": split, + "total_return": return_value, + "total_return_pct": return_value * 100.0, + "simulated_profit_krw": round(starting_capital_krw * return_value), + "simulated_final_capital_krw": round(final_capital), + "max_drawdown_pct": (_to_float(max_drawdown, 0.0) or 0.0) * 100.0, + "mean_turnover_pct": (_to_float(mean_turnover, 0.0) or 0.0) * 100.0, + } + + +def _build_rl_guide_learning_performance(portfolio: dict[str, Any]) -> dict[str, Any]: + baseline_comparison = portfolio.get("baseline_comparison") or {} + samples = portfolio.get("samples") or {} + policy_metrics = (portfolio.get("policy_metrics") or {}).get("metrics") or [] + metric_by_split = { + str(row.get("split") or ""): row + for row in policy_metrics + if isinstance(row, dict) + } + policy_split = str(baseline_comparison.get("policy_split") or "val+test") + policy_metric = metric_by_split.get(policy_split) or metric_by_split.get("test") or {} + policy_return = baseline_comparison.get("policy_total_net_return") + if policy_return is None: + policy_return = policy_metric.get("total_net_return") + policy_drawdown = baseline_comparison.get("policy_max_drawdown") + if policy_drawdown is None: + policy_drawdown = policy_metric.get("max_drawdown") + policy_turnover = baseline_comparison.get("policy_mean_turnover") + if policy_turnover is None: + policy_turnover = policy_metric.get("mean_turnover") + + policy_card = _rl_guide_performance_card( + label="D4 RL 정책", + split=policy_split, + total_return=policy_return, + max_drawdown=policy_drawdown, + mean_turnover=policy_turnover, + ) + baseline_card = _rl_guide_performance_card( + label=str(baseline_comparison.get("best_d3_strategy") or "D3 baseline"), + split="best_d3_baseline", + total_return=baseline_comparison.get("best_d3_total_net_return"), + ) + delta_card = _rl_guide_performance_card( + label="D4 RL - best D3 baseline", + split="delta", + total_return=baseline_comparison.get("delta_vs_best_d3_total_net_return"), + ) + delta_return = delta_card["total_return"] + interpretation = ( + "현재 D4 RL 정책은 같은 연구 원금 가정에서 D3 baseline보다 약합니다. " + "따라서 학습 성과 화면은 설명·진단용이며 GO/수익성 주장으로 쓰면 안 됩니다." + if delta_return < 0 + else "현재 D4 RL 정책은 baseline 대비 우위처럼 보일 수 있으나, D5 walk-forward와 비용 민감도 통과 전에는 연구 진단용입니다." + ) + + learning_curve_preview = [ + { + "episode": _to_int(row.get("episode")), + "total_reward": _to_float(row.get("total_reward")), + "rolling_mean_reward": _to_float(row.get("rolling_mean_reward")), + "final_equity": _to_float(row.get("final_equity")), + "invalid_action_rate_pct": (_to_float(row.get("invalid_action_rate"), 0.0) or 0.0) * 100.0, + } + for row in (samples.get("learning_curve") or [])[:8] + if isinstance(row, dict) + ] + portfolio_nav_preview = [] + for row in (samples.get("policy_nav") or [])[:12]: + if not isinstance(row, dict): + continue + nav = _to_float(row.get("policy_nav")) + portfolio_nav_preview.append( + { + "date": row.get("date"), + "nav": nav, + "simulated_capital_krw": round(RL_GUIDE_DISPLAY_CAPITAL_KRW * nav) if nav is not None else None, + "current_drawdown_pct": (_to_float(row.get("policy_current_drawdown"), 0.0) or 0.0) * 100.0, + } + ) + + return { + "status": "RESEARCH_ONLY_PERFORMANCE_DIAGNOSTIC", + "display_capital_krw": RL_GUIDE_DISPLAY_CAPITAL_KRW, + "currency": "KRW", + "run_id": portfolio.get("run_id"), + "policy": policy_card, + "best_d3_baseline": baseline_card, + "delta_vs_best_d3": delta_card, + "interpretation_ko": interpretation, + "metric_definitions": [ + {"metric": "수익률", "meaning_ko": "연구용 평가 구간에서 NAV가 몇 % 변했는지입니다."}, + {"metric": "수익금", "meaning_ko": "가정 원금 1,000만원에 수익률을 곱한 모의 금액입니다."}, + {"metric": "MDD", "meaning_ko": "고점 대비 최대 낙폭입니다. 수익률과 함께 봐야 합니다."}, + {"metric": "turnover", "meaning_ko": "교체/거래 강도입니다. 높으면 23bp 비용 압력이 커집니다."}, + ], + "learning_curve_preview": learning_curve_preview, + "portfolio_nav_preview": portfolio_nav_preview, + "guardrail": "All PnL/return figures are simulated research diagnostics from generated artifacts; no profit guarantee, no live/broker/orders, no deployable readiness.", + } +def _rl_guide_action_distribution(samples: dict[str, Any]) -> list[dict[str, Any]]: + rows: list[dict[str, Any]] = [] + for row in samples.get("action_distribution") or []: + if not isinstance(row, dict): + continue + action = str(row.get("executed_action") or row.get("action") or "") + if not action: + continue + rows.append( + { + "split": row.get("split"), + "action": action, + "requested_action": row.get("requested_action"), + "executed_action": row.get("executed_action"), + "invalid_action": str(row.get("invalid_action") or "False"), + "invalid_action_reason": row.get("invalid_action_reason") or "", + "no_trade_action": str(row.get("no_trade_action") or "False"), + "count": _to_int(row.get("count"), 0), + "action_rate": _to_float(row.get("action_rate"), 0.0), + } + ) + return rows + + +def _rl_guide_mask_reasons(row: dict[str, Any]) -> dict[str, Any]: + return { + "hold": row.get("mask_reason_hold"), + "buy": row.get("mask_reason_buy"), + "add": row.get("mask_reason_add"), + "sell": row.get("mask_reason_sell"), + "reduce": row.get("mask_reason_reduce"), + } + + +def _build_rl_guide_active_replay(portfolio: dict[str, Any], contract: dict[str, Any]) -> dict[str, Any]: + """Build an artifact-backed replay payload without fake policy-network outputs.""" + + samples = portfolio.get("samples") if isinstance(portfolio.get("samples"), dict) else {} + state_rows = [row for row in samples.get("state_observations") or [] if isinstance(row, dict)] + reward_rows = [row for row in samples.get("reward_breakdown") or [] if isinstance(row, dict)] + nav_rows = [row for row in samples.get("policy_nav") or [] if isinstance(row, dict)] + learning_rows = [row for row in samples.get("learning_curve") or [] if isinstance(row, dict)] + action_distribution = _rl_guide_action_distribution(samples) + + def replay_key(row: dict[str, Any]) -> tuple[str, str]: + return (str(row.get("split") or ""), str(row.get("date") or "")) + + reward_by_key = {replay_key(row): row for row in reward_rows if row.get("date")} + nav_by_key = {replay_key(row): row for row in nav_rows if row.get("date")} + frame_keys: list[tuple[str, str]] = [] + for row in state_rows: + key = replay_key(row) + if key[1]: + frame_keys.append(key) + if not frame_keys: + for row in reward_rows: + key = replay_key(row) + if key[1]: + frame_keys.append(key) + frame_keys = frame_keys[:8] + + frames: list[dict[str, Any]] = [] + for index, key in enumerate(frame_keys): + state = next((row for row in state_rows if replay_key(row) == key), {}) + reward = reward_by_key.get(key, {}) + nav = nav_by_key.get(key, {}) + learning = learning_rows[index % len(learning_rows)] if learning_rows else {} + frame_status = "LOADED_GENERATED_ARTIFACT" if state and reward else "MISSING_REPLAY_JOIN_KEY" + frames.append( + { + "frame": index + 1, + "status": frame_status, + "join_key": {"split": key[0], "date": key[1]}, + "split": key[0] or state.get("split") or reward.get("split") or nav.get("split"), + "date": key[1] or state.get("date") or reward.get("date") or nav.get("date"), + "state": { + "position_count": _to_int(state.get("observation_position_count"), 0), + "top_score_bucket": _to_int(state.get("observation_top_score_bucket"), None), + "cash_fraction": _to_float(state.get("cash_fraction"), None), + "exposure_fraction": _to_float(state.get("exposure_fraction"), None), + "top_candidate_code": state.get("top_candidate_code"), + "candidate_count": _to_int(state.get("candidate_count"), None), + "future_label_exposed": str(state.get("future_label_exposed") or "UNKNOWN"), + }, + "action": { + "requested": reward.get("requested_action") or reward.get("action"), + "executed": reward.get("executed_action") or reward.get("action"), + "invalid": str(reward.get("invalid_action") or "False"), + "invalid_reason": reward.get("invalid_action_reason") or "", + "no_trade": str(reward.get("no_trade_action") or "False"), + "mask": state.get("action_mask_hold_buy_add_sell_reduce") or reward.get("action_mask_hold_buy_add_sell_reduce"), + "mask_reasons": _rl_guide_mask_reasons(state or reward), + }, + "reward": { + "reward": _to_float(reward.get("reward"), None), + "gross_return": _to_float(reward.get("gross_return"), None), + "net_return_after_cost": _to_float(reward.get("net_return_after_cost"), None), + "turnover_cost": _to_float(reward.get("cost"), None), + "turnover": _to_float(reward.get("turnover"), None), + "drawdown_penalty": _to_float(reward.get("drawdown_penalty"), None), + "concentration_penalty": _to_float(reward.get("concentration_penalty"), None), + "invalid_action_penalty": _to_float(reward.get("invalid_action_penalty"), None), + "churn_penalty": _to_float(reward.get("churn_penalty"), None), + "equity": _to_float(reward.get("equity"), None), + }, + "learning": { + "episode": _to_int(learning.get("episode"), None), + "total_reward": _to_float(learning.get("total_reward"), None), + "final_equity": _to_float(learning.get("final_equity"), None), + }, + "nav": { + "policy_nav": _to_float(nav.get("policy_nav"), None), + "policy_turnover": _to_float(nav.get("policy_turnover"), None), + "policy_current_drawdown": _to_float(nav.get("policy_current_drawdown"), None), + }, + } + ) + + policy_strategy = str((portfolio.get("baseline_comparison") or {}).get("policy_strategy") or "tabular_q_constrained_daily_portfolio_rl") + policy_type = "tabular_q" + replay_status = "LOADED_GENERATED_ARTIFACT" if frames and any(frame["status"] == "LOADED_GENERATED_ARTIFACT" for frame in frames) else "MISSING_REPLAY_ARTIFACT" + return { + "schema_version": "daily_rl_active_replay.v1", + "status": replay_status, + "source_run_id": portfolio.get("run_id") or "MISSING_SOURCE_RUN_ID", + "artifact_hashes": portfolio.get("artifact_hashes") or {"status": "MISSING_ARTIFACT_HASHES"}, + "policy_type": policy_type, + "policy_strategy": policy_strategy, + "policy_network_available": False, + "policy_network_status": "MISSING_POLICY_ARTIFACT", + "policy_representation_ko": "현재 산출물은 신경망 정책이 아니라 tabular Q 학습/평가 telemetry입니다.", + "action_schema_version": contract.get("schema_version"), + "action_distribution": action_distribution, + "frames": frames, + "source_tables": [ + "state_observations.csv", + "reward_breakdown.csv", + "learning_curve.csv", + "action_distribution.csv", + "policy_nav.csv", + ], + "guardrail": "Artifact-backed replay only. No fake neural/probability fallback, no live/broker/orders, no profit or deployment claim.", + } + + +def _build_rl_guide_process_lane( + *, + lane_id: str, + title_ko: str, + stage: str, + status: str, + goal_ko: str, + state_setup: list[str], + action_setup: list[str], + reward_setup: list[str], + current_limitations: list[str], + improvement_directions: list[str], + metrics_to_watch: list[str], + required_artifacts: list[str], + baseline: str, + controls: list[str], + next_step_ko: str, + guardrails: list[str], +) -> dict[str, Any]: + return { + "id": lane_id, + "title_ko": title_ko, + "stage": stage, + "status": status, + "severity": _status_severity(status), + "goal_ko": goal_ko, + "visual_nodes": [ + {"label": "Environment", "detail": "일봉 날짜 단위 연구 환경", "status": "RESEARCH_ONLY"}, + {"label": "State", "detail": " · ".join(state_setup[:3]), "status": status}, + {"label": "Action", "detail": " · ".join(action_setup[:3]), "status": status}, + {"label": "Reward", "detail": " · ".join(reward_setup[:3]), "status": status}, + {"label": "Gate", "detail": "D5 NO-GO면 승격 금지", "status": "NO-GO"}, + ], + "environment_setup": "일봉 OHLCV/D2-D3 산출물을 날짜 순서대로 읽는 research-only 포트폴리오 환경입니다.", + "state_setup": state_setup, + "action_setup": action_setup, + "reward_setup": reward_setup, + "baseline": baseline, + "controls": controls, + "current_limitations": current_limitations, + "improvement_directions": improvement_directions, + "metrics_to_watch": metrics_to_watch, + "required_artifacts": required_artifacts, + "user_questions_to_improve": [ + "이 연구는 D3 baseline 대비 어떤 위험/행동을 줄이려는가?", + "비용 23bp와 46bp stress에서도 같은 결론인가?", + "D5 5-fold OOS에서 특정 fold만 골라 설명하지 않았는가?", + "NO-GO 원인을 개선할 다음 산출물이 명확한가?", + ], + "ai_guidance_format": { + "lane_id": lane_id, + "status": status, + "research_question": goal_ko, + "environment": "daily_ohlcv_research_only_portfolio_env", + "state": state_setup, + "action": action_setup, + "reward": reward_setup, + "baseline": baseline, + "controls": controls, + "metrics_to_watch": metrics_to_watch, + "required_artifacts": required_artifacts, + "stop_conditions": [ + "D5_NO_GO", + "MODEL_BUILD_LOCKED", + "PAPER_FORWARD_LOCKED", + "LIVE_BROKER_ORDER_LOCKED", + "MISSING_OR_STALE_ARTIFACT", + ], + "guardrails": guardrails, + "recommended_next_step_ko": next_step_ko, + }, + "next_step_ko": next_step_ko, + "guardrails": guardrails, + } + + +def _build_rl_guide_research_process_catalog( + portfolio: dict[str, Any], + gate: dict[str, Any], + contract: dict[str, Any], + *, + validation_status: str, + d5_consumed: bool, +) -> dict[str, Any]: + baseline_comparison = portfolio.get("baseline_comparison") if isinstance(portfolio.get("baseline_comparison"), dict) else {} + gate_verdict = gate.get("verdict") if isinstance(gate.get("verdict"), dict) else {} + d3_strategy = str(baseline_comparison.get("best_d3_strategy") or "equal_weight_topk_momentum") + d3_return = _to_float(baseline_comparison.get("best_d3_total_net_return"), None) + d4_delta = _to_float(baseline_comparison.get("delta_vs_best_d3_total_net_return"), None) + gate_status = str(gate.get("status") or gate_verdict.get("status") or "NO-GO") + + common_guardrails = [ + "research_only", + "no_live_broker_orders", + "no_profit_claims", + "default_cost_23bp", + "model_build_allowed_false", + "paper_forward_allowed_false", + "live_broker_order_allowed_false", + "D5_NO_GO_until_verified_gates_pass", + ] + controls = ["no_trade_cash", "shuffle_control", "D3_rule_baseline", "supervised_baseline", "0/23/46bp_cost_sensitivity", "5_fold_oos_no_retune"] + lanes = [ + _build_rl_guide_process_lane( + lane_id="D3_BASELINE_FREEZE", + title_ko="D3 기준선 고정/비교", + stage="D3", + status="WATCH_RESEARCH_ONLY", + goal_ko="현재 가장 강한 rule/supervised 기준선을 고정하고 RL이 반드시 넘어야 할 비교 대상을 명확히 합니다.", + state_setup=["D2 split", "rank score", "candidate universe", "future_return_1d는 평가 라벨로만 사용"], + action_setup=["top-k selection", "equal weight", "no-trade baseline", "shuffle control"], + reward_setup=["total_net_return_after_23bp", "MDD", "turnover", "hit rate"], + current_limitations=[ + "price_basis/universe blockers가 남아 있어 모델 승격 근거가 아닙니다.", + "D3는 RL이 아니라 rule/supervised baseline입니다.", + "D4가 D3보다 약하면 RL을 교체 모델로 주장할 수 없습니다.", + ], + improvement_directions=[ + "D0 price basis와 D1 universe review를 먼저 고정합니다.", + "D3 best baseline을 5-fold/OOS/cost sensitivity로 계속 비교 기준화합니다.", + "shuffle/no-trade 대비 우위와 D3 대비 열위를 함께 표시합니다.", + ], + metrics_to_watch=["best_d3_total_net_return", "shuffle_delta", "MDD", "turnover", "cost_23bp_delta"], + required_artifacts=["baseline_metrics.csv", "baseline_comparison.json", "D3 freeze manifest"], + baseline=d3_strategy, + controls=controls, + next_step_ko="D3 baseline freeze artifact와 blockers를 먼저 확인한 뒤 D4는 baseline 대비 위험 overlay로만 연구합니다.", + guardrails=common_guardrails, + ), + _build_rl_guide_process_lane( + lane_id="D4_RL_RISK_OVERLAY", + title_ko="D4 RL 위험/비중 Overlay", + stage="D4", + status="RESEARCH_ONLY" if validation_status == "PASS" else validation_status, + goal_ko="D3 후보를 대체하지 않고 현금/노출/리밸런싱/감축 행동으로 위험을 조절하는지 검증합니다.", + state_setup=["position_count", "top_score_bucket", "cash_fraction", "exposure_fraction", "drawdown/turnover/concentration buckets는 다음 확장 후보"], + action_setup=["현재: hold/buy/add/sell/reduce", "목표: target_cash/25/50/75/100", "목표: reduce_risk/rebalance_to_d3_topk"], + reward_setup=["policy_daily_net_return - D3_baseline_daily_net_return", "23bp turnover cost", "drawdown/concentration/invalid/churn penalties"], + current_limitations=[ + "현재 정책은 tabular Q이며 신경망 policy_network artifact가 없습니다.", + "현재 D4 성과는 D3 best baseline보다 약합니다.", + "target exposure overlay action은 아직 D5 consumed schema로 증명되지 않았습니다.", + ], + improvement_directions=[ + "overlay action_schema_version을 환경/학습/D5에 먼저 추가합니다.", + "baseline-relative reward와 cost-first reward를 ablation으로 비교합니다.", + "action collapse(hold만 선택)와 invalid-action rate를 먼저 줄입니다.", + ], + metrics_to_watch=["delta_vs_best_d3_total_net_return", "action_distribution", "invalid_action_rate", "turnover", "drawdown", "concentration"], + required_artifacts=["state_observations.csv", "reward_breakdown.csv", "action_distribution.csv", "policy_nav.csv", "observation_manifest.json"], + baseline=d3_strategy, + controls=controls, + next_step_ko="overlay action schema가 D5에 소비되기 전까지 UI에는 현재 hold/buy/add/sell/reduce만 표시합니다.", + guardrails=common_guardrails, + ), + _build_rl_guide_process_lane( + lane_id="REWARD_ABLATION_LAB", + title_ko="Reward Ablation 연구", + stage="D4/D5", + status="RESEARCH_ONLY", + goal_ko="수익, 비용, drawdown, concentration, invalid, churn 벌점 중 어떤 보상 설계가 실패/개선에 영향을 주는지 분리합니다.", + state_setup=["same D4 state contract", "same split/fold", "same seed unless preregistered"], + action_setup=["same action schema", "invalid-action masking preserved", "no post-hoc action cherry-pick"], + reward_setup=["recorded_reward", "no_turnover_cost", "no_drawdown_penalty", "baseline_relative_reward", "risk_only_overlay_reward"], + current_limitations=[ + "ablation이 좋게 보여도 D5 NO-GO이면 승격 금지입니다.", + "하나의 ablation만 골라 수익 주장하면 안 됩니다.", + "비용 23bp/46bp 민감도 없이 결론을 내릴 수 없습니다.", + ], + improvement_directions=[ + "모든 ablation을 동일 fold/no-retune 조건으로 비교합니다.", + "cost/drawdown/concentration 제거 시 과최적화 여부를 확인합니다.", + "reward hacking 징후를 AI guidance format에 기록합니다.", + ], + metrics_to_watch=["reward_component_delta", "D3_delta", "turnover", "MDD", "invalid_action_rate", "fold_consistency"], + required_artifacts=["reward_action_ablations.csv", "reward_component_summary.json", "fold_metrics.csv"], + baseline=d3_strategy, + controls=controls, + next_step_ko="reward ablation 표준 포맷으로 어떤 벌점이 성과/위험을 망치는지 먼저 분해합니다.", + guardrails=common_guardrails, + ), + _build_rl_guide_process_lane( + lane_id="REGIME_DIAGNOSTICS", + title_ko="시장 국면/실패 진단", + stage="D7", + status="WATCH_DIAGNOSTIC", + goal_ko="변동성, breadth, score dispersion, drawdown regime별로 실패 원인을 찾아 다음 가설을 좁힙니다.", + state_setup=["volatility_bucket", "market_breadth_proxy", "score_spread_bucket", "drawdown_bucket"], + action_setup=["diagnostic grouping only", "no live action", "no broker order"], + reward_setup=["D3/D4 delta by regime", "fold failure attribution", "cost sensitivity by regime"], + current_limitations=[ + "현재는 진단용이며 regime 하나만 골라 GO로 바꾸면 안 됩니다.", + "D7 산출물이 없거나 stale이면 PLACEHOLDER/WATCH로만 표시해야 합니다.", + ], + improvement_directions=[ + "실패 fold와 성공 fold를 함께 보여주는 heatmap을 고정합니다.", + "regime별 action collapse와 turnover spike를 분리합니다.", + "다음 D4 state 후보를 regime diagnostic 결과에서만 선택합니다.", + ], + metrics_to_watch=["regime_delta", "fold_failure_count", "turnover_spike", "drawdown_bucket_loss", "score_dispersion"], + required_artifacts=["failure_reason_attribution.csv", "walk_forward_heatmap.csv", "regime_diagnostics.csv"], + baseline=d3_strategy, + controls=controls, + next_step_ko="성공 regime만 골라 주장하지 말고 실패 regime를 다음 state/reward 후보로 전환합니다.", + guardrails=common_guardrails, + ), + _build_rl_guide_process_lane( + lane_id="SCENARIO_AUTOMATION", + title_ko="시나리오 자동화/모델 후보 공장", + stage="Scenario", + status="RESEARCH_ONLY_AUTOMATION", + goal_ko="여러 가정/비용/seed/fold/action/reward 조합을 정해진 manifest로 반복 실행하고 NO-GO/개선 방향을 자동 기록합니다.", + state_setup=["scenario_id", "state_profile", "split_policy", "source_run_hashes"], + action_setup=["action_profile", "reward_profile", "max_turnover_budget", "max_drawdown_limit"], + reward_setup=["scenario gate status", "D3 delta", "cost sensitivity", "failure taxonomy"], + current_limitations=[ + "대시보드는 실행 버튼이 아니라 read-only ledger입니다.", + "자동화가 성공 주장을 만들지 않습니다. 실패/NO-GO를 더 빨리 찾는 장치입니다.", + "scenario manifest/hashes가 없으면 AI 개선 지시도 막아야 합니다.", + ], + improvement_directions=[ + "state/action/reward profile을 manifest로 고정하고 batch runner로만 실행합니다.", + "각 scenario가 같은 controls와 D5 no-retune gate를 통과하는지 비교합니다.", + "AI는 fixed ai_guidance_format만 읽고 다음 실험을 제안하게 합니다.", + ], + metrics_to_watch=["scenario_gate_status_counts", "failed_count", "D3_delta_distribution", "cost_46bp_stress", "manifest_hash_coverage"], + required_artifacts=["scenario_manifest.json", "scenario_batch_manifest.json", "comparison_rows.csv"], + baseline=d3_strategy, + controls=controls, + next_step_ko="대시보드에서 lane별 한계/개선 방향을 읽고 CLI scenario plan으로만 연구 실행합니다.", + guardrails=common_guardrails, + ), + ] + + blockers = [ + "BLOCKED_D5_NO_GO" if "NO-GO" in gate_status.upper() else "", + "BLOCKED_MODEL_BUILD_LOCKED", + "BLOCKED_PAPER_FORWARD_LOCKED", + "BLOCKED_LIVE_BROKER_ORDER_LOCKED", + ] + if not d5_consumed: + blockers.append("BLOCKED_D4_CONTRACT_NOT_CONSUMED_BY_D5") + return { + "schema_version": "daily_rl_research_process_catalog.v1", + "status": "RESEARCH_ONLY_PROCESS_GUIDE", + "source_run_id": portfolio.get("run_id") or "MISSING_SOURCE_RUN_ID", + "artifact_hashes": portfolio.get("artifact_hashes") or {"status": "MISSING_ARTIFACT_HASHES"}, + "policy_type": "tabular_q", + "action_schema_version": contract.get("schema_version"), + "headline": { + "best_d3_strategy": d3_strategy, + "best_d3_total_return_pct": d3_return * 100.0 if d3_return is not None else None, + "d4_delta_vs_best_d3_pct": d4_delta * 100.0 if d4_delta is not None else None, + "d5_status": gate_status, + "cost_round_trip_bp": contract.get("cost_round_trip_bp"), + }, + "selector_help_ko": "연구 lane을 선택하면 환경/상태/행동/보상/한계/개선 방향/AI 고정 포맷을 같은 구조로 확인합니다.", + "lanes": lanes, + "blockers": [blocker for blocker in blockers if blocker], + "guardrail": "Research process selector is read-only. It records what to test next; it does not run training, submit orders, unlock paper-forward, or claim profit.", + } + + +def load_daily_rl_env_guide() -> dict[str, Any]: + """Return a beginner-friendly, read-only map of the daily portfolio RL environment.""" + + contract = environment_contract(max_positions=5, score_column="score_supervised_linear_ranker", candidate_limit=20) + portfolio = load_portfolio_latest(sample_limit=25) + gate = load_walk_forward_latest(sample_limit=0) + portfolio_validation = portfolio.get("observation_manifest_validation") or {} + portfolio_manifest = portfolio.get("observation_manifest") or {} + gate_verdict = gate.get("verdict") or {} + validation_status = str( + portfolio_validation.get("status") + or (contract.get("observation_manifest_validation") or {}).get("status") + or "UNKNOWN" + ) + d5_consumed = bool(gate_verdict.get("d4_state_contract_artifacts_consumed")) + environment_built = validation_status == "PASS" and portfolio_manifest.get("gate", "D4_OBSERVATION_STATE_MANIFEST") == "D4_OBSERVATION_STATE_MANIFEST" + guide_blockers = [ + "BLOCKED_D5_NO_GO" if "NO-GO" in str(gate.get("status") or gate_verdict.get("status") or "").upper() else "", + "BLOCKED_MODEL_BUILD_LOCKED", + "BLOCKED_PAPER_FORWARD_LOCKED", + "BLOCKED_LIVE_BROKER_ORDER_LOCKED", + ] + if not portfolio.get("run_id"): + guide_blockers.append("MISSING_SOURCE_RUN_ID") + if not portfolio.get("artifact_hashes"): + guide_blockers.append("MISSING_ARTIFACT_HASHES") + if not d5_consumed: + guide_blockers.append("BLOCKED_D4_CONTRACT_NOT_CONSUMED_BY_D5") + signal_quality_summary = _build_rl_guide_signal_quality_summary() + scenario_generator = _build_rl_guide_scenario_generator(signal_quality_summary) + market_regime_audit_readiness = _build_rl_guide_market_regime_readiness(signal_quality_summary) + improvement_queue = _build_rl_guide_improvement_queue(signal_quality_summary) + scenario_comparison = _build_rl_guide_scenario_comparison(signal_quality_summary) + page_maturity_report = _build_rl_guide_page_maturity_report( + scenario_generator=scenario_generator, + signal_summary=signal_quality_summary, + market_regime_readiness=market_regime_audit_readiness, + improvement_queue=improvement_queue, + scenario_comparison=scenario_comparison, + ) + research_workflow_catalog = _build_research_workflow_catalog_payload( + signal_summary=signal_quality_summary, + scenario_generator=scenario_generator, + market_regime_readiness=market_regime_audit_readiness, + improvement_queue=improvement_queue, + scenario_comparison=scenario_comparison, + ) + research_job_intent_ledger = load_research_job_intent_ledger(limit=10) + rejection_analytics = load_rejection_analytics(limit=10) + dashboard_first_completion_report = _build_dashboard_first_completion_report( + workflow_catalog=research_workflow_catalog, + intent_ledger=research_job_intent_ledger, + rejection_analytics=rejection_analytics, + ) + + return { + "mode": "daily_ohlcv_rl_environment_guide", + "platform_stage": "RL_ENV_VISUAL_GUIDE_MVP", + "status": "RESEARCH_ONLY", + "read_only": True, + "schema_version": "daily_rl_env_guide.v2", + "source_run_id": portfolio.get("run_id") or "MISSING_SOURCE_RUN_ID", + "source_stage": "D4_D5_RESEARCH_ONLY", + "artifact_hashes": portfolio.get("artifact_hashes") or {"status": "MISSING_ARTIFACT_HASHES"}, + "policy_type": "tabular_q", + "action_schema_version": contract.get("schema_version"), + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "blockers": [blocker for blocker in guide_blockers if blocker], + "environment_built": environment_built, + "maturity": "RESEARCH_ONLY_ENV_BUILT_NOT_PROFIT_READY", + "plain_language_verdict": ( + "일봉 포트폴리오 강화학습 환경의 상태/행동/보상/마스크/누수 방지 계약은 구축되어 있습니다. " + "다만 현재 증거는 연구·시나리오 실험용이며 수익성, 실거래, 브로커 주문 준비 상태는 아닙니다." + ), + "what_rl_means_here": { + "agent": "정책/모델이 매일 관측(state)을 보고 행동(action)을 고릅니다.", + "environment": "DailyPortfolioEnv가 보유 종목, 후보 종목, 비용, 보상, 종료 여부를 계산합니다.", + "state": "미래 수익률을 보지 않고 현재 포지션 수와 현재 후보 점수 부호만 봅니다.", + "action": "hold/buy/add/sell/reduce 중 하나를 고르되 action mask가 불가능한 행동을 막습니다.", + "reward": "다음날 연구용 future_return_1d로 수익을 계산한 뒤 23bp 비용과 위험 벌점을 뺍니다.", + "episode": "날짜 순서대로 하루씩 진행한 전체 기간입니다.", + }, + "visual_flow": [ + {"id": "D2", "label": "D2 데이터셋", "summary": "일봉 feature/label/split", "status": "INPUT"}, + {"id": "D3", "label": "D3 예측/랭킹", "summary": "후보 점수와 baseline", "status": "INPUT"}, + {"id": "STATE", "label": "상태 관측", "summary": "position_count + top_score_bucket", "status": validation_status}, + {"id": "MASK", "label": "행동 마스크", "summary": "불가능한 buy/add/sell/reduce 차단", "status": validation_status}, + {"id": "ACTION", "label": "행동", "summary": "hold · buy · add · sell · reduce", "status": "RESEARCH_ONLY"}, + {"id": "REWARD", "label": "보상", "summary": "net return - cost/penalties", "status": "RESEARCH_ONLY"}, + {"id": "D5", "label": "워크포워드 게이트", "summary": "5-fold + 0/23/46bp + no retune", "status": gate.get("status", "NO-GO")}, + ], + "state_contract": contract.get("state", {}), + "action_space": contract.get("action_space", {}), + "action_mask": contract.get("action_mask", {}), + "reward_formula": contract.get("reward_formula"), + "reward_components": contract.get("reward_components", []), + "cost_round_trip_bp": contract.get("cost_round_trip_bp"), + "fill_assumption": contract.get("fill_assumption"), + "observation_manifest_status": (contract.get("observation_manifest") or {}).get("status"), + "observation_manifest_validation": contract.get("observation_manifest_validation", {}), + "learning_performance": _build_rl_guide_learning_performance(portfolio), + "active_replay": _build_rl_guide_active_replay(portfolio, contract), + "research_process_catalog": _build_rl_guide_research_process_catalog( + portfolio, + gate, + contract, + validation_status=validation_status, + d5_consumed=d5_consumed, + ), + "scenario_generator": scenario_generator, + "signal_quality_audit_summary": signal_quality_summary, + "market_regime_audit_readiness": market_regime_audit_readiness, + "improvement_queue": improvement_queue, + "scenario_comparison": scenario_comparison, + "page_maturity_report": page_maturity_report, + "research_workflow_catalog": research_workflow_catalog, + "research_job_intent_ledger": research_job_intent_ledger, + "rejection_analytics": rejection_analytics, + "dashboard_first_completion_report": dashboard_first_completion_report, + "current_artifact_evidence": { + "d4_status": portfolio.get("status"), + "d4_readiness_status": portfolio.get("readiness_status"), + "d4_observation_manifest_gate": portfolio_manifest.get("gate"), + "d4_observation_manifest_validation_status": validation_status, + "d4_reward_action_telemetry_sufficient_for_d4": portfolio_manifest.get("reward_action_telemetry_sufficient_for_d4"), + "d5_status": gate.get("status"), + "d5_readiness_status": gate.get("readiness_status"), + "d5_d4_state_contract_artifacts_consumed": d5_consumed, + "d5_d4_state_observation_rows": gate_verdict.get("d4_state_observation_rows"), + "d5_d4_reward_action_ablation_rows": gate_verdict.get("d4_reward_action_ablation_rows"), + }, + "well_built_checks": [ + { + "check": "state_has_no_future_label", + "status": "PASS", + "meaning_ko": "관측값에는 future_return_1d가 들어가지 않습니다.", + }, + { + "check": "action_mask_available", + "status": "PASS" if contract.get("action_mask") else "FAIL", + "meaning_ko": "불가능한 행동을 마스크와 사유로 기록합니다.", + }, + { + "check": "reward_components_logged", + "status": "PASS" if contract.get("reward_components") else "FAIL", + "meaning_ko": "수익, 비용, 노출, 집중도, 무효행동, drawdown 벌점이 분해됩니다.", + }, + { + "check": "d4_observation_manifest_validation", + "status": validation_status, + "meaning_ko": "D4 상태 계약 검증 결과입니다.", + }, + { + "check": "d5_consumes_d4_state_contract", + "status": "PASS" if d5_consumed else "WATCH", + "meaning_ko": "워크포워드 게이트가 D4 상태/보상/액션 증거를 읽었는지입니다.", + }, + ], + "good_enough_for": [ + "여러 가정/시나리오 모델 smoke·mid 실험", + "action mask와 reward 설계 검증", + "D3 baseline 대비 D4/D5 연구 비교", + "실패 원인과 다음 가설 생성", + ], + "not_good_enough_for": [ + "수익성 주장", + "실거래 또는 브로커 주문", + "paper-forward unlock", + "D0/D1/D5 blocker를 무시한 모델 승격", + ], + "guardrail": "Daily RL environment guide is explanatory/read-only; no profit guarantee, no live/broker/orders, and no deployable model readiness claim.", + } + + +def load_daily_flow_chart() -> dict[str, Any]: + progress = load_daily_progress() + nodes = [ + { + "id": stage.get("id"), + "label": stage.get("label"), + "status": stage.get("status"), + "severity": _status_severity(stage.get("status")), + "evidence": stage.get("evidence"), + "usage_guide": stage.get("usage_guide"), + } + for stage in progress.get("stages", []) + ] + edges = [ + {"from": "D0", "to": "D1", "label": "DB scope"}, + {"from": "D1", "to": "D2", "label": "allowed universe"}, + {"from": "D2", "to": "D3", "label": "causal dataset"}, + {"from": "D3", "to": "D4", "label": "baseline threshold"}, + {"from": "D4", "to": "D5", "label": "RL candidate evidence"}, + {"from": "D5", "to": "D6", "label": "gate visualization"}, + {"from": "D6", "to": "D7", "label": "research diagnostics"}, + {"from": "D7", "to": "D8", "label": "registry evidence"}, + {"from": "D8", "to": "D9", "label": "paper-forward lock"}, + ] + return { + "status": progress.get("overall_status"), + "model_build_allowed": progress.get("model_build_allowed", False), + "go_summary_allowed": progress.get("go_summary_allowed", False), + "nodes": nodes, + "edges": edges, + "guardrail": "Flow map shows research evidence dependencies only; it does not unlock model training, broker, live, or order behavior.", + } + + +def load_daily_metric_glossary() -> dict[str, Any]: + return { + "status": "REFERENCE_ONLY", + "items": [ + {"term": "NO-GO", "meaning": "현재 증거로 모델 빌드/GO 요약을 허용하지 않는 상태.", "guardrail": "실패를 숨기지 않는다."}, + {"term": "WATCH", "meaning": "분류·가격·검증 중 하나가 아직 확정되지 않아 주의가 필요한 상태.", "guardrail": "WATCH는 PASS가 아니다."}, + {"term": "RESEARCH_ONLY", "meaning": "연구 산출물은 존재하지만 선택/실거래/배포 증거로 쓰면 안 되는 상태.", "guardrail": "D4 RL에 적용."}, + {"term": "MDD", "meaning": "고점 대비 최대 낙폭. 우상향 착시를 막는 핵심 리스크 지표.", "guardrail": "수익률과 항상 함께 봐야 한다."}, + {"term": "Turnover", "meaning": "포트폴리오 교체 강도. 높을수록 23bp 비용 압력이 커진다.", "guardrail": "비용 전 수익률 착시를 막는다."}, + {"term": "Hit rate", "meaning": "양(+)의 순수익 거래/일자 비율.", "guardrail": "단독으로는 수익성 증거가 아니다."}, + {"term": "Shuffle control", "meaning": "라벨/순서를 섞은 음성 대조군.", "guardrail": "알파 주장은 shuffle보다 좋아야 한다."}, + {"term": "Fold consistency", "meaning": "워크포워드 fold별 성과 일관성.", "guardrail": "한 fold cherry-pick 금지."}, + {"term": "model_build_allowed", "meaning": "모델 구현/학습 후보로 넘어갈 수 있는 게이트 플래그.", "guardrail": "false이면 UI도 구현 GO처럼 보이면 안 된다."}, + {"term": "Learning curve", "meaning": "학습 중 reward/return 변화. 안정화 여부와 과적합 의심 구간을 찾는 진단 그래프.", "guardrail": "상승 곡선만으로 수익성 또는 배포 가능성을 주장하지 않는다."}, + {"term": "Action distribution", "meaning": "hold/buy/sell 또는 target weight 선택 빈도. 한 행동 쏠림과 invalid action을 확인한다.", "guardrail": "정책 의도 설명용이며 주문 신호가 아니다."}, + {"term": "Portfolio trajectory", "meaning": "NAV, drawdown, turnover, cost, concentration이 시간에 따라 어떻게 움직였는지 보는 경로.", "guardrail": "D3/D5 control과 함께 보지 않은 trajectory는 선택 근거가 아니다."}, + {"term": "Symbol drilldown", "meaning": "개별 종목 OHLCV, 기간, 결측·보정 의심을 검사하는 읽기 전용 표/막대.", "guardrail": "개별 종목 매수·매도 추천 화면이 아니다."}, + ], + "guardrail": "Metric glossary is explanatory only: research signal, not production proof, no profit/live/broker/order readiness.", + } +def load_research_diagnostics_chart() -> dict[str, Any]: + prediction = load_prediction_latest(sample_limit=0) + portfolio = load_portfolio_latest(sample_limit=0) + gate = load_walk_forward_latest(sample_limit=0) + gate_verdict = gate.get("verdict") or {} + diagnostics = [ + { + "id": "D7_FEATURE_DIAGNOSTICS", + "label": "Feature diagnostics", + "status": "PLACEHOLDER_READY", + "summary": "ret_1d/3d/5d/20d, volatility, momentum, rank score contribution and drift slices are planned as read-only diagnostics.", + "evidence": f"D3 best={(prediction.get('verdict') or {}).get('best_strategy_by_total_net_return')} price_basis={prediction.get('price_basis')}", + "next_artifact": "feature_importance_by_fold.csv", + "guardrail": "feature importance is explanatory, not a profit claim.", + "allowed_use": "feature별 fold 기여도와 drift를 비교해 D3/D4 실패 원인을 설명한다.", + "blocked_use": "feature 중요도를 곧바로 종목 선택, 수익 주장, live signal로 사용하지 않는다.", + "how_to_read_ko": "fold마다 같은 feature가 반복적으로 유효한지, price_basis unknown에 민감한 feature인지 먼저 본다.", + "current_gap": "feature_importance_by_fold.csv가 생성되기 전까지 PLACEHOLDER_READY입니다.", + }, + { + "id": "D7_REGIME_DIAGNOSTICS", + "label": "Regime diagnostics", + "status": "PLACEHOLDER_READY", + "summary": "market regime buckets should compare trend/volatility/liquidity periods before any swing-trading claim.", + "evidence": f"D5 status={gate.get('status')} reasons={','.join((gate_verdict.get('reasons') or [])[:4])}", + "next_artifact": "regime_bucket_metrics.csv", + "guardrail": "regime labels must be forward-only and cannot retune OOS folds.", + "allowed_use": "추세·변동성·유동성 regime별로 baseline/RL 성능이 무너지는 구간을 찾는다.", + "blocked_use": "OOS fold를 보고 regime 정의를 재튜닝하지 않는다.", + "how_to_read_ko": "regime bucket은 forward-only 규칙이어야 하며, 한 구간의 좋은 결과를 전체 성과처럼 말하지 않는다.", + "current_gap": "regime_bucket_metrics.csv가 생성되기 전까지 PLACEHOLDER_READY입니다.", + }, + { + "id": "D7_CORRELATION_RISK", + "label": "Correlation and concentration", + "status": "PLACEHOLDER_READY", + "summary": "portfolio trajectory, concentration, and universe clusters should be compared to avoid single-theme exposure.", + "evidence": f"D4 validation={(portfolio.get('observation_manifest_validation') or {}).get('status')} state_contract={(portfolio.get('telemetry') or {}).get('state_contract')}", + "next_artifact": "correlation_cluster_summary.csv", + "guardrail": "correlation views are risk diagnostics, not selection proof.", + "allowed_use": "상관·집중도·테마 쏠림을 보며 포트폴리오 리스크를 설명한다.", + "blocked_use": "상관 클러스터를 종목 추천 또는 배포 가능한 allocation으로 사용하지 않는다.", + "how_to_read_ko": "고상관 cluster가 손실 fold와 겹치는지 확인하고 concentration penalty 가설로만 사용한다.", + "current_gap": "correlation_cluster_summary.csv가 생성되기 전까지 PLACEHOLDER_READY입니다.", + }, + { + "id": "D7_FAILURE_ANALYSIS", + "label": "Failure analysis", + "status": "PLACEHOLDER_READY", + "summary": "NO-GO reasons, missing labels, invalid actions, drawdown spikes, and fold failures should be grouped before new RL reward changes.", + "evidence": f"model_build_allowed={gate_verdict.get('model_build_allowed')} go_summary_allowed={gate_verdict.get('go_summary_allowed')}", + "next_artifact": "failure_reason_attribution.csv", + "guardrail": "failure visibility is mandatory; do not hide weak or flat RL outcomes.", + "allowed_use": "NO-GO reason, invalid action, drawdown spike, fold failure를 묶어 다음 실험 가설을 만든다.", + "blocked_use": "실패 fold를 숨기거나 성공 fold만 골라 GO처럼 표현하지 않는다.", + "how_to_read_ko": "먼저 D0/D1/D3/D5 blocker를 확인하고, reward/action 변경은 그 다음에 사전등록한다.", + "current_gap": "failure_reason_attribution.csv가 생성되기 전까지 PLACEHOLDER_READY입니다.", + }, + ] + return { + "status": "WATCH", + "model_build_allowed": False, + "go_summary_allowed": False, + "cards": diagnostics, + "items": diagnostics, + "usage_guide": [_usage_for_stage("D7")], + "summary": { + "korean": "D7 연구 진단은 feature/regime/correlation/failure 원인 분석을 위한 읽기 전용 확장 영역입니다.", + "current_contract": "PLACEHOLDER_READY_UNTIL_ARTIFACTS_EXIST", + "required_before_claim": [ + "feature importance by fold", + "regime bucket metrics", + "correlation/concentration diagnostics", + "failure attribution", + ], + "how_to_use": "D7은 새 모델을 고르는 화면이 아니라, 실패 원인과 다음 가설을 좁히는 연구 노트입니다.", + }, + "guardrail": "D7 diagnostics are explanatory research surfaces only; no profit, live, broker, order, or deployable model claim.", + } + + + +def _sample_points(points: list[dict[str, Any]], *, maximum: int = 240) -> list[dict[str, Any]]: + if len(points) <= maximum: + return points + step = max(1, len(points) // maximum) + sampled = points[::step] + if sampled[-1] != points[-1]: + sampled.append(points[-1]) + return sampled[: maximum + 1] + + +def _metric_severity(metric: str, value: float | None) -> str: + if value is None: + return "block" + if metric == "max_drawdown": + return "block" if value <= -0.2 else "watch" if value < -0.1 else "pass" + if metric in {"total_net_return", "delta_vs_no_trade_total_net_return", "delta_vs_shuffled_total_net_return", "hit_rate"}: + return "pass" if value > 0 else "block" + if metric == "mean_turnover": + return "watch" if value > 0.5 else "pass" + return "neutral" + + +def load_equity_overlay_chart(*, run: str | None = None) -> dict[str, Any]: + prediction_dir = _latest_run_dir(DEFAULT_PREDICTION_ROOT, required_file="prediction_manifest.json", run_id=run) + if prediction_dir is None: + return load_not_started_surface("equity_overlay") + prediction = load_prediction_latest(run=run, sample_limit=0) + baseline_curves: list[dict[str, Any]] = [] + grouped: dict[str, list[dict[str, Any]]] = {} + for row in _read_csv_rows(prediction_dir / "drawdown.csv", MAX_LIMIT): + strategy = str(row.get("strategy") or "unknown") + equity = _to_float(row.get("equity")) + net_return = _to_float(row.get("net_return")) + grouped.setdefault(strategy, []).append( + { + "x": row.get("date"), + "y": equity, + "net_return": net_return, + "evidence_status": "LOADED" if equity is not None and net_return is not None else "INCOMPLETE_NUMERIC_EVIDENCE", + } + ) + for strategy, points in grouped.items(): + baseline_curves.append( + { + "id": f"D3:{strategy}", + "label": strategy, + "kind": "daily_baseline", + "status": prediction.get("status"), + "points": _sample_points(points), + } + ) + + portfolio_curve: list[dict[str, Any]] = [] + portfolio_dir = _latest_run_dir(DEFAULT_PORTFOLIO_ROOT, required_file="rl_manifest.json") + if portfolio_dir is not None: + by_date: dict[str, float | None] = {} + for row in _read_csv_rows(portfolio_dir / "positions.csv", MAX_LIMIT): + date = str(row.get("date") or "") + if date: + by_date[date] = _to_float(row.get("equity")) + if not by_date: + for row in _read_csv_rows(portfolio_dir / "reward_breakdown.csv", MAX_LIMIT): + date = str(row.get("date") or "") + if date: + by_date[date] = _to_float(row.get("equity")) + portfolio_curve = [ + {"x": date, "y": equity, "evidence_status": "LOADED" if equity is not None else "INCOMPLETE_NUMERIC_EVIDENCE"} + for date, equity in sorted(by_date.items()) + ] + + gate_curve: list[dict[str, Any]] = [] + gate_dir = _latest_run_dir(DEFAULT_WALK_FORWARD_ROOT, required_file="walk_forward_manifest.json") + gate = load_walk_forward_latest(sample_limit=0) + selected_strategy = (gate.get("verdict") or {}).get("selected_strategy") + if gate_dir is not None: + fold_equity = 1.0 + for row in _read_csv_rows(gate_dir / "fold_metrics.csv", MAX_LIMIT): + if row.get("control") != "actual" or (selected_strategy and row.get("strategy") != selected_strategy): + continue + net_return = _to_float(row.get("total_net_return")) + if net_return is not None: + fold_equity += net_return + gate_curve.append( + { + "x": row.get("fold_id"), + "y": fold_equity if net_return is not None else None, + "net_return": net_return, + "evidence_status": "LOADED" if net_return is not None else "INCOMPLETE_NUMERIC_EVIDENCE", + } + ) + + curves = baseline_curves + if portfolio_curve: + curves.append( + { + "id": "D4:portfolio_rl", + "label": "D4 constrained portfolio RL", + "kind": "rl_research_only", + "status": "RESEARCH_ONLY", + "points": _sample_points(portfolio_curve), + } + ) + if gate_curve: + curves.append( + { + "id": "D5:walk_forward_selected", + "label": "D5 selected walk-forward folds", + "kind": "walk_forward_gate", + "status": gate.get("status"), + "points": gate_curve, + } + ) + + return { + "status": gate.get("status") or prediction.get("status"), + "run_id": prediction.get("run_id"), + "selected_strategy": selected_strategy, + "curves": curves, + "guardrail": "Equity overlay is historical research visualization only; it is not a profit, broker, live, order, or deployable RL claim.", + } + + +def load_walk_forward_heatmap_chart(*, run: str | None = None) -> dict[str, Any]: + gate_dir = _latest_run_dir(DEFAULT_WALK_FORWARD_ROOT, required_file="walk_forward_manifest.json", run_id=run) + if gate_dir is None: + return load_not_started_surface("walk_forward_heatmap") + gate = load_walk_forward_latest(run=run, sample_limit=0) + verdict = gate.get("verdict") or {} + selected_strategy = verdict.get("selected_strategy") + metric_keys = [ + "total_net_return", + "max_drawdown", + "delta_vs_no_trade_total_net_return", + "delta_vs_shuffled_total_net_return", + "mean_turnover", + "hit_rate", + ] + cells: list[dict[str, Any]] = [] + for row in _read_csv_rows(gate_dir / "fold_metrics.csv", MAX_LIMIT): + if row.get("control") != "actual" or (selected_strategy and row.get("strategy") != selected_strategy): + continue + for metric in metric_keys: + value = _to_float(row.get(metric)) + cells.append( + { + "fold_id": row.get("fold_id"), + "strategy": row.get("strategy"), + "metric": metric, + "value": value, + "severity": _metric_severity(metric, value), + "evidence_status": "LOADED" if value is not None else "INCOMPLETE_NUMERIC_EVIDENCE", + } + ) + cost_series = [ + { + "fold_id": row.get("fold_id"), + "cost_bp": _to_float(row.get("cost_bp")), + "total_net_return": _to_float(row.get("total_net_return")), + "max_drawdown": _to_float(row.get("max_drawdown")), + "strategy": row.get("strategy"), + } + for row in _read_csv_rows(gate_dir / "cost_sensitivity.csv", MAX_LIMIT) + if not selected_strategy or row.get("strategy") == selected_strategy + ] + effective_gate = _effective_daily_model_gate( + db=load_daily_db_summary(table_limit=0, flag_limit=0, window_limit=0), + universe=load_universe_preview(limit=0), + prediction=load_prediction_latest(sample_limit=0), + gate_verdict=verdict, + gate_status=gate.get("status"), + ) + return { + "status": gate.get("status"), + "readiness_status": gate.get("readiness_status") or D5_RESEARCH_READINESS_STATUS, + "run_id": gate.get("run_id"), + "selected_strategy": selected_strategy, + "model_build_allowed": False, + "go_summary_allowed": False, + "paper_forward_allowed": False, + "live_broker_order_allowed": False, + "no_live_broker_order_readiness": True, + "effective_gate_blockers": effective_gate["effective_gate_blockers"], + "cells": cells, + "cost_series": cost_series, + "guardrail": "Heatmap shows D5 gate evidence under cost/shuffle controls only; it does not authorize model build, profit, live, broker, or orders.", + } + + +def load_run_scatter_chart() -> dict[str, Any]: + prediction = load_prediction_latest(sample_limit=500) + portfolio = load_portfolio_latest(sample_limit=0) + gate = load_walk_forward_latest(sample_limit=0) + points: list[dict[str, Any]] = [] + for row in prediction.get("baseline_metrics") or []: + positions = _to_int(row.get("positions"), 1) or 1 + points.append( + { + "id": f"D3:{row.get('strategy')}", + "label": row.get("strategy"), + "kind": "daily_baseline", + "status": prediction.get("status"), + "x_max_drawdown": _to_float(row.get("max_drawdown")), + "y_total_net_return": _to_float(row.get("total_net_return")), + "size": max(1, positions), + } + ) + for row in ((portfolio.get("policy_metrics") or {}).get("metrics") or []): + positions = _to_int(row.get("positions"), 1) or 1 + points.append( + { + "id": f"D4:{row.get('split')}", + "label": f"D4 {row.get('split')}", + "kind": "daily_rl", + "status": portfolio.get("status"), + "x_max_drawdown": _to_float(row.get("max_drawdown")), + "y_total_net_return": _to_float(row.get("total_net_return")), + "size": max(1, positions), + } + ) + gate_dir = _latest_run_dir(DEFAULT_WALK_FORWARD_ROOT, required_file="walk_forward_manifest.json") + if gate_dir is not None: + verdict = gate.get("verdict") or {} + selected_strategy = verdict.get("selected_strategy") + selected_rows = [ + row + for row in _read_csv_rows(gate_dir / "fold_metrics.csv", MAX_LIMIT) + if row.get("control") == "actual" and (not selected_strategy or row.get("strategy") == selected_strategy) + ] + if selected_rows: + drawdowns = [_to_float(row.get("max_drawdown")) for row in selected_rows] + returns = [_to_float(row.get("total_net_return")) for row in selected_rows] + positions = [_to_int(row.get("positions")) for row in selected_rows] + clean_drawdowns = [value for value in drawdowns if value is not None] + clean_returns = [value for value in returns if value is not None] + clean_positions = [value for value in positions if value is not None] + points.append( + { + "id": "D5:selected_walk_forward", + "label": f"D5 {selected_strategy}", + "kind": "walk_forward_gate", + "status": gate.get("status"), + "x_max_drawdown": min(clean_drawdowns) if clean_drawdowns else None, + "y_total_net_return": sum(clean_returns) if clean_returns else None, + "size": sum(max(0, value) for value in clean_positions), + "evidence_status": "LOADED" if len(clean_drawdowns) == len(selected_rows) and len(clean_returns) == len(selected_rows) else "INCOMPLETE_NUMERIC_EVIDENCE", + } + ) + return { + "status": gate.get("status"), + "points": points, + "guardrail": "Risk/return scatter compares research artifacts only; positive points are not profit or deployment proof.", + } + + +def load_universe_breakdown_chart() -> dict[str, Any]: + manifest = load_universe_preview(limit=0) + return { + "status": manifest.get("verdict") or "WATCH", + "counts_by_type": manifest.get("counts_by_type") or {}, + "counts_by_market": manifest.get("counts_by_market") or {}, + "counts_by_exclusion_reason": manifest.get("counts_by_exclusion_reason") or {}, + "summary": { + "include_count": manifest.get("include_count"), + "exclude_count": manifest.get("exclude_count"), + "q_product_count": manifest.get("q_product_count"), + "unmatched_quarantine_count": manifest.get("unmatched_quarantine_count"), + "stockinfo_matched_table_count": manifest.get("stockinfo_matched_table_count"), + "stockinfo_unmatched_table_count": manifest.get("stockinfo_unmatched_table_count"), + "official_metadata_status": manifest.get("official_metadata_status"), + "official_metadata_coverage_status": manifest.get("official_metadata_coverage_status"), + "universe_certification_status": manifest.get("universe_certification_status"), + }, + "guardrail": "Universe breakdown is heuristic WATCH until official metadata/manual review confirms KOSPI/KOSDAQ common-stock classification.", + } + + +def load_symbol_chart(symbol_or_table: str, *, sample_limit: int = 160) -> dict[str, Any]: + symbol = load_daily_symbol(symbol_or_table, sample_limit=_bounded_limit(sample_limit, default=160, maximum=200)) + rows = list(symbol.get("sample_rows_desc") or []) + rows.reverse() + points = [ + { + "date": row.get("date"), + "open": _to_float(row.get("open")), + "high": _to_float(row.get("high")), + "low": _to_float(row.get("low")), + "close": _to_float(row.get("close")), + "volume": _to_float(row.get("volume")), + } + for row in rows + ] + return { + "status": "PASS" if points else "WATCH", + "code": symbol.get("code"), + "table": symbol.get("table"), + "price_basis": symbol.get("price_basis"), + "row_count": symbol.get("row_count"), + "range": {"first_date": symbol.get("first_date"), "last_date": symbol.get("last_date")}, + "ohlcv": points, + "guardrail": "Symbol OHLCV chart is historical read-only inspection only; it is not a prediction, trade signal, or profit claim.", + "usage_guide": [ + { + "section": "symbol_ohlcv_preview", + "can_do": "개별 종목의 날짜 범위, OHLCV 막대, 거래량, 결측/불완전 evidence를 점검한다.", + "must_not": "선택된 종목을 매수/매도 추천, 수익 보장, 실거래 준비 증거로 해석하지 않는다.", + "next_action": "price_basis가 unknown이면 분할·배당·adjusted/raw 기준을 먼저 확인한다.", + } + ], + } + + +def load_daily_artifacts(limit: int = 50) -> dict[str, Any]: + artifacts: list[dict[str, Any]] = [] + safe_limit = _bounded_limit(limit, default=50, maximum=500) + for root, required, kind in ( + (DB_SUMMARY_ROOT.resolve(), "db_summary.json", "db_summary"), + (DEFAULT_UNIVERSE_ROOT.resolve(), "universe.json", "universe_manifest"), + (DEFAULT_DATASET_ROOT.resolve(), "dataset_manifest.json", "daily_ohlcv_dataset"), + (DEFAULT_PREDICTION_ROOT.resolve(), "prediction_manifest.json", "daily_ohlcv_prediction"), + (DEFAULT_PORTFOLIO_ROOT.resolve(), "rl_manifest.json", "daily_ohlcv_portfolio_rl"), + (DEFAULT_WALK_FORWARD_ROOT.resolve(), "walk_forward_manifest.json", "daily_ohlcv_walk_forward"), + (DEFAULT_DAILY_REGISTRY_ROOT.resolve(), "registry_manifest.json", "daily_ohlcv_registry"), + ): + if not root.exists(): + continue + for run_dir in sorted(root.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True): + path = run_dir / required + if run_dir.is_dir() and path.exists(): + artifacts.append( + { + "kind": kind, + "run_id": run_dir.name, + "artifact_dir": str(run_dir), + "primary_file": str(path), + "size_bytes": path.stat().st_size, + "modified_at": path.stat().st_mtime, + } + ) + artifacts = sorted(artifacts, key=lambda row: row["modified_at"], reverse=True) + return { + "artifacts": artifacts[:safe_limit], + "artifacts_total": len(artifacts), + "artifacts_truncated": len(artifacts) > safe_limit, + "limit": safe_limit, + "read_only": True, + } + + +def load_daily_progress() -> dict[str, Any]: + db = load_daily_db_summary(table_limit=0, flag_limit=0, window_limit=0) + universe = load_universe_preview(limit=0) + dataset = load_dataset_latest(sample_limit=0) + prediction = load_prediction_latest(sample_limit=0) + portfolio = load_portfolio_latest(sample_limit=0) + gate = load_walk_forward_latest(sample_limit=0) + gate_verdict = gate.get("verdict") or {} + registry = load_registry_latest(sample_limit=0) + diagnostics = load_research_diagnostics_chart() + baseline_delta = prediction.get("baseline_delta_summary") or {} + effective_gate = _effective_daily_model_gate( + db=db, + universe=universe, + prediction=prediction, + gate_verdict=gate_verdict, + gate_status=gate.get("status"), + ) + stages = [ + _progress_stage( + "D0", + "DB 분석", + "PASS" if db.get("table_count") else "BLOCKED", + ( + f"tables={db.get('table_count')} rows={db.get('total_rows')} " + f"price_basis={db.get('price_basis')} status={db.get('price_basis_status')} " + f"decision_grade={db.get('decision_grade_return_status')} " + f"quality_scope={db.get('quality_scan_scope')}" + ), + ), + _progress_stage( + "D1", + "유니버스 관리", + "WATCH", + ( + f"include={universe.get('include_count')} exclude={universe.get('exclude_count')} " + f"matched={universe.get('stockinfo_matched_table_count')} unmatched={universe.get('stockinfo_unmatched_table_count')} " + f"official={universe.get('official_metadata_status')} verdict={universe.get('verdict')}" + ), + ), + _progress_stage( + "D2", + "데이터셋", + dataset.get("status", "NOT_STARTED"), + ( + f"rows={(dataset.get('row_counts') or {}).get('feature_rows')} " + f"eligible={(dataset.get('row_counts') or {}).get('eligible_rows')} " + f"leakage={dataset.get('leakage_status')} split={dataset.get('split_chronology_status')} " + f"price_basis={dataset.get('price_basis')} universe={dataset.get('universe_verdict')} " + f"scope={dataset.get('artifact_scope')}" + ), + ), + _progress_stage( + "D3", + "예측/Top-K", + prediction.get("status", "NOT_STARTED"), + ( + f"best={(prediction.get('verdict') or {}).get('best_strategy_by_total_net_return')} " + f"go_summary={(prediction.get('verdict') or {}).get('go_summary_allowed')} " + f"model_build={baseline_delta.get('model_build_allowed')} price_basis={prediction.get('price_basis')} " + f"control={baseline_delta.get('shuffle_control_strategy')} best_rule={baseline_delta.get('best_rule_baseline_strategy')} " + f"cost={baseline_delta.get('cost_round_trip_bp')}bp" + ), + ), + _progress_stage( + "D4", + "포트폴리오 RL", + portfolio.get("status", "NOT_STARTED"), + ( + f"badge={(portfolio.get('verdict') or {}).get('ui_badge')} " + f"state_contract={portfolio.get('state_contract_status') or (portfolio.get('observation_manifest_validation') or {}).get('status')} " + f"unlocked={(portfolio.get('verdict') or {}).get('implementation_unlocked')}" + ), + ), + _progress_stage( + "D5", + "워크포워드/게이트", + gate.get("status", "NOT_STARTED"), + ( + f"folds={gate_verdict.get('n_folds')} no_oos={gate_verdict.get('no_oos_retuning')} " + f"d4_state={gate_verdict.get('d4_state_contract_status')} " + f"model_build_allowed={effective_gate['model_build_allowed']} " + f"effective_lock={','.join(effective_gate['effective_gate_blockers'][:3])} " + f"reasons={','.join((gate_verdict.get('reasons') or [])[:4])}" + ), + ), + _progress_stage( + "D6", + "대시보드 시각화", + "PASS" if gate.get("status") != "NOT_STARTED" else "NOT_STARTED", + ( + "Decision cockpit, glossary, flow, heatmap, scatter, equity overlay, universe breakdown, " + "symbol chart, and registry panels are visible as read-only evidence surfaces." + if gate.get("status") != "NOT_STARTED" + else "D3-D5 artifacts required before full D6 result visualization." + ), + ), + _progress_stage( + "D7", + "연구 진단", + diagnostics.get("status", "WATCH"), + ( + f"contract={(diagnostics.get('summary') or {}).get('current_contract')} " + "feature/regime/correlation/failure placeholders visible; no alpha/profit claim." + ), + ), + _progress_stage( + "D8", + "레지스트리", + registry.get("status", "NOT_STARTED"), + ( + f"promotion={registry.get('promotion_status')} model_build_allowed={registry.get('model_build_allowed')} " + f"paper_forward_allowed={registry.get('paper_forward_allowed')} code_hash={str(registry.get('code_hash') or '—')[:12]}" + ), + ), + _progress_stage( + "D9", + "페이퍼 포워드", + registry.get("promotion_status", registry.get("status", "NOT_STARTED")), + ( + f"paper_selected={(registry.get('candidate_registry') or {}).get('selected_strategy') or 'BLOCKED'} " + f"live_broker_order_allowed={registry.get('live_broker_order_allowed')} " + f"no_live_broker_order_readiness={registry.get('no_live_broker_order_readiness')}" + ), + ), + ] + provenance_matrix = [ + { + "id": stage["id"], + "status": stage["status"], + "lock_labels": stage.get("lock_labels", []), + "verification_commands": stage.get("verification_commands", []), + } + for stage in stages + ] + overall_status = "D0_D9_EVIDENCE_VISIBLE_MODEL_BUILD_NO_GO" if gate.get("status") != "NOT_STARTED" else "D0_D2_DATASET_READY_WATCH_D3_LOCKED" + return { + "mode": "daily_ohlcv_research_only", + "overall_status": overall_status, + "model_build_allowed": effective_gate["model_build_allowed"], + "go_summary_allowed": effective_gate["go_summary_allowed"], + "guardrail": "Daily OHLCV dashboard is read-only D0-D9 evidence. no live/broker/orders, no profit claim, no RL model readiness.", + "stages": stages, + "provenance_matrix": provenance_matrix, + "page_usage_guide": [_usage_for_stage(stage_id) for stage_id in DAILY_PAGE_USAGE_GUIDE], + "verification_commands": DAILY_STAGE_VERIFICATION_COMMANDS, + "research_diagnostics": diagnostics, + "effective_gate_blockers": effective_gate["effective_gate_blockers"], + } + + +def load_coverage_chart() -> dict[str, Any]: + db = load_daily_db_summary(table_limit=0, flag_limit=0, window_limit=0) + return { + "latest_date": db.get("latest_date"), + "series": [ + {"label": "latest_date_tables", "value": db.get("tables_at_latest_date")}, + {"label": "total_tables", "value": db.get("table_count")}, + {"label": "total_rows", "value": db.get("total_rows")}, + ], + "price_basis": db.get("price_basis"), + "decision_grade_status": db.get("decision_grade_status"), + } + + +def load_universe_chart() -> dict[str, Any]: + manifest = load_universe_preview(limit=0) + return { + "verdict": manifest.get("verdict"), + "counts_by_type": manifest.get("counts_by_type") or {}, + "counts_by_market": manifest.get("counts_by_market") or {}, + "counts_by_exclusion_reason": manifest.get("counts_by_exclusion_reason") or {}, + "official_metadata_status": manifest.get("official_metadata_status"), + "official_metadata_coverage_status": manifest.get("official_metadata_coverage_status"), + "universe_certification_status": manifest.get("universe_certification_status"), + "universe_blocked_uses": manifest.get("universe_blocked_uses") or [], + } + + +def load_not_started_surface(surface: str) -> dict[str, Any]: + return { + "surface": surface, + "status": "NOT_STARTED", + "guardrail": "D3-D6 model/result surfaces remain locked until their evidence artifacts exist; no model/profit/live readiness claim.", + "required_before_go": ["D3 baselines", "D5 walk-forward controls", "D1/D2 architecture checkpoint"], + } diff --git a/webui/rl_dashboard.py b/webui/rl_dashboard.py new file mode 100644 index 000000000..ea46cebc9 --- /dev/null +++ b/webui/rl_dashboard.py @@ -0,0 +1,71 @@ +"""Read-only dashboard helpers for independent STOM RL artifacts. + +The implementation is split by responsibility so path safety, run details, +table readers, and progress synthesis remain reviewable. This module preserves +legacy imports and test monkeypatching of ``RL_RUN_ROOTS``. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional + +try: + from . import rl_dashboard_files as _files + from . import rl_dashboard_progress as _progress + from . import rl_dashboard_runs as _runs + from . import rl_dashboard_tables as _tables +except ImportError: # pragma: no cover - supports direct script-style imports + import rl_dashboard_files as _files + import rl_dashboard_progress as _progress + import rl_dashboard_runs as _runs + import rl_dashboard_tables as _tables + +REPO_ROOT = _files.REPO_ROOT +WEBUI_ROOT = _files.WEBUI_ROOT +MAX_TABLE_LIMIT = _files.MAX_TABLE_LIMIT +RL_RUN_ROOTS = _files.RL_RUN_ROOTS + + +def _sync_roots() -> None: + _files.RL_RUN_ROOTS = [Path(root) for root in RL_RUN_ROOTS] + + +def iter_run_dirs() -> Iterable[Path]: + _sync_roots() + return _runs.iter_run_dirs() + + +def list_rl_runs(limit: int = 50) -> List[Dict[str, Any]]: + _sync_roots() + return _runs.list_rl_runs(limit=limit) + + +def resolve_run_dir(run_name: str) -> Path: + _sync_roots() + return _runs.resolve_run_dir(run_name) + + +def load_rl_run(run_name: str) -> Dict[str, Any]: + _sync_roots() + return _runs.load_rl_run(run_name) + + +def load_rl_table(run_name: str, table_name: str, *, policy: Optional[str] = None, limit: int = 500) -> Dict[str, Any]: + _sync_roots() + return _tables.load_rl_table(run_name, table_name, policy=policy, limit=limit) + + +def load_rl_cost_gate(run_name: str, *, limit: int = 500) -> Dict[str, Any]: + _sync_roots() + return _tables.load_rl_cost_gate(run_name, limit=limit) + + +def load_rl_events(run_name: str, *, limit: int = 500) -> Dict[str, Any]: + _sync_roots() + return _tables.load_rl_events(run_name, limit=limit) + + +def load_rl_progress() -> Dict[str, Any]: + _sync_roots() + return _progress.load_rl_progress() diff --git a/webui/rl_dashboard_files.py b/webui/rl_dashboard_files.py new file mode 100644 index 000000000..8d3aaa378 --- /dev/null +++ b/webui/rl_dashboard_files.py @@ -0,0 +1,252 @@ +"""Path-safe file and CSV helpers for STOM RL dashboard artifacts.""" + +from __future__ import annotations + +import csv +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Tuple + +REPO_ROOT = Path(__file__).resolve().parents[1] +WEBUI_ROOT = Path(__file__).resolve().parent +RL_RUN_ROOTS = [WEBUI_ROOT / "rl_runs"] +MAX_TABLE_LIMIT = 5000 + + +ARTIFACT_SIGNATURES = ( + ("opening_30m_rule_filter", "opening_rule_filter_summary.json"), + ("opening_30m_rl_workflow", "opening_30m_rl_workflow_summary.json"), + ("orderbook_rl_readiness", "orderbook_rl_readiness_summary.json"), + ("portfolio_paper", "portfolio_paper_summary.json"), + ("performance_leaderboard", "performance_leaderboard.json"), + ("sb3_smoke", "sb3_smoke_summary.json"), + ("contextual_bandit", "eval_summary.json"), + ("cost_gate", "cost_gate_report.json"), + ("baseline", "baseline_summary.json"), + ("episode_manifest", "episode_manifest.json"), +) + +TABLE_ALIASES = { + "action": "actions", + "actions": "actions", + "trade": "trades", + "trades": "trades", + "equity": "equity", + "equity_curve": "equity", + "episodes": "episodes", + "episode": "episodes", + "summary": "summary", + "manifest": "manifest", + "scenario": "scenario", + "scenarios": "scenario", + "rolling": "rolling", + "folds": "rolling", + "gate": "gate", + "cost_gate": "gate", + "leaderboard": "leaderboard", + "performance": "leaderboard", + "performance_leaderboard": "leaderboard", + "nav": "nav", + "nav_curve": "nav", + "decision": "decisions", + "decisions": "decisions", + "position": "decisions", + "positions": "decisions", + "candidate": "candidates", + "candidates": "candidates", + "portfolio_fold": "portfolio_folds", + "portfolio_folds": "portfolio_folds", + "event": "events", + "events": "events", + "live": "events", + "live_events": "events", + "stage": "stages", + "stages": "stages", + "workflow_stage": "stages", + "workflow_stages": "stages", + "control": "controls", + "controls": "controls", + "negative_control": "controls", + "negative_controls": "controls", + "proxy": "proxy_availability", + "proxy_availability": "proxy_availability", + "participant_proxy": "proxy_availability", + "participant_study": "participant_study_groups", + "participant_studies": "participant_study_groups", + "participant_study_group": "participant_study_groups", + "participant_study_groups": "participant_study_groups", + "participant_study_episode": "participant_study_episodes", + "participant_study_episodes": "participant_study_episodes", + "orderbook_persistence": "orderbook_persistence", + "orderbook_persistence_score": "orderbook_persistence", + "feature_ablation": "feature_ablation", + "feature_ablations": "feature_ablation", + "candidate_lifecycle": "candidate_lifecycle", + "candidate_splits": "candidate_splits", + "candidate_controls": "candidate_controls", + "candidate_ablations": "candidate_ablations", + "candidate_feature_ablation": "candidate_feature_ablation", + "candidate_equity_curve": "candidate_equity_curve", + "candidate_time_buckets": "candidate_time_buckets", + "candidate_failure_reasons": "candidate_failure_reasons", + "context_feature_sample": "context_feature_sample", + "context_features": "context_feature_sample", + "rule_filter_lifecycle": "rule_filter_lifecycle", + "rule_filter_splits": "rule_filter_splits", + "rule_filter_controls": "rule_filter_controls", + "rule_filter_ablations": "rule_filter_ablations", + "rule_filter_equity_curve": "rule_filter_equity_curve", + "rule_filter_time_buckets": "rule_filter_time_buckets", + "rule_filter_failure_reasons": "rule_filter_failure_reasons", + "rule_filter_opportunity_cost": "rule_filter_opportunity_cost", + "rule_filter_proxy_availability": "rule_filter_proxy_availability", + "rule_filter_orderbook_persistence": "rule_filter_orderbook_persistence", + "rule_filter_context_sample": "rule_filter_context_sample", + "orderbook_readiness": "orderbook_readiness", + "orderbook-readiness": "orderbook_readiness", + "readiness": "orderbook_readiness", +} + +ROOT_TABLE_CANDIDATES = { + "actions": ("actions.csv",), + "trades": ("trades.csv",), + "equity": ("equity_curve.csv", "equity.csv"), + "episodes": ("episodes.csv",), + "summary": ("summary.csv", "sb3_smoke_summary.csv", "baseline_summary.csv", "gate_summary.csv"), + "manifest": ("episode_manifest.csv",), + "scenario": ("scenario_summary.csv",), + "rolling": ("rolling_folds.csv",), + "gate": ("gate_summary.csv",), + "leaderboard": ("opening_leaderboard.csv", "performance_leaderboard.csv", "leaderboard.csv"), + "controls": ("controls.csv", "opening_controls.csv", "negative_controls.csv"), + "participant_study_groups": ("market_participant_study_groups.csv",), + "participant_study_episodes": ("market_participant_study_episodes.csv",), + "nav": ("nav.csv",), + "decisions": ("decisions.csv",), + "candidates": ("candidates.csv",), + "portfolio_folds": ("portfolio_walk_forward_folds.csv",), + "orderbook_readiness": ("orderbook_rl_readiness.csv",), +} + +LIVE_EVENT_FILE_NAMES = ("rl_live_events.jsonl", "live_events.jsonl", "events.jsonl") +LIVE_SUMMARY_FILE_NAMES = ("rl_live_summary.json", "live_summary.json") + + +class RlDashboardPathError(ValueError): + """Raised when a dashboard artifact path escapes its read-only boundary.""" + + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + def __str__(self) -> str: + return self.message + + +def _safe_direct_child_name(name: str, *, label: str) -> str: + value = str(name or "").strip() + if not value: + raise RlDashboardPathError(f"{label} is required") + path = Path(value) + if path.is_absolute() or value in {".", ".."} or any(part in {"", ".", ".."} for part in path.parts): + raise RlDashboardPathError(f"Invalid {label}: {name!r}") + if "/" in value or "\\" in value: + raise RlDashboardPathError(f"{label} must be a direct child name: {name!r}") + return value + + +def _utc_mtime(path: Path) -> str: + return datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc).isoformat() + + +def _read_json(path: Path) -> Dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8-sig")) + + +def _ensure_run_path(run_dir: Path, path: Path, *, label: str) -> Path: + run_resolved = run_dir.resolve() + path_resolved = path.resolve() + if path_resolved != run_resolved and run_resolved not in path_resolved.parents: + raise RlDashboardPathError(f"{label} resolves outside RL run: {path.name!r}") + return path + + +def _is_run_file(run_dir: Path, path: Path) -> bool: + if not path.is_file(): + return False + try: + _ensure_run_path(run_dir, path, label="RL artifact file") + except ValueError: + return False + return True + + +def _read_run_json(run_dir: Path, path: Path) -> Dict[str, Any]: + return _read_json(_ensure_run_path(run_dir, path, label="RL JSON artifact")) + + +def _read_run_csv_rows(run_dir: Path, path: Path, *, limit: int) -> Tuple[List[Dict[str, Any]], bool]: + return _read_csv_rows(_ensure_run_path(run_dir, path, label="RL CSV artifact"), limit=limit) + + +def _repo_path(path: str | Path) -> Path: + candidate = Path(path) + if candidate.is_absolute(): + return candidate + return REPO_ROOT / candidate + + +def _coerce_scalar(value: str) -> Any: + if value == "": + return None + lowered = value.lower() + if lowered == "true": + return True + if lowered == "false": + return False + # Preserve zero-padded codes (e.g. Korean stock symbol ``000250``) as strings: + # coercing to int would strip the leading zeros and display ``250``. + if len(value) > 1 and value[0] == "0" and value.isdigit(): + return value + if len(value) > 1 and value[0] == "0" and all(ch.isdigit() or ch == "_" for ch in value): + return value + try: + if any(ch in value for ch in (".", "e", "E")): + return float(value) + return int(value) + except ValueError: + return value + + +def _float_or_zero(value: Any) -> float: + try: + if value is None: + return 0.0 + return float(value) + except (TypeError, ValueError): + return 0.0 + + +def _int_or_zero(value: Any) -> int: + return int(_float_or_zero(value)) + + +def _read_csv_rows(path: Path, *, limit: int) -> Tuple[List[Dict[str, Any]], bool]: + limit = max(0, min(int(limit), MAX_TABLE_LIMIT)) + rows: List[Dict[str, Any]] = [] + truncated = False + with path.open(encoding="utf-8-sig", newline="") as f: + reader = csv.DictReader(f) + for idx, row in enumerate(reader): + if idx >= limit: + truncated = True + break + rows.append({key: _coerce_scalar(value) for key, value in row.items()}) + return rows, truncated + + +def _is_relative_to_root(candidate: Path, root: Path) -> bool: + root_resolved = root.resolve() + candidate_resolved = candidate.resolve() + return candidate_resolved == root_resolved or root_resolved in candidate_resolved.parents diff --git a/webui/rl_dashboard_opening.py b/webui/rl_dashboard_opening.py new file mode 100644 index 000000000..b44a0220b --- /dev/null +++ b/webui/rl_dashboard_opening.py @@ -0,0 +1,88 @@ +"""Opening 30-minute RL workflow dashboard adapters.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Mapping + +try: + from .rl_dashboard_files import _read_run_json +except ImportError: # pragma: no cover - supports direct script-style imports + from rl_dashboard_files import _read_run_json + + +def opening_workflow_summary(run_dir: Path) -> Dict[str, Any]: + """Return a compact summary for an opening workflow run.""" + + payload = _read_run_json(run_dir, run_dir / "opening_30m_rl_workflow_summary.json") + config = payload.get("config", {}) + guardrails = payload.get("guardrails", {}) + realdata = payload.get("realdata", {}) + validation_gate = payload.get("realdata_validation_gate", {}) + stages = payload.get("stages", []) + candidate_history = payload.get("candidate_history", []) + candidate_lifecycle = payload.get("candidate_lifecycle", {}) + if not isinstance(config, Mapping): + config = {} + if not isinstance(guardrails, Mapping): + guardrails = {} + if not isinstance(realdata, Mapping): + realdata = {} + if not isinstance(validation_gate, Mapping): + validation_gate = {} + if not isinstance(candidate_lifecycle, Mapping): + candidate_lifecycle = {} + if not isinstance(candidate_history, list): + candidate_history = [] + realdata_bounds = realdata.get("bounds", {}) + sampled_tables = realdata.get("sampled_tables", []) + if not isinstance(realdata_bounds, Mapping): + realdata_bounds = {} + if not isinstance(sampled_tables, list): + sampled_tables = [] + stage_rows = stages if isinstance(stages, list) else [] + passed_statuses = {"complete", "completed", "passed"} + blocked_statuses = {"blocked", "failed"} + return { + "run_id": payload.get("run_id", run_dir.name), + "verdict": payload.get("verdict"), + "cost_bps": config.get("cost_bps"), + "baseline": guardrails.get("baseline"), + "time_start": config.get("time_start"), + "time_end": config.get("time_end"), + "realdata_time_start": realdata_bounds.get("time_start"), + "realdata_time_end": realdata_bounds.get("time_end"), + "realdata_sampled_table_count": len(sampled_tables), + "model_status": realdata.get("model_status"), + "training_status": realdata.get("training_status"), + "validation_verdict": validation_gate.get("verdict") or realdata.get("verdict") or payload.get("candidate_verdict"), + "candidate_count": len(candidate_history), + "candidate_verdict": payload.get("candidate_verdict"), + "split_hash": _nested(candidate_lifecycle, "split_manifest", "split_hash"), + "stage_count": len(stage_rows), + "passed_stage_count": sum( + 1 + for stage in stage_rows + if isinstance(stage, Mapping) and str(stage.get("status", "")).lower() in passed_statuses + ), + "blocked_stage_count": sum( + 1 + for stage in stage_rows + if isinstance(stage, Mapping) and str(stage.get("status", "")).lower() in blocked_statuses + ), + "participant_context_version": payload.get("participant_context_version"), + "missing_proxy_columns": payload.get("missing_proxy_columns", []), + } + + +def load_opening_workflow_detail(run_dir: Path) -> Dict[str, Any]: + """Load the full opening workflow manifest for a detail view.""" + + return _read_run_json(run_dir, run_dir / "opening_30m_rl_workflow_summary.json") + + +def _nested(mapping: Mapping[str, Any], first: str, second: str) -> Any: + value = mapping.get(first, {}) + if isinstance(value, Mapping): + return value.get(second) + return None diff --git a/webui/rl_dashboard_opening_tables.py b/webui/rl_dashboard_opening_tables.py new file mode 100644 index 000000000..da8f91e35 --- /dev/null +++ b/webui/rl_dashboard_opening_tables.py @@ -0,0 +1,324 @@ +"""Opening workflow JSON table adapters for the RL dashboard.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + from .rl_dashboard_files import MAX_TABLE_LIMIT, _is_run_file, _read_run_json +except ImportError: # pragma: no cover + from rl_dashboard_files import MAX_TABLE_LIMIT, _is_run_file, _read_run_json + + +def _limited_rows(rows: List[Dict[str, Any]], *, limit: int) -> Tuple[List[Dict[str, Any]], bool]: + capped_limit = max(0, min(int(limit), MAX_TABLE_LIMIT)) + return rows[:capped_limit], len(rows) > capped_limit + + +def _mapping_rows(mapping: Any, *, key_name: str, value_name: str) -> List[Dict[str, Any]]: + if not isinstance(mapping, dict): + return [] + rows: List[Dict[str, Any]] = [] + for key, value in mapping.items(): + row: Dict[str, Any] = {key_name: str(key)} + if isinstance(value, dict): + row.update(value) + else: + row[value_name] = value + rows.append(row) + return rows + + +def _proxy_availability_rows(run_dir: Path) -> List[Dict[str, Any]]: + summary = _read_run_json(run_dir, run_dir / "opening_30m_rl_workflow_summary.json") + readiness_path = _first_run_file( + run_dir, + ( + run_dir / "participant_pressure_readiness_summary.json", + run_dir / "participant_pressure" / "participant_pressure_readiness_summary.json", + ), + ) + readiness = _read_run_json(run_dir, readiness_path) if readiness_path is not None else {} + availability = readiness.get("proxy_availability", summary.get("proxy_availability", {})) + schema_rows = readiness.get("feature_schema", []) + schema_by_name = { + str(row.get("name")): row + for row in schema_rows + if isinstance(row, dict) and row.get("name") is not None + } + rows = _mapping_rows(availability, key_name="proxy", value_name="status") + for row in rows: + schema = schema_by_name.get(str(row["proxy"]), {}) + if isinstance(schema, dict): + row.setdefault("feature_group", schema.get("feature_group")) + row.setdefault("source_column", schema.get("source_column")) + return rows + + +def _first_run_file(run_dir: Path, paths: Tuple[Path, ...]) -> Optional[Path]: + for path in paths: + if _is_run_file(run_dir, path): + return path + return None + + +def _table_payload( + *, + run_name: str, + artifact_type: str, + table: str, + source_file: str, + rows: List[Dict[str, Any]], + limit: int, +) -> Dict[str, Any]: + limited, truncated = _limited_rows(rows, limit=limit) + return { + "run": run_name, + "artifact_type": artifact_type, + "table": table, + "policy": None, + "source_file": source_file, + "rows": limited, + "row_count": len(limited), + "truncated": truncated, + } + + +def _candidate_lifecycle(run_dir: Path) -> Dict[str, Any]: + payload = _read_run_json(run_dir, run_dir / "opening_30m_rl_workflow_summary.json") + lifecycle = payload.get("candidate_lifecycle", {}) + return lifecycle if isinstance(lifecycle, dict) else {} + + +def _rule_filter_lifecycle(run_dir: Path) -> Dict[str, Any]: + path = run_dir / "opening_rule_filter_lifecycle.json" + if not _is_run_file(run_dir, path): + return {} + payload = _read_run_json(run_dir, path) + return payload if isinstance(payload, dict) else {} + + +def _rule_filter_split_rows(run_dir: Path) -> List[Dict[str, Any]]: + lifecycle = _rule_filter_lifecycle(run_dir) + manifest = lifecycle.get("split_manifest", {}) + split_sessions = manifest.get("split_sessions", {}) if isinstance(manifest, dict) else {} + rows: List[Dict[str, Any]] = [] + if isinstance(split_sessions, dict): + split_hash = manifest.get("split_hash") if isinstance(manifest, dict) else "" + for split, sessions in split_sessions.items(): + if isinstance(sessions, list): + rows.extend({"split": split, "session": session, "split_hash": split_hash} for session in sessions) + return rows + + +def _rule_filter_table(run_name: str, run_dir: Path, artifact_type: str, table: str, limit: int) -> Optional[Dict[str, Any]]: + lifecycle = _rule_filter_lifecycle(run_dir) + if not lifecycle: + return None + gate = lifecycle.get("promotion_gate", {}) + controls = lifecycle.get("controls", {}) + ablations = lifecycle.get("ablations", {}) + rows: List[Dict[str, Any]] | None = None + if table == "rule_filter_lifecycle": + policy = lifecycle.get("policy", {}) + rows = [dict(policy)] if isinstance(policy, dict) else [] + elif table == "rule_filter_splits": + rows = _rule_filter_split_rows(run_dir) + elif table == "rule_filter_controls": + raw = controls.get("controls", []) if isinstance(controls, dict) else [] + rows = [dict(row) for row in raw if isinstance(row, dict)] if isinstance(raw, list) else [] + elif table == "rule_filter_ablations": + raw = ablations.get("ablations", []) if isinstance(ablations, dict) else [] + rows = [dict(row) for row in raw if isinstance(row, dict)] if isinstance(raw, list) else [] + elif table == "rule_filter_equity_curve": + raw = gate.get("equity_curve", []) if isinstance(gate, dict) else [] + rows = [dict(row) for row in raw if isinstance(row, dict)] if isinstance(raw, list) else [] + elif table == "rule_filter_time_buckets": + raw = gate.get("time_bucket_performance", []) if isinstance(gate, dict) else [] + rows = [dict(row) for row in raw if isinstance(row, dict)] if isinstance(raw, list) else [] + elif table == "rule_filter_failure_reasons": + raw = gate.get("blocking_reasons", []) if isinstance(gate, dict) else [] + rows = [{"reason": str(reason)} for reason in raw] if isinstance(raw, list) else [] + elif table == "rule_filter_opportunity_cost": + raw = gate.get("opportunity_cost_curve", []) if isinstance(gate, dict) else [] + rows = [dict(row) for row in raw if isinstance(row, dict)] if isinstance(raw, list) else [] + elif table == "rule_filter_context_sample": + rows = _rule_filter_context_rows(lifecycle) + elif table == "rule_filter_proxy_availability": + rows = _rule_filter_proxy_rows(lifecycle) + elif table == "rule_filter_orderbook_persistence": + rows = _rule_filter_orderbook_rows(lifecycle) + if rows is None: + return None + return _table_payload(run_name=run_name, artifact_type=artifact_type, table=table, source_file="opening_rule_filter_lifecycle.json", rows=rows, limit=limit) + + +def _rule_filter_context_rows(lifecycle: Dict[str, Any]) -> List[Dict[str, Any]]: + context = lifecycle.get("context_features", {}) + names = context.get("feature_names", []) if isinstance(context, dict) else [] + sample = context.get("sample", {}) if isinstance(context, dict) else {} + vector = sample.get("vector", []) if isinstance(sample, dict) else [] + if not isinstance(names, list): + return [] + return [{"feature_name": str(name), "value": vector[index] if isinstance(vector, list) and index < len(vector) else None} for index, name in enumerate(names)] + + +def _rule_filter_proxy_rows(lifecycle: Dict[str, Any]) -> List[Dict[str, Any]]: + rows = lifecycle.get("dataset_rows", []) + if not isinstance(rows, list) or not rows: + return [] + first = rows[0] + availability = first.get("proxy_availability", {}) if isinstance(first, dict) else {} + return _mapping_rows(availability, key_name="proxy", value_name="status") + + +def _rule_filter_orderbook_rows(lifecycle: Dict[str, Any]) -> List[Dict[str, Any]]: + rows = lifecycle.get("dataset_rows", []) + if not isinstance(rows, list) or not rows: + return [] + first = rows[0] + diagnostics = first.get("diagnostics", {}) if isinstance(first, dict) else {} + components = diagnostics.get("orderbook_components", {}) if isinstance(diagnostics, dict) else {} + return _mapping_rows(components, key_name="component", value_name="value") + + +def _candidate_rows(run_dir: Path) -> List[Dict[str, Any]]: + lifecycle = _candidate_lifecycle(run_dir) + training = lifecycle.get("training", {}) + rows = training.get("candidates", []) if isinstance(training, dict) else [] + return [dict(row) for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] + + +def _split_rows(run_dir: Path) -> List[Dict[str, Any]]: + lifecycle = _candidate_lifecycle(run_dir) + manifest = lifecycle.get("split_manifest", {}) + split_sessions = manifest.get("split_sessions", {}) if isinstance(manifest, dict) else {} + rows: List[Dict[str, Any]] = [] + if isinstance(split_sessions, dict): + split_hash = manifest.get("split_hash") if isinstance(manifest, dict) else "" + for split, sessions in split_sessions.items(): + if isinstance(sessions, list): + for session in sessions: + rows.append({"split": split, "session": session, "split_hash": split_hash}) + return rows + + +def _lifecycle_rows(run_dir: Path, section: str, key: str) -> List[Dict[str, Any]]: + lifecycle = _candidate_lifecycle(run_dir) + payload = lifecycle.get(section, {}) + rows = payload.get(key, []) if isinstance(payload, dict) else [] + return [dict(row) for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] + + +def _gate_rows(run_dir: Path, key: str) -> List[Dict[str, Any]]: + lifecycle = _candidate_lifecycle(run_dir) + gate = lifecycle.get("promotion_gate", {}) + rows = gate.get(key, []) if isinstance(gate, dict) else [] + return [dict(row) for row in rows if isinstance(row, dict)] if isinstance(rows, list) else [] + + +def _candidate_table(run_name: str, run_dir: Path, artifact_type: str, table: str, limit: int) -> Optional[Dict[str, Any]]: + table_rows: List[Dict[str, Any]] | None = None + if table == "candidate_lifecycle": + table_rows = _candidate_rows(run_dir) + elif table == "candidate_splits": + table_rows = _split_rows(run_dir) + elif table in {"candidate_controls", "negative_controls"}: + table_rows = _lifecycle_rows(run_dir, "controls", "controls") + elif table in {"candidate_ablations", "candidate_feature_ablation"}: + table_rows = _lifecycle_rows(run_dir, "ablations", "ablations") + elif table == "candidate_equity_curve": + table_rows = _gate_rows(run_dir, "equity_curve") + elif table == "candidate_time_buckets": + table_rows = _gate_rows(run_dir, "time_bucket_performance") + elif table == "candidate_failure_reasons": + lifecycle = _candidate_lifecycle(run_dir) + gate = lifecycle.get("promotion_gate", {}) + reasons = gate.get("blocking_reasons", []) if isinstance(gate, dict) else [] + table_rows = [{"reason": str(reason)} for reason in reasons] if isinstance(reasons, list) else [] + elif table == "context_feature_sample": + table_rows = _context_feature_rows(run_dir) + if table_rows is None: + return None + return _table_payload( + run_name=run_name, + artifact_type=artifact_type, + table=table, + source_file="opening_candidate_lifecycle.json", + rows=table_rows, + limit=limit, + ) + + +def _context_feature_rows(run_dir: Path) -> List[Dict[str, Any]]: + lifecycle = _candidate_lifecycle(run_dir) + context = lifecycle.get("context_features", {}) + if not isinstance(context, dict): + return [] + names = context.get("feature_names", []) + sample = context.get("sample", {}) + vector = sample.get("vector", []) if isinstance(sample, dict) else [] + if not isinstance(names, list): + return [] + rows: List[Dict[str, Any]] = [] + for index, name in enumerate(names): + value = vector[index] if isinstance(vector, list) and index < len(vector) else None + rows.append({"feature_name": str(name), "value": value, "status": context.get("status", "")}) + return rows + + +def load_opening_json_table( + run_name: str, + run_dir: Path, + artifact_type: str, + table: str, + *, + limit: int, +) -> Optional[Dict[str, Any]]: + """Load JSON-backed table aliases for opening workflow artifacts.""" + + rule_filter_payload = _rule_filter_table(run_name, run_dir, artifact_type, table, limit) + if rule_filter_payload is not None: + return rule_filter_payload + + candidate_payload = _candidate_table(run_name, run_dir, artifact_type, table, limit) + if candidate_payload is not None: + return candidate_payload + + summary_path = run_dir / "opening_30m_rl_workflow_summary.json" + if table == "stages": + payload = _read_run_json(run_dir, summary_path) + raw_rows = payload.get("stages", []) + rows = [dict(row) for row in raw_rows if isinstance(row, dict)] if isinstance(raw_rows, list) else [] + return _table_payload(run_name=run_name, artifact_type=artifact_type, table=table, source_file=summary_path.name, rows=rows, limit=limit) + if table == "controls": + controls_path = run_dir / "opening_negative_controls_summary.json" + if not _is_run_file(run_dir, controls_path): + return None + payload = _read_run_json(run_dir, controls_path) + raw_rows = payload.get("controls", []) + rows = [dict(row) for row in raw_rows if isinstance(row, dict)] if isinstance(raw_rows, list) else [] + return _table_payload(run_name=run_name, artifact_type=artifact_type, table=table, source_file=controls_path.name, rows=rows, limit=limit) + if table == "proxy_availability": + rows = _proxy_availability_rows(run_dir) + if not rows: + return None + return _table_payload(run_name=run_name, artifact_type=artifact_type, table=table, source_file="participant_pressure_readiness_summary.json", rows=rows, limit=limit) + if table == "orderbook_persistence": + score_path = run_dir / "orderbook_persistence_score_summary.json" + if not _is_run_file(run_dir, score_path): + return None + payload = _read_run_json(run_dir, score_path) + rows = _mapping_rows(payload.get("components", {}), key_name="component", value_name="value") + return _table_payload(run_name=run_name, artifact_type=artifact_type, table=table, source_file=score_path.name, rows=rows, limit=limit) + if table == "feature_ablation": + payload = _read_run_json(run_dir, summary_path) + raw_results = payload.get("feature_ablation_results", {}) + if not raw_results and isinstance(payload.get("realdata_validation_gate"), dict): + raw_results = payload["realdata_validation_gate"].get("feature_ablation_results", {}) + rows = _mapping_rows(raw_results, key_name="feature_ablation", value_name="value") + if not rows: + return None + return _table_payload(run_name=run_name, artifact_type=artifact_type, table=table, source_file=summary_path.name, rows=rows, limit=limit) + return None diff --git a/webui/rl_dashboard_progress.py b/webui/rl_dashboard_progress.py new file mode 100644 index 000000000..a71869d73 --- /dev/null +++ b/webui/rl_dashboard_progress.py @@ -0,0 +1,206 @@ +"""Page-progress synthesis for the STOM RL dashboard.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple + +try: + from .rl_dashboard_files import _int_or_zero, _repo_path + from .rl_dashboard_runs import list_rl_runs, load_rl_run +except ImportError: # pragma: no cover + from rl_dashboard_files import _int_or_zero, _repo_path + from rl_dashboard_runs import list_rl_runs, load_rl_run + +def _criteria_progress(criteria: Sequence[Tuple[str, bool, str]]) -> Tuple[int, List[Dict[str, Any]]]: + rows = [{"label": label, "passed": bool(passed), "evidence": evidence} for label, passed, evidence in criteria] + if not rows: + return 0, rows + passed_count = sum(1 for row in rows if row["passed"]) + return int(round((passed_count / len(rows)) * 100)), rows + + +def _latest_sb3_details() -> Tuple[List[Dict[str, Any]], Optional[Dict[str, Any]]]: + details: List[Dict[str, Any]] = [] + for run in list_rl_runs(limit=200): + if run.get("artifact_type") != "sb3_smoke": + continue + try: + detail = load_rl_run(str(run["name"])) + except (FileNotFoundError, ValueError, json.JSONDecodeError): + continue + models = detail.get("detail", {}).get("models", []) + max_timesteps = max((_int_or_zero(row.get("training_timesteps")) for row in models), default=0) + detail["max_training_timesteps"] = max_timesteps + details.append(detail) + details.sort(key=lambda row: int(row.get("max_training_timesteps") or 0), reverse=True) + return details, details[0] if details else None + + +def _latest_opening_workflow(runs: Sequence[Mapping[str, Any]]) -> Optional[Dict[str, Any]]: + for run in runs: + if run.get("artifact_type") != "opening_30m_rl_workflow": + continue + try: + return load_rl_run(str(run["name"])) + except (FileNotFoundError, ValueError, json.JSONDecodeError): + continue + return None + + +def _has_model_file(detail: Optional[Mapping[str, Any]], algorithm: str) -> bool: + if not detail: + return False + model_files = detail.get("detail", {}).get("artifacts", {}).get("model_files", {}) + path = model_files.get(algorithm) if isinstance(model_files, dict) else None + return bool(path and _repo_path(str(path)).is_file()) + + +def _opening_stage(detail: Mapping[str, Any], stage_name: str) -> Mapping[str, Any]: + stages = detail.get("detail", {}).get("stages", []) + if not isinstance(stages, list): + return {} + return next((stage for stage in stages if isinstance(stage, Mapping) and stage.get("name") == stage_name), {}) + + +def _opening_stage_criterion(detail: Mapping[str, Any], stage_name: str, label: str) -> Tuple[str, bool, str]: + stage = _opening_stage(detail, stage_name) + status = str(stage.get("status", "missing")).lower() + evidence = str(stage.get("evidence", "") or "-") + reason = str(stage.get("reason", "") or "") + passed = status in {"complete", "completed", "passed"} + evidence_text = f"{status} | {evidence}" if not reason else f"{status} | {evidence} | {reason}" + return label, passed, evidence_text + + +def _opening_workflow_page(detail: Mapping[str, Any]) -> Tuple[str, List[Tuple[str, bool, str]]]: + cost_stage = _opening_stage_criterion(detail, "cost_gate", "opening cost gate") + cost_status, cost_evidence = cost_stage[1], cost_stage[2] + leaderboard_passed = cost_status and "leaderboard" in cost_evidence.lower() + return ( + "Opening 30M RL Workflow", + [ + _opening_stage_criterion(detail, "contract", "opening contract"), + _opening_stage_criterion(detail, "manifest", "opening manifest"), + _opening_stage_criterion(detail, "readiness_env", "opening env/readiness"), + _opening_stage_criterion(detail, "baseline", "opening baseline"), + _opening_stage_criterion(detail, "training", "opening training"), + _opening_stage_criterion(detail, "evaluation", "opening evaluation"), + _opening_stage_criterion(detail, "controls", "opening controls"), + cost_stage, + ("opening leaderboard", leaderboard_passed, cost_evidence), + _opening_stage_criterion(detail, "dashboard", "opening dashboard"), + ], + ) + + +def load_rl_progress() -> Dict[str, Any]: + """Return page-level STOM RL completion progress for the dashboard.""" + + runs = list_rl_runs(limit=200) + run_types = {str(run.get("artifact_type")) for run in runs} + run_names = {str(run.get("name")) for run in runs} + sb3_details, latest_sb3 = _latest_sb3_details() + latest_sb3_summary = latest_sb3.get("summary", {}) if latest_sb3 else {} + latest_models = latest_sb3.get("detail", {}).get("models", []) if latest_sb3 else [] + latest_algorithms = {str(row.get("algorithm")) for row in latest_models} + max_timesteps = _int_or_zero(latest_sb3.get("max_training_timesteps")) if latest_sb3 else 0 + + leaderboard_detail = next((run for run in runs if run.get("artifact_type") == "performance_leaderboard"), None) + leaderboard_payload: Dict[str, Any] = {} + leaderboard_rows: List[Dict[str, Any]] = [] + if leaderboard_detail: + try: + leaderboard_payload = load_rl_run(str(leaderboard_detail["name"])) + leaderboard_rows = list(leaderboard_payload.get("detail", {}).get("leaderboard", [])) + except (FileNotFoundError, ValueError, json.JSONDecodeError): + leaderboard_payload = {} + leaderboard_rows = [] + leaderboard_models = {str(row.get("model")) for row in leaderboard_rows} + latest_opening = _latest_opening_workflow(runs) + + docs_ready = _repo_path("docs/stom_rl_realtime_learning_dashboard_implementation_2026-05-23.md").is_file() + completion_doc_ready = _repo_path("docs/stom_rl_page100_completion_report_2026-05-24.md").is_file() + + page_specs = [ + ( + "RL Trading 개요", + [ + ("run 목록 조회", bool(runs), f"{len(runs)} runs"), + ("baseline/contextual/cost/SB3/leaderboard 유형", {"baseline", "contextual_bandit", "cost_gate", "sb3_smoke", "performance_leaderboard"}.issubset(run_types), ",".join(sorted(run_types))), + ("상세 artifact 조회", bool(leaderboard_payload or latest_sb3), "load_rl_run available"), + ], + ), + ( + "실시간 RL", + [ + ("live event log", int(latest_sb3_summary.get("live_event_count") or 0) > 0, str(latest_sb3_summary.get("live_event_count") or 0)), + ("DQN/PPO event", {"dqn", "ppo"}.issubset(latest_algorithms), ",".join(sorted(latest_algorithms))), + ("50k short run", max_timesteps >= 50_000, str(max_timesteps)), + ], + ), + ( + "실제 딥러닝 학습", + [ + ("check_env 통과", bool(latest_sb3_summary.get("check_env_passed")), str(latest_sb3_summary.get("check_env_passed"))), + ("CUDA 학습 확인", bool(latest_sb3_summary.get("cuda_available")), str(latest_sb3_summary.get("cuda_available"))), + ("DQN/PPO 모델 파일", _has_model_file(latest_sb3, "dqn") and _has_model_file(latest_sb3, "ppo"), latest_sb3.get("name", "-") if latest_sb3 else "-"), + ], + ), + ( + "Performance Leaderboard", + [ + ("leaderboard artifact", "performance_leaderboard" in run_types, leaderboard_detail.get("name", "-") if leaderboard_detail else "-"), + ("DQN/PPO short 모델 반영", {"dqn_50k", "ppo_50k"}.issubset(leaderboard_models), ",".join(sorted(leaderboard_models))), + ("row count", int(leaderboard_payload.get("summary", {}).get("row_count") or len(leaderboard_rows)) >= 10, str(len(leaderboard_rows))), + ], + ), + ( + "Artifacts / Models", + [ + ("summary/csv/jsonl", bool(latest_sb3 and {"sb3_smoke_summary.json", "sb3_smoke_summary.csv", "rl_live_events.jsonl"}.issubset({Path(row.get("name", "")).name for row in latest_sb3.get("artifacts", [])})), latest_sb3.get("name", "-") if latest_sb3 else "-"), + ("DQN zip", _has_model_file(latest_sb3, "dqn"), "dqn_model.zip"), + ("PPO zip", _has_model_file(latest_sb3, "ppo"), "ppo_model.zip"), + ], + ), + ( + "Docs / 운영 경계", + [ + ("구현 문서", docs_ready, "implementation doc"), + ("완료 보고 문서", completion_doc_ready, "page100 report"), + ("실주문 분리", True, "read-only historical replay / smoke-short training"), + ], + ), + ] + if latest_opening: + page_specs.append(_opening_workflow_page(latest_opening)) + + pages: List[Dict[str, Any]] = [] + for page, criteria in page_specs: + progress_pct, criteria_rows = _criteria_progress(criteria) + pages.append( + { + "page": page, + "progress_pct": progress_pct, + "status": "complete" if progress_pct == 100 else "in_progress", + "criteria": criteria_rows, + } + ) + + overall_progress = int(round(sum(int(page["progress_pct"]) for page in pages) / len(pages))) if pages else 0 + return { + "mode": "stom_rl_page_progress", + "overall_progress_pct": overall_progress, + "status": "complete" if overall_progress == 100 else "in_progress", + "pages": pages, + "evidence": { + "run_count": len(runs), + "run_names": sorted(run_names), + "latest_sb3_run": latest_sb3.get("name") if latest_sb3 else None, + "latest_opening_workflow_run": latest_opening.get("name") if latest_opening else None, + "latest_opening_workflow_verdict": latest_opening.get("summary", {}).get("verdict") if latest_opening else None, + "max_sb3_training_timesteps": max_timesteps, + "leaderboard_models": sorted(leaderboard_models), + }, + } diff --git a/webui/rl_dashboard_runs.py b/webui/rl_dashboard_runs.py new file mode 100644 index 000000000..d8641fc66 --- /dev/null +++ b/webui/rl_dashboard_runs.py @@ -0,0 +1,266 @@ +"""Run listing and detail loading for STOM RL dashboard artifacts.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, Iterable, List, Mapping + +try: + from .rl_strategy_context import build_strategy_context + from . import rl_dashboard_files as _files + from .rl_dashboard_opening import load_opening_workflow_detail, opening_workflow_summary + from .rl_dashboard_files import ARTIFACT_SIGNATURES, LIVE_SUMMARY_FILE_NAMES, RlDashboardPathError, _int_or_zero, _is_relative_to_root, _is_run_file, _read_run_json, _safe_direct_child_name, _utc_mtime +except ImportError: # pragma: no cover - supports direct script-style imports + from rl_strategy_context import build_strategy_context + import rl_dashboard_files as _files + from rl_dashboard_opening import load_opening_workflow_detail, opening_workflow_summary + from rl_dashboard_files import ARTIFACT_SIGNATURES, LIVE_SUMMARY_FILE_NAMES, RlDashboardPathError, _int_or_zero, _is_relative_to_root, _is_run_file, _read_run_json, _safe_direct_child_name, _utc_mtime + +def _detect_artifact_type(run_dir: Path) -> str: + for artifact_type, file_name in ARTIFACT_SIGNATURES: + if _is_run_file(run_dir, run_dir / file_name): + return artifact_type + return "unknown" + + +def _find_json_summary(run_dir: Path, artifact_type: str) -> Dict[str, Any]: + if artifact_type == "opening_30m_rule_filter": + payload = _read_run_json(run_dir, run_dir / "opening_rule_filter_summary.json") + summary = dict(payload) + summary.pop("rule_filter_lifecycle", None) + return summary + if artifact_type == "opening_30m_rl_workflow": + return opening_workflow_summary(run_dir) + if artifact_type == "orderbook_rl_readiness": + payload = _read_run_json(run_dir, run_dir / "orderbook_rl_readiness_summary.json") + return dict(payload.get("summary", {})) + if artifact_type == "portfolio_paper": + payload = _read_run_json(run_dir, run_dir / "portfolio_paper_summary.json") + summary = dict(payload.get("summary", {})) + config = payload.get("config", {}) + if isinstance(config, dict): + summary.setdefault("cost_bps", config.get("cost_bps")) + summary.setdefault("max_positions", config.get("max_positions")) + summary.setdefault("top_k_candidates", config.get("top_k_candidates")) + return summary + if artifact_type == "performance_leaderboard": + payload = _read_run_json(run_dir, run_dir / "performance_leaderboard.json") + return dict(payload.get("summary", {})) + if artifact_type == "sb3_smoke": + payload = _read_run_json(run_dir, run_dir / "sb3_smoke_summary.json") + summary = dict(payload.get("summary", {})) + live_summary = payload.get("live_events") + if isinstance(live_summary, dict): + summary.setdefault("live_event_count", live_summary.get("event_count")) + summary.setdefault("live_event_phases", live_summary.get("phases")) + else: + for file_name in LIVE_SUMMARY_FILE_NAMES: + summary_path = run_dir / file_name + if _is_run_file(run_dir, summary_path): + file_summary = _read_run_json(run_dir, summary_path) + summary.setdefault("live_event_count", file_summary.get("event_count")) + summary.setdefault("live_event_phases", file_summary.get("phases")) + break + models = payload.get("models", []) + best_model = summary.get("best_model") + selected_model = next((row for row in models if row.get("model") == best_model), models[0] if models else {}) + summary.setdefault( + "max_training_timesteps", + max((_int_or_zero(row.get("training_timesteps")) for row in models), default=0), + ) + for key in ( + "avg_episode_net_return_pct", + "trade_count", + "cost_bps", + "slippage_bps", + "passes_cost_gate", + ): + if key in selected_model: + summary.setdefault(key, selected_model[key]) + return summary + if artifact_type == "contextual_bandit": + payload = _read_run_json(run_dir, run_dir / "eval_summary.json") + return dict(payload.get("eval_summary", payload.get("summary", {}))) + if artifact_type == "cost_gate": + payload = _read_run_json(run_dir, run_dir / "cost_gate_report.json") + return dict(payload.get("summary", {})) + if artifact_type == "baseline": + payload = _read_run_json(run_dir, run_dir / "baseline_summary.json") + return dict(payload.get("summary", {})) + if artifact_type == "episode_manifest": + summary_path = run_dir / "episode_summary.json" + payload = _read_run_json(run_dir, summary_path if _is_run_file(run_dir, summary_path) else run_dir / "episode_manifest.json") + return dict(payload.get("summary", payload)) + return {} + + +def _artifact_files(run_dir: Path) -> List[Dict[str, Any]]: + files = [] + for path in sorted(run_dir.rglob("*")): + if _is_run_file(run_dir, path): + rel = path.relative_to(run_dir).as_posix() + files.append( + { + "name": rel, + "suffix": path.suffix.lower(), + "size_bytes": path.stat().st_size, + "modified_at": _utc_mtime(path), + } + ) + return files + + +def _baseline_policies(run_dir: Path) -> List[str]: + policies = [] + for child in sorted(run_dir.iterdir()): + if child.is_dir() and any((child / file_name).is_file() for file_name in ("actions.csv", "trades.csv", "equity.csv", "episodes.csv")): + policies.append(child.name) + return policies + + +def _run_record(run_dir: Path) -> Dict[str, Any]: + artifact_type = _detect_artifact_type(run_dir) + summary = _find_json_summary(run_dir, artifact_type) + return { + "name": run_dir.name, + "artifact_type": artifact_type, + "modified_at": _utc_mtime(run_dir), + "summary": summary, + "strategy_context": build_strategy_context(artifact_type, summary), + "policies": _baseline_policies(run_dir) if artifact_type == "baseline" else [], + } + + +def iter_run_dirs() -> Iterable[Path]: + seen = set() + for root in _files.RL_RUN_ROOTS: + root = Path(root) + if not root.is_dir(): + continue + for child in _candidate_run_dirs(root): + if child.name not in seen and _is_relative_to_root(child, root): + seen.add(child.name) + yield child + + +def _candidate_run_dirs(root: Path) -> Iterable[Path]: + for child in root.iterdir(): + if not child.is_dir(): + continue + nested_runs = _nested_run_dirs(child) + if nested_runs: + yield from nested_runs + elif _detect_artifact_type(child) != "unknown": + yield child + + +def _nested_run_dirs(parent: Path) -> List[Path]: + return [ + grandchild + for grandchild in parent.iterdir() + if grandchild.is_dir() and _detect_artifact_type(grandchild) != "unknown" + ] + + +def list_rl_runs(limit: int = 50) -> List[Dict[str, Any]]: + """List available independent RL runtime artifact directories.""" + + runs = sorted(iter_run_dirs(), key=lambda path: path.stat().st_mtime, reverse=True) + return [_run_record(path) for path in runs[: max(0, int(limit))]] + + +def resolve_run_dir(run_name: str) -> Path: + safe_name = _safe_direct_child_name(run_name, label="run") + for root in _files.RL_RUN_ROOTS: + root_path = Path(root) + candidate = root_path / safe_name + if candidate.is_dir() and not _nested_run_dirs(candidate): + if not _is_relative_to_root(candidate, root_path): + raise RlDashboardPathError(f"Invalid run: resolved path escapes RL root: {run_name!r}") + return candidate + for child in root_path.iterdir() if root_path.is_dir() else []: + nested = child / safe_name + if nested.is_dir() and _is_relative_to_root(nested, root_path): + return nested + raise FileNotFoundError(f"RL run not found: {run_name}") + + +def load_rl_run(run_name: str) -> Dict[str, Any]: + """Load a run detail payload without reading large CSV tables.""" + + run_dir = resolve_run_dir(run_name) + artifact_type = _detect_artifact_type(run_dir) + payload: Dict[str, Any] = { + **_run_record(run_dir), + "artifacts": _artifact_files(run_dir), + } + if artifact_type == "orderbook_rl_readiness": + payload["detail"] = _read_run_json(run_dir, run_dir / "orderbook_rl_readiness_summary.json") + payload["model"] = { + "model_type": "marketable_only_orderbook_rl_environment", + "feature_columns": payload["detail"].get("observation_features", []), + "train_summary": payload.get("summary", {}), + } + elif artifact_type == "portfolio_paper": + signature = _read_run_json(run_dir, run_dir / "portfolio_paper_summary.json") + wf_report_path = run_dir / "portfolio_walk_forward_report.json" + walk_forward = _read_run_json(run_dir, wf_report_path) if _is_run_file(run_dir, wf_report_path) else {} + risk_path = run_dir / "risk_triggers.json" + risk_payload = _read_run_json(run_dir, risk_path) if _is_run_file(run_dir, risk_path) else {} + risk_triggers = risk_payload.get("risk_triggers", []) if isinstance(risk_payload, dict) else [] + risk_reasons: Dict[str, int] = {} + for trigger in risk_triggers if isinstance(risk_triggers, list) else []: + reason = str(trigger.get("reason", "unknown")) if isinstance(trigger, Mapping) else "unknown" + risk_reasons[reason] = risk_reasons.get(reason, 0) + 1 + payload["detail"] = { + "summary": signature.get("summary", {}), + "config": signature.get("config", {}), + "walk_forward_summary": walk_forward.get("summary", signature.get("walk_forward_summary", {})), + "risk_trigger_reasons": risk_reasons, + "risk_trigger_sample": risk_triggers[:20] if isinstance(risk_triggers, list) else [], + } + elif artifact_type == "performance_leaderboard": + payload["detail"] = _read_run_json(run_dir, run_dir / "performance_leaderboard.json") + elif artifact_type == "sb3_smoke": + payload["detail"] = _read_run_json(run_dir, run_dir / "sb3_smoke_summary.json") + live_summary = payload["detail"].get("live_events") + if not isinstance(live_summary, dict): + for file_name in LIVE_SUMMARY_FILE_NAMES: + summary_path = run_dir / file_name + if _is_run_file(run_dir, summary_path): + live_summary = _read_run_json(run_dir, summary_path) + break + if isinstance(live_summary, dict): + payload["live_events"] = live_summary + models = payload["detail"].get("models", []) + best_model = payload["summary"].get("best_model") + selected_model = next((row for row in models if row.get("model") == best_model), models[0] if models else {}) + payload["model"] = { + "model_type": f"stable_baselines3_{selected_model.get('algorithm', 'sb3')}", + "feature_columns": payload["summary"].get("feature_columns", []), + "train_summary": selected_model, + } + elif artifact_type == "contextual_bandit": + payload["detail"] = _read_run_json(run_dir, run_dir / "eval_summary.json") + model_path = run_dir / "model.json" + if _is_run_file(run_dir, model_path): + model_payload = _read_run_json(run_dir, model_path) + model = model_payload.get("model", {}) + payload["model"] = { + "model_type": model.get("model_type"), + "feature_columns": model.get("feature_columns", []), + "train_summary": model.get("train_summary", {}), + } + elif artifact_type == "cost_gate": + payload["detail"] = _read_run_json(run_dir, run_dir / "cost_gate_report.json") + elif artifact_type == "baseline": + payload["detail"] = _read_run_json(run_dir, run_dir / "baseline_summary.json") + elif artifact_type == "episode_manifest": + manifest = _read_run_json(run_dir, run_dir / "episode_manifest.json") + payload["detail"] = {"summary": manifest.get("summary", {}), "episode_sample": manifest.get("episodes", [])[:10]} + elif artifact_type == "opening_30m_rule_filter": + payload["detail"] = _read_run_json(run_dir, run_dir / "opening_rule_filter_summary.json") + elif artifact_type == "opening_30m_rl_workflow": + payload["detail"] = load_opening_workflow_detail(run_dir) + return payload diff --git a/webui/rl_dashboard_tables.py b/webui/rl_dashboard_tables.py new file mode 100644 index 000000000..5ad2dad5b --- /dev/null +++ b/webui/rl_dashboard_tables.py @@ -0,0 +1,189 @@ +"""Table and live-event readers for STOM RL dashboard artifacts.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +try: + from .rl_dashboard_files import ( + LIVE_EVENT_FILE_NAMES, + MAX_TABLE_LIMIT, + ROOT_TABLE_CANDIDATES, + TABLE_ALIASES, + _is_run_file, + _read_run_json, + _read_run_csv_rows, + _safe_direct_child_name, + ) + from .rl_dashboard_opening_tables import load_opening_json_table + from .rl_dashboard_runs import _baseline_policies, _detect_artifact_type, resolve_run_dir +except ImportError: # pragma: no cover + from rl_dashboard_files import ( + LIVE_EVENT_FILE_NAMES, + MAX_TABLE_LIMIT, + ROOT_TABLE_CANDIDATES, + TABLE_ALIASES, + _is_run_file, + _read_run_json, + _read_run_csv_rows, + _safe_direct_child_name, + ) + from rl_dashboard_opening_tables import load_opening_json_table + from rl_dashboard_runs import _baseline_policies, _detect_artifact_type, resolve_run_dir + + +class RlDashboardTableError(ValueError): + """Raised when a requested dashboard table alias is unknown.""" + + def __init__(self, message: str) -> None: + self.message = message + super().__init__(message) + + def __str__(self) -> str: + return self.message + + +def _live_events_path(run_dir: Path) -> Optional[Path]: + for file_name in LIVE_EVENT_FILE_NAMES: + path = run_dir / file_name + if _is_run_file(run_dir, path): + return path + return None + + +def _read_jsonl_rows(path: Path, *, limit: int) -> Tuple[List[Dict[str, Any]], bool]: + limit = max(0, min(int(limit), MAX_TABLE_LIMIT)) + if limit == 0: + return [], False + lines = path.read_text(encoding="utf-8").splitlines() + truncated = len(lines) > limit + rows: List[Dict[str, Any]] = [] + for line in reversed(lines): + if len(rows) >= limit: + break + if not line.strip(): + continue + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if isinstance(payload, dict): + rows.append(payload) + rows.reverse() + return rows, truncated + + +def _normalize_table(table_name: str) -> str: + table = str(table_name or "").strip().lower().replace("-", "_") + if table not in TABLE_ALIASES: + raise RlDashboardTableError(f"Unknown RL table: {table_name}") + return TABLE_ALIASES[table] + + +def _read_policy_table(run_dir: Path, table: str, policy: Optional[str], limit: int) -> Dict[str, Any]: + file_name = "equity.csv" if table == "equity" else f"{table}.csv" + policies = [policy] if policy else _baseline_policies(run_dir) + rows: List[Dict[str, Any]] = [] + truncated = False + for policy_name in policies: + safe_policy = _safe_direct_child_name(policy_name, label="policy") + path = run_dir / safe_policy / file_name + if not _is_run_file(run_dir, path): + if policy: + raise FileNotFoundError(f"Baseline table not found: {safe_policy}/{file_name}") + continue + remaining = max(0, min(int(limit), MAX_TABLE_LIMIT) - len(rows)) + table_rows, table_truncated = _read_run_csv_rows(run_dir, path, limit=remaining) + rows.extend(table_rows) + truncated = truncated or table_truncated + if len(rows) >= min(int(limit), MAX_TABLE_LIMIT): + truncated = truncated or table_truncated + break + return {"rows": rows, "row_count": len(rows), "truncated": truncated, "policies": policies} + + +def load_rl_table(run_name: str, table_name: str, *, policy: Optional[str] = None, limit: int = 500) -> Dict[str, Any]: + """Load a CSV table for a run with path-safe table/policy handling.""" + + run_dir = resolve_run_dir(run_name) + artifact_type = _detect_artifact_type(run_dir) + table = _normalize_table(table_name) + limit = max(0, min(int(limit), MAX_TABLE_LIMIT)) + + if artifact_type in {"opening_30m_rl_workflow", "opening_30m_rule_filter"}: + opening_payload = load_opening_json_table(run_name, run_dir, artifact_type, table, limit=limit) + if opening_payload is not None: + return opening_payload + + if table == "events": + path = _live_events_path(run_dir) + if path is None: + return { + "run": run_name, + "artifact_type": artifact_type, + "table": table, + "policy": policy, + "source_file": None, + "rows": [], + "row_count": 0, + "truncated": False, + "message": "live event log is not available for this run", + } + rows, truncated = _read_jsonl_rows(path, limit=limit) + return { + "run": run_name, + "artifact_type": artifact_type, + "table": table, + "policy": policy, + "source_file": path.name, + "rows": rows, + "row_count": len(rows), + "truncated": truncated, + } + + if artifact_type == "baseline" and table in {"actions", "trades", "equity", "episodes"}: + payload = _read_policy_table(run_dir, table, policy, limit) + payload.update({"run": run_name, "artifact_type": artifact_type, "table": table, "policy": policy}) + return payload + + for file_name in ROOT_TABLE_CANDIDATES.get(table, ()): + path = run_dir / file_name + if _is_run_file(run_dir, path): + rows, truncated = _read_run_csv_rows(run_dir, path, limit=limit) + return { + "run": run_name, + "artifact_type": artifact_type, + "table": table, + "policy": policy, + "source_file": file_name, + "rows": rows, + "row_count": len(rows), + "truncated": truncated, + } + raise FileNotFoundError(f"RL table not found for run={run_name!r}, table={table!r}") + + +def load_rl_cost_gate(run_name: str, *, limit: int = 500) -> Dict[str, Any]: + """Load cost-gate summary and compact CSV tables for a cost-gate run.""" + + run_dir = resolve_run_dir(run_name) + report_path = run_dir / "cost_gate_report.json" + if not _is_run_file(run_dir, report_path): + raise FileNotFoundError(f"cost_gate_report.json not found for run: {run_name}") + report = _read_run_json(run_dir, report_path) + return { + "run": run_name, + "artifact_type": _detect_artifact_type(run_dir), + "summary": report.get("summary", {}), + "gate": load_rl_table(run_name, "gate", limit=limit), + "scenario": load_rl_table(run_name, "scenario", limit=limit), + "rolling": load_rl_table(run_name, "rolling", limit=limit), + } + + +def load_rl_events(run_name: str, *, limit: int = 500) -> Dict[str, Any]: + """Load realtime RL JSONL event tail for a run.""" + + return load_rl_table(run_name, "events", limit=limit) diff --git a/webui/rl_strategy_context.py b/webui/rl_strategy_context.py new file mode 100644 index 000000000..713daf31b --- /dev/null +++ b/webui/rl_strategy_context.py @@ -0,0 +1,114 @@ +"""Strategy-classification metadata for STOM RL dashboard payloads. + +The dashboard compares rule baselines, cost/evaluation artifacts, and RL +experiments. Keep that distinction server-computed so the UI does not infer +profitability or live readiness from artifact names. +""" + +from __future__ import annotations + +from typing import Any, Mapping + +from stom_rl.gap_up_risk_sizing import PRIMARY_FILTER, RiskConfig, risk_unit_account_pct + + +RL_ARTIFACT_TYPES = frozenset({"contextual_bandit", "opening_30m_rl_workflow", "sb3_smoke"}) +EVALUATION_ARTIFACT_TYPES = frozenset({"cost_gate", "performance_leaderboard", "episode_manifest", "portfolio_paper"}) +GUARDRAIL = "research evidence only; not live-ready and not a profit model" + + +def risk_policy_summary(config: RiskConfig | None = None) -> dict[str, float | int | str]: + """Return the locked ts_imb RULE sizing policy used as the main baseline.""" + + policy = config or RiskConfig() + return { + "strategy": "opening_gap_up_rule", + "primary_filter": PRIMARY_FILTER, + "per_trade_fraction_pct": round(policy.per_trade_fraction * 100.0, 6), + "max_concurrent": policy.max_concurrent, + "max_deployed_fraction_pct": round(policy.per_trade_fraction * policy.max_concurrent * 100.0, 6), + "daily_loss_limit_pct": policy.daily_loss_limit_pct, + "cost_bps": policy.cost_bps, + "tp_pct": policy.tp_pct, + "sl_pct": policy.sl_pct, + "risk_unit_account_pct": round(risk_unit_account_pct(policy), 6), + } + + +def build_strategy_context(artifact_type: str, summary: Mapping[str, Any] | None = None) -> dict[str, Any]: + """Classify an RL-dashboard artifact without implying live readiness.""" + + summary_map = dict(summary or {}) + if artifact_type == "baseline": + return { + "line": "rule_mainline", + "label": "RULE MAINLINE", + "primary_baseline": PRIMARY_FILTER, + "is_reinforcement_learning": False, + "is_environment_readiness": False, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": GUARDRAIL, + "risk_policy_summary": risk_policy_summary(), + } + + if artifact_type == "opening_30m_rule_filter": + return { + "line": "evaluation", + "label": "RULE FILTER EVIDENCE", + "primary_baseline": "ts_imb RULE baseline", + "is_reinforcement_learning": False, + "is_environment_readiness": False, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": GUARDRAIL, + "readiness_status": summary_map.get("verdict"), + } + + if artifact_type == "orderbook_rl_readiness": + return { + "line": "rl_experiment", + "label": "RL EXPERIMENT", + "primary_baseline": PRIMARY_FILTER, + "is_reinforcement_learning": False, + "is_environment_readiness": True, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": GUARDRAIL, + "readiness_status": summary_map.get("readiness_status") or summary_map.get("verdict"), + } + + if artifact_type in RL_ARTIFACT_TYPES: + return { + "line": "rl_experiment", + "label": "RL EXPERIMENT", + "primary_baseline": PRIMARY_FILTER, + "is_reinforcement_learning": True, + "is_environment_readiness": False, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": GUARDRAIL, + } + + if artifact_type in EVALUATION_ARTIFACT_TYPES: + return { + "line": "evaluation", + "label": "EVALUATION", + "primary_baseline": PRIMARY_FILTER, + "is_reinforcement_learning": False, + "is_environment_readiness": False, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": GUARDRAIL, + } + + return { + "line": "unknown", + "label": "UNKNOWN", + "primary_baseline": PRIMARY_FILTER, + "is_reinforcement_learning": False, + "is_environment_readiness": False, + "is_live_ready": False, + "is_profit_model": False, + "guardrail": GUARDRAIL, + } diff --git a/webui/run.py b/webui/run.py index 64a7b1670..7a7c21683 100644 --- a/webui/run.py +++ b/webui/run.py @@ -9,6 +9,11 @@ import webbrowser import time +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8", errors="replace") +if hasattr(sys.stderr, "reconfigure"): + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + def check_dependencies(): """Check if dependencies are installed""" try: @@ -70,16 +75,33 @@ def main(): # Start server try: from app import app + host = os.environ.get("KRONOS_WEBUI_HOST", "127.0.0.1") + port = int(os.environ.get("KRONOS_WEBUI_PORT", os.environ.get("PORT", "7070"))) + open_browser = os.environ.get("KRONOS_WEBUI_OPEN_BROWSER", "1").lower() not in {"0", "false", "no", "off"} + # Debug stays available, but the file-watch reloader is OFF by default: + # webui/ holds runtime artifacts (rl_runs, stom_predictions, logs) that + # change constantly, which would otherwise restart the server in a loop + # and re-open the browser tab on every restart. Opt back in with + # KRONOS_WEBUI_RELOAD=1 when editing server code. + debug_mode = os.environ.get("KRONOS_WEBUI_DEBUG", "1").lower() not in {"0", "false", "no", "off"} + use_reloader = os.environ.get("KRONOS_WEBUI_RELOAD", "0").lower() in {"1", "true", "yes", "on"} + # In the reloader child process Werkzeug sets WERKZEUG_RUN_MAIN; never + # re-open the browser there, otherwise each restart spawns a new tab. + is_reloader_child = bool(os.environ.get("WERKZEUG_RUN_MAIN")) + access_host = "localhost" if host in {"0.0.0.0", "::"} else host + access_url = f"http://{access_host}:{port}" + print("✅ Web server started successfully!") - print(f"🌐 Access URL: http://localhost:7070") + print(f"🌐 Access URL: {access_url}") print("💡 Tip: Press Ctrl+C to stop server") - - # Auto-open browser - time.sleep(2) - webbrowser.open('http://localhost:7070') - + + # Auto-open browser once, only in the main process and only if enabled. + if open_browser and not is_reloader_child: + time.sleep(2) + webbrowser.open(access_url) + # Start Flask application - app.run(debug=True, host='0.0.0.0', port=7070) + app.run(debug=debug_mode, host=host, port=port, use_reloader=use_reloader) except Exception as e: print(f"❌ Startup failed: {e}") diff --git a/webui/static/v2/dist/assets/index-DPLWELOm.js b/webui/static/v2/dist/assets/index-DPLWELOm.js new file mode 100644 index 000000000..67f7b23df --- /dev/null +++ b/webui/static/v2/dist/assets/index-DPLWELOm.js @@ -0,0 +1,163 @@ +var fF=Object.defineProperty;var oC=r=>{throw TypeError(r)};var hF=(r,t,e)=>t in r?fF(r,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):r[t]=e;var Ur=(r,t,e)=>hF(r,typeof t!="symbol"?t+"":t,e),s1=(r,t,e)=>t.has(r)||oC("Cannot "+e);var gt=(r,t,e)=>(s1(r,t,"read from private field"),e?e.call(r):t.get(r)),Qr=(r,t,e)=>t.has(r)?oC("Cannot add the same private member more than once"):t instanceof WeakSet?t.add(r):t.set(r,e),Vr=(r,t,e,a)=>(s1(r,t,"write to private field"),a?a.call(r,e):t.set(r,e),e),Ya=(r,t,e)=>(s1(r,t,"access private method"),e);(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))a(i);new MutationObserver(i=>{for(const n of i)if(n.type==="childList")for(const o of n.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&a(o)}).observe(document,{childList:!0,subtree:!0});function e(i){const n={};return i.integrity&&(n.integrity=i.integrity),i.referrerPolicy&&(n.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?n.credentials="include":i.crossOrigin==="anonymous"?n.credentials="omit":n.credentials="same-origin",n}function a(i){if(i.ep)return;i.ep=!0;const n=e(i);fetch(i.href,n)}})();const pF=!1;var Y2=Array.isArray,gF=Array.prototype.indexOf,Oc=Array.prototype.includes,Um=Array.from,yF=Object.defineProperty,_c=Object.getOwnPropertyDescriptor,mF=Object.getOwnPropertyDescriptors,_F=Object.prototype,xF=Array.prototype,WP=Object.getPrototypeOf,sC=Object.isExtensible;const Xu=()=>{};function UP(r){for(var t=0;t{r=a,t=i});return{promise:e,resolve:r,reject:t}}function Ki(r,t){if(Array.isArray(r))return r;if(!(Symbol.iterator in r))return Array.from(r);const e=[];for(const a of r)if(e.push(a),e.length===t)break;return e}const li=2,zc=4,qm=8,$P=1<<24,Gl=16,So=32,vv=64,Bb=128,qn=512,Qa=1024,gi=2048,To=4096,hn=8192,$n=16384,Sv=32768,Vb=1<<25,Bc=65536,lC=1<<17,bF=1<<18,ed=1<<19,wF=1<<20,Zo=1<<25,cv=65536,Fb=1<<21,Z2=1<<22,Cl=1<<23,ju=Symbol("$state"),SF=Symbol("legacy props"),TF=Symbol(""),Ts=new class extends Error{constructor(){super(...arguments);Ur(this,"name","StaleReactionError");Ur(this,"message","The reaction that called `getAbortSignal()` was re-run or destroyed")}};function YP(r){throw new Error("https://svelte.dev/e/lifecycle_outside_component")}function AF(){throw new Error("https://svelte.dev/e/async_derived_orphan")}function CF(r,t,e){throw new Error("https://svelte.dev/e/each_key_duplicate")}function kF(r){throw new Error("https://svelte.dev/e/effect_in_teardown")}function DF(){throw new Error("https://svelte.dev/e/effect_in_unowned_derived")}function MF(r){throw new Error("https://svelte.dev/e/effect_orphan")}function LF(){throw new Error("https://svelte.dev/e/effect_update_depth_exceeded")}function IF(r){throw new Error("https://svelte.dev/e/props_invalid_value")}function RF(){throw new Error("https://svelte.dev/e/state_descriptors_fixed")}function PF(){throw new Error("https://svelte.dev/e/state_prototype_fixed")}function EF(){throw new Error("https://svelte.dev/e/state_unsafe_mutation")}function NF(){throw new Error("https://svelte.dev/e/svelte_boundary_reset_onerror")}const OF=1,zF=2,ZP=4,BF=8,VF=16,FF=1,GF=4,HF=8,WF=16,UF=1,qF=2,fi=Symbol(),XP="http://www.w3.org/1999/xhtml",$F="http://www.w3.org/2000/svg",YF="http://www.w3.org/1998/Math/MathML";function ZF(){console.warn("https://svelte.dev/e/select_multiple_invalid_value")}function XF(){console.warn("https://svelte.dev/e/svelte_boundary_reset_noop")}function jP(r){return r===this.v}function KP(r,t){return r!=r?t==t:r!==t||r!==null&&typeof r=="object"||typeof r=="function"}function QP(r){return!KP(r,this.v)}let jF=!1,Bi=null;function Vc(r){Bi=r}function $r(r,t=!1,e){Bi={p:Bi,i:!1,c:null,e:null,s:r,x:null,r:aa,l:null}}function Yr(r){var t=Bi,e=t.e;if(e!==null){t.e=null;for(var a of e)wE(a)}return t.i=!0,Bi=t.p,{}}function JP(){return!0}let Nu=[];function tE(){var r=Nu;Nu=[],UP(r)}function Ku(r){if(Nu.length===0&&!If){var t=Nu;queueMicrotask(()=>{t===Nu&&tE()})}Nu.push(r)}function KF(){for(;Nu.length>0;)tE()}function eE(r){var t=aa;if(t===null)return qr.f|=Cl,r;if(!(t.f&Sv)&&!(t.f&zc))throw r;gl(r,t)}function gl(r,t){for(;t!==null;){if(t.f&Bb){if(!(t.f&Sv))throw r;try{t.b.error(r);return}catch(e){r=e}}t=t.parent}throw r}const QF=-7169;function Va(r,t){r.f=r.f&QF|t}function X2(r){r.f&qn||r.deps===null?Va(r,Qa):Va(r,To)}function rE(r){if(r!==null)for(const t of r)!(t.f&li)||!(t.f&cv)||(t.f^=cv,rE(t.deps))}function aE(r,t,e){r.f&gi?t.add(r):r.f&To&&e.add(r),rE(r.deps),Va(r,Qa)}function iE(r,t,e){if(r==null)return t(void 0),e&&e(void 0),Xu;const a=Hl(()=>r.subscribe(t,e));return a.unsubscribe?()=>a.unsubscribe():a}const Fv=[];function JF(r,t){return{subscribe:Wi(r,t).subscribe}}function Wi(r,t=Xu){let e=null;const a=new Set;function i(s){if(KP(r,s)&&(r=s,e)){const l=!Fv.length;for(const u of a)u[1](),Fv.push(u,r);if(l){for(let u=0;u{a.delete(u),a.size===0&&e&&(e(),e=null)}}return{set:i,update:n,subscribe:o}}function t7(r,t,e){const a=!Array.isArray(r),i=a?[r]:r;if(!i.every(Boolean))throw new Error("derived() expects stores as input, got a falsy value");const n=t.length<2;return JF(e,(o,s)=>{let l=!1;const u=[];let v=0,c=Xu;const d=()=>{if(v)return;c();const h=t(a?u[0]:u,o,s);n?o(h):c=typeof h=="function"?h:Xu},f=i.map((h,g)=>iE(h,m=>{u[g]=m,v&=~(1<{v|=1<t=e)(),t}let dg=!1;function r7(r){var t=dg;try{return dg=!1,[r(),dg]}finally{dg=t}}const zd=new Set;let oa=null,Xo=null,Gb=null,If=!1,l1=!1,hc=null,my=null;var uC=0;let a7=1;var kc,Dc,Mc,Lc,Mh,In,Ic,hl,As,Rc,Mi,Hb,Wb,Ub,qb,nE;const Gm=class Gm{constructor(){Qr(this,Mi);Ur(this,"id",a7++);Ur(this,"current",new Map);Ur(this,"previous",new Map);Qr(this,kc,new Set);Qr(this,Dc,new Set);Qr(this,Mc,0);Qr(this,Lc,0);Qr(this,Mh,null);Qr(this,In,[]);Qr(this,Ic,new Set);Qr(this,hl,new Set);Qr(this,As,new Map);Ur(this,"is_fork",!1);Qr(this,Rc,!1)}skip_effect(t){gt(this,As).has(t)||gt(this,As).set(t,{d:[],m:[]})}unskip_effect(t){var e=gt(this,As).get(t);if(e){gt(this,As).delete(t);for(var a of e.d)Va(a,gi),this.schedule(a);for(a of e.m)Va(a,To),this.schedule(a)}}capture(t,e){e!==fi&&!this.previous.has(t)&&this.previous.set(t,e),t.f&Cl||(this.current.set(t,t.v),Xo?.set(t,t.v))}activate(){oa=this}deactivate(){oa=null,Xo=null}flush(){try{if(l1=!0,oa=this,!Ya(this,Mi,Hb).call(this)){for(const t of gt(this,Ic))gt(this,hl).delete(t),Va(t,gi),this.schedule(t);for(const t of gt(this,hl))Va(t,To),this.schedule(t)}Ya(this,Mi,Wb).call(this)}finally{uC=0,Gb=null,hc=null,my=null,l1=!1,oa=null,Xo=null,kl.clear()}}discard(){for(const t of gt(this,Dc))t(this);gt(this,Dc).clear()}increment(t){Vr(this,Mc,gt(this,Mc)+1),t&&Vr(this,Lc,gt(this,Lc)+1)}decrement(t,e){Vr(this,Mc,gt(this,Mc)-1),t&&Vr(this,Lc,gt(this,Lc)-1),!(gt(this,Rc)||e)&&(Vr(this,Rc,!0),Ku(()=>{Vr(this,Rc,!1),this.flush()}))}oncommit(t){gt(this,kc).add(t)}ondiscard(t){gt(this,Dc).add(t)}settled(){return(gt(this,Mh)??Vr(this,Mh,qP())).promise}static ensure(){if(oa===null){const t=oa=new Gm;l1||(zd.add(oa),If||Ku(()=>{oa===t&&t.flush()}))}return oa}apply(){{Xo=null;return}}schedule(t){if(Gb=t,t.b?.is_pending&&t.f&(zc|qm|$P)&&!(t.f&Sv)){t.b.defer_effect(t);return}for(var e=t;e.parent!==null;){e=e.parent;var a=e.f;if(hc!==null&&e===aa&&(qr===null||!(qr.f&li)))return;if(a&(vv|So)){if(!(a&Qa))return;e.f^=Qa}}gt(this,In).push(e)}};kc=new WeakMap,Dc=new WeakMap,Mc=new WeakMap,Lc=new WeakMap,Mh=new WeakMap,In=new WeakMap,Ic=new WeakMap,hl=new WeakMap,As=new WeakMap,Rc=new WeakMap,Mi=new WeakSet,Hb=function(){return this.is_fork||gt(this,Lc)>0},Wb=function(){var s;uC++>1e3&&n7();const t=gt(this,In);Vr(this,In,[]),this.apply();var e=hc=[],a=[],i=my=[];for(const l of t)try{Ya(this,Mi,Ub).call(this,l,e,a)}catch(u){throw uE(l),u}if(oa=null,i.length>0){var n=Gm.ensure();for(const l of i)n.schedule(l)}if(hc=null,my=null,Ya(this,Mi,Hb).call(this)){Ya(this,Mi,qb).call(this,a),Ya(this,Mi,qb).call(this,e);for(const[l,u]of gt(this,As))lE(l,u)}else{gt(this,Mc)===0&&zd.delete(this),gt(this,Ic).clear(),gt(this,hl).clear();for(const l of gt(this,kc))l(this);gt(this,kc).clear(),vC(a),vC(e),gt(this,Mh)?.resolve()}var o=oa;if(gt(this,In).length>0){const l=o??(o=this);gt(l,In).push(...gt(this,In).filter(u=>!gt(l,In).includes(u)))}o!==null&&(zd.add(o),Ya(s=o,Mi,Wb).call(s)),zd.has(this)||Ya(this,Mi,nE).call(this)},Ub=function(t,e,a){t.f^=Qa;for(var i=t.first;i!==null;){var n=i.f,o=(n&(So|vv))!==0,s=o&&(n&Qa)!==0,l=s||(n&hn)!==0||gt(this,As).has(i);if(!l&&i.fn!==null){o?i.f^=Qa:n&zc?e.push(i):Ph(i)&&(n&Gl&>(this,hl).add(i),Gc(i));var u=i.first;if(u!==null){i=u;continue}}for(;i!==null;){var v=i.next;if(v!==null){i=v;break}i=i.parent}}},qb=function(t){for(var e=0;e!this.current.has(v));if(a.length>0){u.activate();var i=new Set,n=new Map;for(var o of e)oE(o,a,i,n);if(gt(u,In).length>0){u.apply();for(var s of gt(u,In))Ya(l=u,Mi,Ub).call(l,s,[],[])}u.deactivate()}}}};let dv=Gm;function i7(r){var t=If;If=!0;try{for(var e;;){if(KF(),oa===null)return e;oa.flush()}}finally{If=t}}function n7(){try{LF()}catch(r){gl(r,Gb)}}let bs=null;function vC(r){var t=r.length;if(t!==0){for(var e=0;e0)){kl.clear();for(const i of bs){if(i.f&($n|hn))continue;const n=[i];let o=i.parent;for(;o!==null;)bs.has(o)&&(bs.delete(o),n.push(o)),o=o.parent;for(let s=n.length-1;s>=0;s--){const l=n[s];l.f&($n|hn)||Gc(l)}}bs.clear()}}bs=null}}function oE(r,t,e,a){if(!e.has(r)&&(e.add(r),r.reactions!==null))for(const i of r.reactions){const n=i.f;n&li?oE(i,t,e,a):n&(Z2|Gl)&&!(n&gi)&&sE(i,t,a)&&(Va(i,gi),j2(i))}}function sE(r,t,e){const a=e.get(r);if(a!==void 0)return a;if(r.deps!==null)for(const i of r.deps){if(Oc.call(t,i))return!0;if(i.f&li&&sE(i,t,e))return e.set(i,!0),!0}return e.set(r,!1),!1}function j2(r){oa.schedule(r)}function lE(r,t){if(!(r.f&So&&r.f&Qa)){r.f&gi?t.d.push(r):r.f&To&&t.m.push(r),Va(r,Qa);for(var e=r.first;e!==null;)lE(e,t),e=e.next}}function uE(r){Va(r,Qa);for(var t=r.first;t!==null;)uE(t),t=t.next}function o7(r){let t=0,e=fv(0),a;return()=>{tS()&&(p(e),Zm(()=>(t===0&&(a=Hl(()=>r(()=>Rf(e)))),t+=1,()=>{Ku(()=>{t-=1,t===0&&(a?.(),a=void 0,Rf(e))})})))}}var s7=Bc|ed;function l7(r,t,e,a){new u7(r,t,e,a)}var Rn,$2,Wo,$u,Xi,Uo,un,fo,Cs,Yu,pl,Pc,Ec,Nc,ks,Hm,ri,v7,c7,d7,$b,_y,xy,Yb;class u7{constructor(t,e,a,i){Qr(this,ri);Ur(this,"parent");Ur(this,"is_pending",!1);Ur(this,"transform_error");Qr(this,Rn);Qr(this,$2,null);Qr(this,Wo);Qr(this,$u);Qr(this,Xi);Qr(this,Uo,null);Qr(this,un,null);Qr(this,fo,null);Qr(this,Cs,null);Qr(this,Yu,0);Qr(this,pl,0);Qr(this,Pc,!1);Qr(this,Ec,new Set);Qr(this,Nc,new Set);Qr(this,ks,null);Qr(this,Hm,o7(()=>(Vr(this,ks,fv(gt(this,Yu))),()=>{Vr(this,ks,null)})));Vr(this,Rn,t),Vr(this,Wo,e),Vr(this,$u,n=>{var o=aa;o.b=this,o.f|=Bb,a(n)}),this.parent=aa.b,this.transform_error=i??this.parent?.transform_error??(n=>n),Vr(this,Xi,eS(()=>{Ya(this,ri,$b).call(this)},s7))}defer_effect(t){aE(t,gt(this,Ec),gt(this,Nc))}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!gt(this,Wo).pending}update_pending_count(t,e){Ya(this,ri,Yb).call(this,t,e),Vr(this,Yu,gt(this,Yu)+t),!(!gt(this,ks)||gt(this,Pc))&&(Vr(this,Pc,!0),Ku(()=>{Vr(this,Pc,!1),gt(this,ks)&&Fc(gt(this,ks),gt(this,Yu))}))}get_effect_pending(){return gt(this,Hm).call(this),p(gt(this,ks))}error(t){var e=gt(this,Wo).onerror;let a=gt(this,Wo).failed;if(!e&&!a)throw t;gt(this,Uo)&&(en(gt(this,Uo)),Vr(this,Uo,null)),gt(this,un)&&(en(gt(this,un)),Vr(this,un,null)),gt(this,fo)&&(en(gt(this,fo)),Vr(this,fo,null));var i=!1,n=!1;const o=()=>{if(i){XF();return}i=!0,n&&NF(),gt(this,fo)!==null&&Qu(gt(this,fo),()=>{Vr(this,fo,null)}),Ya(this,ri,xy).call(this,()=>{Ya(this,ri,$b).call(this)})},s=l=>{try{n=!0,e?.(l,o),n=!1}catch(u){gl(u,gt(this,Xi)&>(this,Xi).parent)}a&&Vr(this,fo,Ya(this,ri,xy).call(this,()=>{try{return Nn(()=>{var u=aa;u.b=this,u.f|=Bb,a(gt(this,Rn),()=>l,()=>o)})}catch(u){return gl(u,gt(this,Xi).parent),null}}))};Ku(()=>{var l;try{l=this.transform_error(t)}catch(u){gl(u,gt(this,Xi)&>(this,Xi).parent);return}l!==null&&typeof l=="object"&&typeof l.then=="function"?l.then(s,u=>gl(u,gt(this,Xi)&>(this,Xi).parent)):s(l)})}}Rn=new WeakMap,$2=new WeakMap,Wo=new WeakMap,$u=new WeakMap,Xi=new WeakMap,Uo=new WeakMap,un=new WeakMap,fo=new WeakMap,Cs=new WeakMap,Yu=new WeakMap,pl=new WeakMap,Pc=new WeakMap,Ec=new WeakMap,Nc=new WeakMap,ks=new WeakMap,Hm=new WeakMap,ri=new WeakSet,v7=function(){try{Vr(this,Uo,Nn(()=>gt(this,$u).call(this,gt(this,Rn))))}catch(t){this.error(t)}},c7=function(t){const e=gt(this,Wo).failed;e&&Vr(this,fo,Nn(()=>{e(gt(this,Rn),()=>t,()=>()=>{})}))},d7=function(){const t=gt(this,Wo).pending;t&&(this.is_pending=!0,Vr(this,un,Nn(()=>t(gt(this,Rn)))),Ku(()=>{var e=Vr(this,Cs,document.createDocumentFragment()),a=Ls();e.append(a),Vr(this,Uo,Ya(this,ri,xy).call(this,()=>Nn(()=>gt(this,$u).call(this,a)))),gt(this,pl)===0&&(gt(this,Rn).before(e),Vr(this,Cs,null),Qu(gt(this,un),()=>{Vr(this,un,null)}),Ya(this,ri,_y).call(this,oa))}))},$b=function(){try{if(this.is_pending=this.has_pending_snippet(),Vr(this,pl,0),Vr(this,Yu,0),Vr(this,Uo,Nn(()=>{gt(this,$u).call(this,gt(this,Rn))})),gt(this,pl)>0){var t=Vr(this,Cs,document.createDocumentFragment());iS(gt(this,Uo),t);const e=gt(this,Wo).pending;Vr(this,un,Nn(()=>e(gt(this,Rn))))}else Ya(this,ri,_y).call(this,oa)}catch(e){this.error(e)}},_y=function(t){this.is_pending=!1;for(const e of gt(this,Ec))Va(e,gi),t.schedule(e);for(const e of gt(this,Nc))Va(e,To),t.schedule(e);gt(this,Ec).clear(),gt(this,Nc).clear()},xy=function(t){var e=aa,a=qr,i=Bi;ns(gt(this,Xi)),Xn(gt(this,Xi)),Vc(gt(this,Xi).ctx);try{return dv.ensure(),t()}catch(n){return eE(n),null}finally{ns(e),Xn(a),Vc(i)}},Yb=function(t,e){var a;if(!this.has_pending_snippet()){this.parent&&Ya(a=this.parent,ri,Yb).call(a,t,e);return}Vr(this,pl,gt(this,pl)+t),gt(this,pl)===0&&(Ya(this,ri,_y).call(this,e),gt(this,un)&&Qu(gt(this,un),()=>{Vr(this,un,null)}),gt(this,Cs)&&(gt(this,Rn).before(gt(this,Cs)),Vr(this,Cs,null)))};function f7(r,t,e,a){const i=$m;var n=r.filter(d=>!d.settled);if(e.length===0&&n.length===0){a(t.map(i));return}var o=aa,s=h7(),l=n.length===1?n[0].promise:n.length>1?Promise.all(n.map(d=>d.promise)):null;function u(d){s();try{a(d)}catch(f){o.f&$n||gl(f,o)}Wy()}if(e.length===0){l.then(()=>u(t.map(i)));return}var v=vE();function c(){Promise.all(e.map(d=>p7(d))).then(d=>u([...t.map(i),...d])).catch(d=>gl(d,o)).finally(()=>v())}l?l.then(()=>{s(),c(),Wy()}):c()}function h7(){var r=aa,t=qr,e=Bi,a=oa;return function(n=!0){ns(r),Xn(t),Vc(e),n&&!(r.f&$n)&&(a?.activate(),a?.apply())}}function Wy(r=!0){ns(null),Xn(null),Vc(null),r&&oa?.deactivate()}function vE(){var r=aa.b,t=oa,e=r.is_rendered();return r.update_pending_count(1,t),t.increment(e),(a=!1)=>{r.update_pending_count(-1,t),t.decrement(e,a)}}function $m(r){var t=li|gi,e=qr!==null&&qr.f&li?qr:null;return aa!==null&&(aa.f|=ed),{ctx:Bi,deps:null,effects:null,equals:jP,f:t,fn:r,reactions:null,rv:0,v:fi,wv:0,parent:e??aa,ac:null}}function p7(r,t,e){let a=aa;a===null&&AF();var i=void 0,n=fv(fi),o=!qr,s=new Map;return D7(()=>{var l=aa,u=qP();i=u.promise;try{Promise.resolve(r()).then(u.resolve,u.reject).finally(Wy)}catch(f){u.reject(f),Wy()}var v=oa;if(o){if(l.f&Sv)var c=vE();if(a.b.is_rendered())s.get(v)?.reject(Ts),s.delete(v);else{for(const f of s.values())f.reject(Ts);s.clear()}s.set(v,u)}const d=(f,h=void 0)=>{if(c){var g=h===Ts;c(g)}if(!(h===Ts||l.f&$n)){if(v.activate(),h)n.f|=Cl,Fc(n,h);else{n.f&Cl&&(n.f^=Cl),Fc(n,f);for(const[m,x]of s){if(s.delete(m),m===v)break;x.reject(Ts)}}v.deactivate()}};u.promise.then(d,f=>d(null,f||"unknown"))}),bE(()=>{for(const l of s.values())l.reject(Ts)}),new Promise(l=>{function u(v){function c(){v===i?l(n):u(i)}v.then(c,c)}u(i)})}function q(r){const t=$m(r);return ME(t),t}function cE(r){const t=$m(r);return t.equals=QP,t}function g7(r){var t=r.effects;if(t!==null){r.effects=null;for(var e=0;e0&&!hE&&x7()}return t}function x7(){hE=!1;for(const r of Zb)r.f&Qa&&Va(r,To),Ph(r)&&Gc(r);Zb.clear()}function Rf(r){W(r,r.v+1)}function pE(r,t,e){var a=r.reactions;if(a!==null)for(var i=a.length,n=0;n{if(Ju===n)return s();var l=qr,u=Ju;Xn(null),pC(n);var v=s();return Xn(l),pC(u),v};return a&&e.set("length",rt(r.length)),new Proxy(r,{defineProperty(s,l,u){(!("value"in u)||u.configurable===!1||u.enumerable===!1||u.writable===!1)&&RF();var v=e.get(l);return v===void 0?o(()=>{var c=rt(u.value);return e.set(l,c),c}):W(v,u.value,!0),!0},deleteProperty(s,l){var u=e.get(l);if(u===void 0){if(l in s){const v=o(()=>rt(fi));e.set(l,v),Rf(i)}}else W(u,fi),Rf(i);return!0},get(s,l,u){if(l===ju)return r;var v=e.get(l),c=l in s;if(v===void 0&&(!c||_c(s,l)?.writable)&&(v=o(()=>{var f=Fr(c?s[l]:fi),h=rt(f);return h}),e.set(l,v)),v!==void 0){var d=p(v);return d===fi?void 0:d}return Reflect.get(s,l,u)},getOwnPropertyDescriptor(s,l){var u=Reflect.getOwnPropertyDescriptor(s,l);if(u&&"value"in u){var v=e.get(l);v&&(u.value=p(v))}else if(u===void 0){var c=e.get(l),d=c?.v;if(c!==void 0&&d!==fi)return{enumerable:!0,configurable:!0,value:d,writable:!0}}return u},has(s,l){if(l===ju)return!0;var u=e.get(l),v=u!==void 0&&u.v!==fi||Reflect.has(s,l);if(u!==void 0||aa!==null&&(!v||_c(s,l)?.writable)){u===void 0&&(u=o(()=>{var d=v?Fr(s[l]):fi,f=rt(d);return f}),e.set(l,u));var c=p(u);if(c===fi)return!1}return v},set(s,l,u,v){var c=e.get(l),d=l in s;if(a&&l==="length")for(var f=u;frt(fi)),e.set(f+"",h))}if(c===void 0)(!d||_c(s,l)?.writable)&&(c=o(()=>rt(void 0)),W(c,Fr(u)),e.set(l,c));else{d=c.v!==fi;var g=o(()=>Fr(u));W(c,g)}var m=Reflect.getOwnPropertyDescriptor(s,l);if(m?.set&&m.set.call(v,u),!d){if(a&&typeof l=="string"){var x=e.get("length"),b=Number(l);Number.isInteger(b)&&b>=x.v&&W(x,b+1)}Rf(i)}return!0},ownKeys(s){p(i);var l=Reflect.ownKeys(s).filter(c=>{var d=e.get(c);return d===void 0||d.v!==fi});for(var[u,v]of e)v.v!==fi&&!(u in s)&&l.push(u);return l},setPrototypeOf(){PF()}})}function cC(r){try{if(r!==null&&typeof r=="object"&&ju in r)return r[ju]}catch{}return r}function b7(r,t){return Object.is(cC(r),cC(t))}var dC,gE,yE,mE;function w7(){if(dC===void 0){dC=window,gE=/Firefox/.test(navigator.userAgent);var r=Element.prototype,t=Node.prototype,e=Text.prototype;yE=_c(t,"firstChild").get,mE=_c(t,"nextSibling").get,sC(r)&&(r.__click=void 0,r.__className=void 0,r.__attributes=null,r.__style=void 0,r.__e=void 0),sC(e)&&(e.__t=void 0)}}function Ls(r=""){return document.createTextNode(r)}function Hn(r){return yE.call(r)}function Rh(r){return mE.call(r)}function y(r,t){return Hn(r)}function lr(r,t=!1){{var e=Hn(r);return e instanceof Comment&&e.data===""?Rh(e):e}}function _(r,t=1,e=!1){let a=r;for(;t--;)a=Rh(a);return a}function S7(r){r.textContent=""}function _E(){return!1}function xE(r,t,e){return document.createElementNS(t??XP,r,void 0)}let fC=!1;function T7(){fC||(fC=!0,document.addEventListener("reset",r=>{Promise.resolve().then(()=>{if(!r.defaultPrevented)for(const t of r.target.elements)t.__on_r?.()})},{capture:!0}))}function Q2(r){var t=qr,e=aa;Xn(null),ns(null);try{return r()}finally{Xn(t),ns(e)}}function J2(r,t,e,a=e){r.addEventListener(t,()=>Q2(e));const i=r.__on_r;i?r.__on_r=()=>{i(),a(!0)}:r.__on_r=()=>a(!0),T7()}function A7(r){aa===null&&(qr===null&&MF(),DF()),Rl&&kF()}function C7(r,t){var e=t.last;e===null?t.last=t.first=r:(e.next=r,r.prev=e,t.last=r)}function Us(r,t){var e=aa;e!==null&&e.f&hn&&(r|=hn);var a={ctx:Bi,deps:null,nodes:null,f:r|gi|qn,first:null,fn:t,last:null,next:null,parent:e,b:e&&e.b,prev:null,teardown:null,wv:0,ac:null},i=a;if(r&zc)hc!==null?hc.push(a):dv.ensure().schedule(a);else if(t!==null){try{Gc(a)}catch(o){throw en(a),o}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&ed)&&(i=i.first,r&Gl&&r&Bc&&i!==null&&(i.f|=Bc))}if(i!==null&&(i.parent=e,e!==null&&C7(i,e),qr!==null&&qr.f&li&&!(r&vv))){var n=qr;(n.effects??(n.effects=[])).push(i)}return a}function tS(){return qr!==null&&!yo}function bE(r){const t=Us(qm,null);return Va(t,Qa),t.teardown=r,t}function Ym(r){A7();var t=aa.f,e=!qr&&(t&So)!==0&&(t&Sv)===0;if(e){var a=Bi;(a.e??(a.e=[])).push(r)}else return wE(r)}function wE(r){return Us(zc|wF,r)}function k7(r){dv.ensure();const t=Us(vv|ed,r);return(e={})=>new Promise(a=>{e.outro?Qu(t,()=>{en(t),a(void 0)}):(en(t),a(void 0))})}function SE(r){return Us(zc,r)}function D7(r){return Us(Z2|ed,r)}function Zm(r,t=0){return Us(qm|t,r)}function U(r,t=[],e=[],a=[]){f7(a,t,e,i=>{Us(qm,()=>r(...i.map(p)))})}function eS(r,t=0){var e=Us(Gl|t,r);return e}function Nn(r){return Us(So|ed,r)}function TE(r){var t=r.teardown;if(t!==null){const e=Rl,a=qr;hC(!0),Xn(null);try{t.call(null)}finally{hC(e),Xn(a)}}}function rS(r,t=!1){var e=r.first;for(r.first=r.last=null;e!==null;){const i=e.ac;i!==null&&Q2(()=>{i.abort(Ts)});var a=e.next;e.f&vv?e.parent=null:en(e,t),e=a}}function M7(r){for(var t=r.first;t!==null;){var e=t.next;t.f&So||en(t),t=e}}function en(r,t=!0){var e=!1;(t||r.f&bF)&&r.nodes!==null&&r.nodes.end!==null&&(AE(r.nodes.start,r.nodes.end),e=!0),Va(r,Vb),rS(r,t&&!e),Jf(r,0);var a=r.nodes&&r.nodes.t;if(a!==null)for(const n of a)n.stop();TE(r),r.f^=Vb,r.f|=$n;var i=r.parent;i!==null&&i.first!==null&&CE(r),r.next=r.prev=r.teardown=r.ctx=r.deps=r.fn=r.nodes=r.ac=null}function AE(r,t){for(;r!==null;){var e=r===t?null:Rh(r);r.remove(),r=e}}function CE(r){var t=r.parent,e=r.prev,a=r.next;e!==null&&(e.next=a),a!==null&&(a.prev=e),t!==null&&(t.first===r&&(t.first=a),t.last===r&&(t.last=e))}function Qu(r,t,e=!0){var a=[];kE(r,a,!0);var i=()=>{e&&en(r),t&&t()},n=a.length;if(n>0){var o=()=>--n||i();for(var s of a)s.out(o)}else i()}function kE(r,t,e){if(!(r.f&hn)){r.f^=hn;var a=r.nodes&&r.nodes.t;if(a!==null)for(const s of a)(s.is_global||e)&&t.push(s);for(var i=r.first;i!==null;){var n=i.next,o=(i.f&Bc)!==0||(i.f&So)!==0&&(r.f&Gl)!==0;kE(i,t,o?e:!1),i=n}}}function aS(r){DE(r,!0)}function DE(r,t){if(r.f&hn){r.f^=hn,r.f&Qa||(Va(r,gi),dv.ensure().schedule(r));for(var e=r.first;e!==null;){var a=e.next,i=(e.f&Bc)!==0||(e.f&So)!==0;DE(e,i?t:!1),e=a}var n=r.nodes&&r.nodes.t;if(n!==null)for(const o of n)(o.is_global||t)&&o.in()}}function iS(r,t){if(r.nodes)for(var e=r.nodes.start,a=r.nodes.end;e!==null;){var i=e===a?null:Rh(e);t.append(e),e=i}}let by=!1,Rl=!1;function hC(r){Rl=r}let qr=null,yo=!1;function Xn(r){qr=r}let aa=null;function ns(r){aa=r}let Yn=null;function ME(r){qr!==null&&(Yn===null?Yn=[r]:Yn.push(r))}let ji=null,ln=0,Dn=null;function L7(r){Dn=r}let LE=1,Ou=0,Ju=Ou;function pC(r){Ju=r}function IE(){return++LE}function Ph(r){var t=r.f;if(t&gi)return!0;if(t&li&&(r.f&=~cv),t&To){for(var e=r.deps,a=e.length,i=0;ir.wv)return!0}t&qn&&Xo===null&&Va(r,Qa)}return!1}function RE(r,t,e=!0){var a=r.reactions;if(a!==null&&!(Yn!==null&&Oc.call(Yn,r)))for(var i=0;i{r.ac.abort(Ts)}),r.ac=null);try{r.f|=Fb;var v=r.fn,c=v();r.f|=Sv;var d=r.deps,f=oa?.is_fork;if(ji!==null){var h;if(f||Jf(r,ln),d!==null&&ln>0)for(d.length=ln+ji.length,h=0;h{throw m});throw d}}finally{r[zu]=t,delete r.currentTarget,Xn(v),ns(c)}}}const E7=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:r=>r});function N7(r){return E7?.createHTML(r)??r}function BE(r){var t=xE("template");return t.innerHTML=N7(r.replaceAll("","")),t.content}function hv(r,t){var e=aa;e.nodes===null&&(e.nodes={start:r,end:t,a:null,t:null})}function G(r,t){var e=(t&UF)!==0,a=(t&qF)!==0,i,n=!r.startsWith("");return()=>{i===void 0&&(i=BE(n?r:""+r),e||(i=Hn(i)));var o=a||gE?document.importNode(i,!0):i.cloneNode(!0);if(e){var s=Hn(o),l=o.lastChild;hv(s,l)}else hv(o,o);return o}}function O7(r,t,e="svg"){var a=!r.startsWith(""),i=`<${e}>${a?r:""+r}`,n;return()=>{if(!n){var o=BE(i),s=Hn(o);for(n=document.createDocumentFragment();Hn(s);)n.appendChild(Hn(s))}var l=n.cloneNode(!0);{var u=Hn(l),v=l.lastChild;hv(u,v)}return l}}function z7(r,t){return O7(r,t,"svg")}function Hc(r=""){{var t=Ls(r+"");return hv(t,t),t}}function jo(){var r=document.createDocumentFragment(),t=document.createComment(""),e=Ls();return r.append(t,e),hv(t,e),r}function F(r,t){r!==null&&r.before(t)}function A(r,t){var e=t==null?"":typeof t=="object"?`${t}`:t;e!==(r.__t??(r.__t=r.nodeValue))&&(r.__t=e,r.nodeValue=`${e}`)}function B7(r,t){return V7(r,t)}const fg=new Map;function V7(r,{target:t,anchor:e,props:a={},events:i,context:n,intro:o=!0,transformError:s}){w7();var l=void 0,u=k7(()=>{var v=e??t.appendChild(Ls());l7(v,{pending:()=>{}},f=>{$r({});var h=Bi;n&&(h.c=n),i&&(a.$$events=i),l=r(f,a)||{},Yr()},s);var c=new Set,d=f=>{for(var h=0;h{for(var f of c)for(const m of[t,document]){var h=fg.get(m),g=h.get(f);--g==0?(m.removeEventListener(f,yC),h.delete(f),h.size===0&&fg.delete(m)):h.set(f,g)}Xb.delete(d),v!==e&&v.parentNode?.removeChild(v)}});return F7.set(l,u),l}let F7=new WeakMap;var ho,qo,vn,Zu,Lh,Ih,Wm;class G7{constructor(t,e=!0){Ur(this,"anchor");Qr(this,ho,new Map);Qr(this,qo,new Map);Qr(this,vn,new Map);Qr(this,Zu,new Set);Qr(this,Lh,!0);Qr(this,Ih,t=>{if(gt(this,ho).has(t)){var e=gt(this,ho).get(t),a=gt(this,qo).get(e);if(a)aS(a),gt(this,Zu).delete(e);else{var i=gt(this,vn).get(e);i&&(gt(this,qo).set(e,i.effect),gt(this,vn).delete(e),i.fragment.lastChild.remove(),this.anchor.before(i.fragment),a=i.effect)}for(const[n,o]of gt(this,ho)){if(gt(this,ho).delete(n),n===t)break;const s=gt(this,vn).get(o);s&&(en(s.effect),gt(this,vn).delete(o))}for(const[n,o]of gt(this,qo)){if(n===e||gt(this,Zu).has(n))continue;const s=()=>{if(Array.from(gt(this,ho).values()).includes(n)){var u=document.createDocumentFragment();iS(o,u),u.append(Ls()),gt(this,vn).set(n,{effect:o,fragment:u})}else en(o);gt(this,Zu).delete(n),gt(this,qo).delete(n)};gt(this,Lh)||!a?(gt(this,Zu).add(n),Qu(o,s,!1)):s()}}});Qr(this,Wm,t=>{gt(this,ho).delete(t);const e=Array.from(gt(this,ho).values());for(const[a,i]of gt(this,vn))e.includes(a)||(en(i.effect),gt(this,vn).delete(a))});this.anchor=t,Vr(this,Lh,e)}ensure(t,e){var a=oa,i=_E();if(e&&!gt(this,qo).has(t)&&!gt(this,vn).has(t))if(i){var n=document.createDocumentFragment(),o=Ls();n.append(o),gt(this,vn).set(t,{effect:Nn(()=>e(o)),fragment:n})}else gt(this,qo).set(t,Nn(()=>e(this.anchor)));if(gt(this,ho).set(a,t),i){for(const[s,l]of gt(this,qo))s===t?a.unskip_effect(l):a.skip_effect(l);for(const[s,l]of gt(this,vn))s===t?a.unskip_effect(l.effect):a.skip_effect(l.effect);a.oncommit(gt(this,Ih)),a.ondiscard(gt(this,Wm))}else gt(this,Ih).call(this,a)}}ho=new WeakMap,qo=new WeakMap,vn=new WeakMap,Zu=new WeakMap,Lh=new WeakMap,Ih=new WeakMap,Wm=new WeakMap;function It(r,t,e=!1){var a=new G7(r),i=e?Bc:0;function n(o,s){a.ensure(o,s)}eS(()=>{var o=!1;t((s,l=0)=>{o=!0,n(l,s)}),o||n(-1,null)},i)}function ut(r,t){return t}function H7(r,t,e){for(var a=[],i=t.length,n,o=t.length,s=0;s{if(n){if(n.pending.delete(c),n.done.add(c),n.pending.size===0){var d=r.outrogroups;jb(r,Um(n.done)),d.delete(n),d.size===0&&(r.outrogroups=null)}}else o-=1},!1)}if(o===0){var l=a.length===0&&e!==null;if(l){var u=e,v=u.parentNode;S7(v),v.append(u),r.items.clear()}jb(r,t,!l)}else n={pending:new Set(t),done:new Set},(r.outrogroups??(r.outrogroups=new Set)).add(n)}function jb(r,t,e=!0){var a;if(r.pending.size>0){a=new Set;for(const o of r.pending.values())for(const s of o)a.add(r.items.get(s).e)}for(var i=0;i{var w=e();return Y2(w)?w:w==null?[]:Um(w)}),d,f=new Map,h=!0;function g(w){b.effect.f&$n||(b.pending.delete(w),b.fallback=v,W7(b,d,o,t,a),v!==null&&(d.length===0?v.f&Zo?(v.f^=Zo,yf(v,null,o)):aS(v):Qu(v,()=>{v=null})))}function m(w){b.pending.delete(w)}var x=eS(()=>{d=p(c);for(var w=d.length,S=new Set,C=oa,T=_E(),k=0;kn(o)):(v=Nn(()=>n(mC??(mC=Ls()))),v.f|=Zo)),w>S.size&&CF(),!h)if(f.set(C,S),T){for(const[I,P]of s)S.has(I)||C.skip_effect(P.e);C.oncommit(g),C.ondiscard(m)}else g(C);p(c)}),b={effect:x,items:s,pending:f,outrogroups:null,fallback:v};h=!1}function Bd(r){for(;r!==null&&!(r.f&So);)r=r.next;return r}function W7(r,t,e,a,i){var n=(a&BF)!==0,o=t.length,s=r.items,l=Bd(r.effect.first),u,v=null,c,d=[],f=[],h,g,m,x;if(n)for(x=0;x0){var M=a&ZP&&o===0?e:null;if(n){for(x=0;x{if(c!==void 0)for(m of c)m.nodes?.a?.apply()})}function U7(r,t,e,a,i,n,o,s){var l=o&OF?o&VF?fv(e):_7(e,!1,!1):null,u=o&zF?fv(i):null;return{v:l,i:u,e:Nn(()=>(n(t,l??e,u??i,s),()=>{r.delete(a)}))}}function yf(r,t,e){if(r.nodes)for(var a=r.nodes.start,i=r.nodes.end,n=t&&!(t.f&Zo)?t.nodes.start:e;a!==null;){var o=Rh(a);if(n.before(a),a===i)return;a=o}}function Js(r,t,e){t===null?r.effect.first=e:t.next=e,e===null?r.effect.last=t:e.prev=t}function Wn(r,t,e=!1,a=!1,i=!1,n=!1){var o=r,s="";if(e)var l=r;U(()=>{var u=aa;if(s!==(s=t()??"")){if(e){u.nodes=null,l.innerHTML=s,s!==""&&hv(Hn(l),l.lastChild);return}if(u.nodes!==null&&(AE(u.nodes.start,u.nodes.end),u.nodes=null),s!==""){var v=a?$F:i?YF:void 0,c=xE(a?"svg":i?"math":"template",v);c.innerHTML=s;var d=a||i?c:c.content;if(hv(Hn(d),d.lastChild),a||i)for(;Hn(d);)o.before(Hn(d));else o.before(d)}}})}function VE(r){var t,e,a="";if(typeof r=="string"||typeof r=="number")a+=r;else if(typeof r=="object")if(Array.isArray(r)){var i=r.length;for(t=0;t=0;){var s=o+n;(o===0||_C.includes(a[o-1]))&&(s===a.length||_C.includes(a[s]))?a=(o===0?"":a.substring(0,o))+a.substring(s+1):o=s}}return a===""?null:a}function xC(r,t=!1){var e=t?" !important;":";",a="";for(var i of Object.keys(r)){var n=r[i];n!=null&&n!==""&&(a+=" "+i+": "+n+e)}return a}function u1(r){return r[0]!=="-"||r[1]!=="-"?r.toLowerCase():r}function Y7(r,t){if(t){var e="",a,i;if(Array.isArray(t)?(a=t[0],i=t[1]):a=t,r){r=String(r).replaceAll(/\s*\/\*.*?\*\/\s*/g,"").trim();var n=!1,o=0,s=!1,l=[];a&&l.push(...Object.keys(a).map(u1)),i&&l.push(...Object.keys(i).map(u1));var u=0,v=-1;const g=r.length;for(var c=0;c{FE(r,r.__value)});t.observe(r,{childList:!0,subtree:!0,attributes:!0,attributeFilter:["value"]}),bE(()=>{t.disconnect()})}function c1(r,t,e=t){var a=new WeakSet,i=!0;J2(r,"change",n=>{var o=n?"[selected]":":checked",s;if(r.multiple)s=[].map.call(r.querySelectorAll(o),Pf);else{var l=r.querySelector(o)??r.querySelector("option:not([disabled])");s=l&&Pf(l)}e(s),oa!==null&&a.add(oa)}),SE(()=>{var n=t();if(r===document.activeElement){var o=oa;if(a.has(o))return}if(FE(r,n,i),i&&n===void 0){var s=r.querySelector(":checked");s!==null&&(n=Pf(s),e(n))}r.__value=n,i=!1}),Z7(r)}function Pf(r){return"__value"in r?r.__value:r.value}const X7=Symbol("is custom element"),j7=Symbol("is html");function K7(r,t){var e=GE(r);e.checked!==(e.checked=t??void 0)&&(r.checked=t)}function xe(r,t,e,a){var i=GE(r);i[t]!==(i[t]=e)&&(t==="loading"&&(r[TF]=e),e==null?r.removeAttribute(t):typeof e!="string"&&Q7(r).includes(t)?r[t]=e:r.setAttribute(t,e))}function GE(r){return r.__attributes??(r.__attributes={[X7]:r.nodeName.includes("-"),[j7]:r.namespaceURI===XP})}var bC=new Map;function Q7(r){var t=r.getAttribute("is")||r.nodeName,e=bC.get(t);if(e)return e;bC.set(t,e=[]);for(var a,i=r,n=Element.prototype;n!==i;){a=mF(i);for(var o in a)a[o].set&&e.push(o);i=WP(i)}return e}function Vd(r,t,e=t){var a=new WeakSet;J2(r,"input",async i=>{var n=i?r.defaultValue:r.value;if(n=d1(r)?f1(n):n,e(n),oa!==null&&a.add(oa),await EE(),n!==(n=t())){var o=r.selectionStart,s=r.selectionEnd,l=r.value.length;if(r.value=n??"",s!==null){var u=r.value.length;o===s&&s===l&&u>l?(r.selectionStart=u,r.selectionEnd=u):(r.selectionStart=o,r.selectionEnd=Math.min(s,u))}}}),Hl(t)==null&&r.value&&(e(d1(r)?f1(r.value):r.value),oa!==null&&a.add(oa)),Zm(()=>{var i=t();if(r===document.activeElement){var n=oa;if(a.has(n))return}d1(r)&&i===f1(r.value)||r.type==="date"&&!i&&!r.value||i!==r.value&&(r.value=i??"")})}function J7(r,t,e=t){J2(r,"change",a=>{var i=a?r.defaultChecked:r.checked;e(i)}),Hl(t)==null&&e(r.checked),Zm(()=>{var a=t();r.checked=!!a})}function d1(r){var t=r.type;return t==="number"||t==="range"}function f1(r){return r===""?null:+r}function wC(r,t){return r===t||r?.[ju]===t}function tG(r={},t,e,a){var i=Bi.r,n=aa;return SE(()=>{var o,s;return Zm(()=>{o=s,s=[],Hl(()=>{r!==e(...s)&&(t(r,...s),o&&wC(e(...o),r)&&t(null,...o))})}),()=>{let l=n;for(;l!==i&&l.parent!==null&&l.parent.f&Vb;)l=l.parent;const u=()=>{s&&wC(e(...s),r)&&t(null,...s)},v=l.teardown;l.teardown=()=>{u(),v?.()}}}),r}function Ef(r,t,e,a){var i=(e&HF)!==0,n=(e&WF)!==0,o=a,s=!0,l=()=>(s&&(s=!1,o=n?Hl(a):a),o);let u;if(i){var v=ju in r||SF in r;u=_c(r,t)?.set??(v&&t in r?b=>r[t]=b:void 0)}var c,d=!1;i?[c,d]=r7(()=>r[t]):c=r[t],c===void 0&&a!==void 0&&(c=l(),u&&(IF(),u(c)));var f;if(f=()=>{var b=r[t];return b===void 0?l():(s=!0,b)},!(e&GF))return f;if(u){var h=r.$$legacy;return function(b,w){return arguments.length>0?((!w||h||d)&&u(w?f():b),b):f()}}var g=!1,m=(e&FF?$m:cE)(()=>(g=!1,f()));i&&p(m);var x=aa;return function(b,w){if(arguments.length>0){const S=w?p(m):i?Fr(b):b;return W(m,S),g=!0,o!==void 0&&(o=S),b}return Rl&&g||x.f&$n?m.v:p(m)}}function mn(r){Bi===null&&YP(),Ym(()=>{const t=Hl(r);if(typeof t=="function")return t})}function Xm(r){Bi===null&&YP(),mn(()=>()=>Hl(r))}const eG="5";var HP;typeof window<"u"&&((HP=window.__svelte??(window.__svelte={})).v??(HP.v=new Set)).add(eG);const Tv=Wi(null),nS=Wi(null),HE=Wi(null),oS=Wi(null),jm=Wi(null),tv=Wi(5),Wc=Wi("live-training"),xc=Wi(!1),Kb=Wi(!1),WE="kronos-theme";function rG(){if(typeof localStorage>"u")return"light";try{return localStorage.getItem(WE)==="dark"?"dark":"light"}catch{return"light"}}const mo=Wi(rG());typeof document<"u"&&mo.subscribe(r=>{document.documentElement.setAttribute("data-theme",r);try{localStorage.setItem(WE,r)}catch{}document.dispatchEvent(new CustomEvent("kronos:theme",{detail:{theme:r}}))});function aG(){mo.update(r=>r==="light"?"dark":"light")}const Km=Wi("-"),sS=Wi([]);function iG(r){sS.update(t=>{const e=[...t,r];return e.length>720?e.slice(e.length-720):e})}const UE=Wi([]);function nG(r){UE.update(t=>{const e=[...t,r];return e.length>720?e.slice(e.length-720):e})}const Qm=Wi([]);function qE(r){const t=new Map;for(const a of r)a.step!=null&&a.loss!=null&&Number.isFinite(a.step)&&Number.isFinite(a.loss)&&t.set(a.step,{step:a.step,loss:a.loss});const e=Array.from(t.values()).sort((a,i)=>a.step-i.step);return e.length>1e3?e.slice(e.length-1e3):e}function oG(r){Qm.set(qE(r))}function sG(r){Qm.update(t=>qE([...t,...r]))}const lS=t7(nS,r=>{const t=r?.latest_point??{},e=r?.latest_progress??{};return{loss:t.loss!=null?t.loss:e.last_loss??null,samplesPerSec:e.samples_per_second??null,learningRate:t.learning_rate??null,epoch:t.epoch??null,epochs:t.epochs??null,runName:r?.run_name??null}}),Ko={grid:'',activity:'',wand:'',pulse:'',package:'',history:'',cpu:'',settings:'',menu:'',sun:'',moon:'',bell:'',refresh:'',chevron_right:'',download:'',play:'',pause:'',file:'',chip:'',check:'',info:'',warn:'',arrow_up:'',arrow_dn:'',flame:'',database:'',rocket:''},Ft={int(r){return r==null||isNaN(r)?"-":new Intl.NumberFormat("ko-KR").format(Math.round(r))},num(r,t=2){return r==null||isNaN(r)?"-":new Intl.NumberFormat("ko-KR",{minimumFractionDigits:t,maximumFractionDigits:t}).format(r)},pct(r,t=1){return r==null||isNaN(r)?"-":new Intl.NumberFormat("ko-KR",{minimumFractionDigits:t,maximumFractionDigits:t}).format(r)+"%"},bytes(r){return r==null||isNaN(r)?"-":r>=1024?(r/1024).toFixed(2)+" GiB":r.toFixed(0)+" MiB"},duration(r){if(r==null||!(r>0))return"-";const t=Math.max(0,Math.floor(r)),e=Math.floor(t/3600),a=Math.floor(t%3600/60),i=t%60;return e>0?`${e}시간 ${a}분`:a>0?`${a}분 ${i}초`:`${i}초`},durationCompact(r){if(r==null||!(r>0))return"-";const t=Math.max(0,Math.floor(r)),e=Math.floor(t/3600),a=Math.floor(t%3600/60),i=t%60;return e>0?`${e}h ${a.toString().padStart(2,"0")}m`:a>0?`${a}m ${i.toString().padStart(2,"0")}s`:`${i}s`},kst(r){if(r==null)return"-";const t=r instanceof Date?r:new Date(typeof r=="number"&&r<1e12?r*1e3:r);return isNaN(t.getTime())?"-":t.toLocaleString("ko-KR",{hour12:!1,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",timeZone:"Asia/Seoul"})},kstShort(r){if(r==null)return"-";const t=r instanceof Date?r:new Date(typeof r=="number"&&r<1e12?r*1e3:r);return isNaN(t.getTime())?"-":t.toLocaleString("ko-KR",{hour12:!1,month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",timeZone:"Asia/Seoul"})},kstTime(r){if(r==null)return"-";const t=r instanceof Date?r:new Date(typeof r=="number"&&r<1e12?r*1e3:r);return isNaN(t.getTime())?"-":t.toLocaleTimeString("ko-KR",{hour12:!1,timeZone:"Asia/Seoul"})},relative(r){if(r==null)return"-";const t=r instanceof Date?r:new Date(typeof r=="number"&&r<1e12?r*1e3:r);if(isNaN(t.getTime()))return"-";const e=(Date.now()-t.getTime())/1e3;return e<60?`${Math.floor(e)}초 전`:e<3600?`${Math.floor(e/60)}분 전`:e<86400?`${Math.floor(e/3600)}시간 전`:`${Math.floor(e/86400)}일 전`},finishKst(r,t){if(r==null||!(r>0))return"-";const e=t?Date.parse(t):Date.now(),a=(isNaN(e)?Date.now():e)+r*1e3;return new Date(a).toLocaleString("ko-KR",{hour12:!1,year:"numeric",month:"2-digit",day:"2-digit",hour:"2-digit",minute:"2-digit",timeZone:"Asia/Seoul"})}};var lG=G('
Kronos 대시보드 official · operations
'),uG=G(''),vG=G(' '),cG=G(' ',1),dG=G(''),fG=G(''),hG=G('
'),pG=G(" "),gG=G(" "),yG=G(''),mG=G('');function _G(r,t){$r(t,!0);const e=[{label:"오버뷰",items:[{id:"live-training",label:"실시간 학습",icon:"activity",badge:"LIVE",status:"live"},{id:"forecast",label:"예측 워크벤치",icon:"wand",badge:null}]},{label:"분석",items:[{id:"stom",label:"예측 진단",icon:"pulse",badge:null},{id:"artifacts",label:"아티팩트 & 모델",icon:"package",badge:null},{id:"history",label:"기록 & 런",icon:"history",badge:null}]},{label:"시스템",items:[{id:"system-health",label:"시스템 상태",icon:"cpu",badge:null},{id:"settings",label:"설정",icon:"settings",badge:null}]},{label:"도움말",items:[{id:"docs",label:"문서 · Wiki",icon:"file",badge:null}]},{label:"트레이딩",items:[{id:"rl",label:"RL Trading",icon:"rocket",badge:"정규"},{id:"daily-ohlcv",label:"Daily OHLCV",icon:"database",badge:"일봉"},{id:"daily-rl-guide",label:"일봉 RL 설명서",icon:"file",badge:"Guide"}]}];let a=rt("live-training");Wc.subscribe(b=>W(a,b,!0));let i=rt(!1);xc.subscribe(b=>W(i,b,!0));let n=rt(!1);Kb.subscribe(b=>W(n,b,!0));let o=rt(null);Tv.subscribe(b=>W(o,b,!0));let s=rt(Fr({}));lS.subscribe(b=>W(s,b,!0));function l(b){Wc.set(b),Kb.set(!1)}var u=mG(),v=y(u),c=y(v),d=y(c);Wn(d,()=>Ko.flame,!0);var f=_(c,2);{var h=b=>{var w=lG();F(b,w)};It(f,b=>{p(i)||b(h)})}var g=_(v,2);nt(g,17,()=>e,ut,(b,w)=>{var S=fG(),C=y(S);{var T=D=>{var M=uG(),L=y(M);U(()=>A(L,p(w).label)),F(D,M)};It(C,D=>{p(i)||D(T)})}var k=_(C,2);nt(k,21,()=>p(w).items,ut,(D,M)=>{var L=dG(),I=y(L),P=y(I);Wn(P,()=>Ko[p(M).icon],!0);var E=_(I,2);{var N=O=>{var V=cG(),B=lr(V),z=y(B),H=_(B,2);{var Y=$=>{var Z=vG(),Q=y(Z);U(()=>A(Q,p(M).badge)),F($,Z)};It(H,$=>{p(M).badge&&$(Y)})}U(()=>A(z,p(M).label)),F(O,V)};It(E,O=>{p(i)||O(N)})}U(()=>{xe(L,"data-tab",p(M).id),xe(L,"data-active",p(a)===p(M).id?"true":"false"),xe(L,"data-status",p(M).status??""),xe(L,"aria-current",p(a)===p(M).id?"page":void 0),xe(L,"title",p(i)?p(M).label:void 0)}),cr("click",L,()=>l(p(M).id)),F(D,L)}),F(b,S)});var m=_(g,2);{var x=b=>{var w=yG(),S=y(w),C=y(S),T=y(C),k=_(y(T),2),D=y(k),M=_(C,2);{var L=V=>{var B=hG(),z=y(B);U(()=>{xe(B,"title",p(s).runName),A(z,p(s).runName)}),F(V,B)};It(M,V=>{p(s).runName&&V(L)})}var I=_(M,2),P=y(I);{var E=V=>{var B=pG(),z=y(B);U(()=>A(z,p(o).latest_stage.train_stage)),F(V,B)};It(P,V=>{p(o)?.latest_stage?.train_stage&&V(E)})}var N=_(P,2);{var O=V=>{var B=gG(),z=y(B);U(H=>A(z,H),[()=>Ft.pct(p(o).latest_stage.overall_percent,1)]),F(V,B)};It(N,V=>{p(o)?.latest_stage?.overall_percent!=null&&V(O)})}U(()=>{xe(T,"data-level",p(o)?.readiness?.level==="ready"||p(o)?.readiness?.level==="training"?"live":"waiting"),A(D,p(o)?.status??"확인 중")}),F(b,w)};It(m,b=>{p(i)||b(x)})}U(()=>{xe(u,"data-sidebar-collapsed",p(i)),xe(u,"data-mobile-open",p(n))}),F(r,u),Yr()}an(["click"]);var xG=G(' '),bG=G('
Kronos
갱신
');function wG(r,t){$r(t,!0);const e={"live-training":"실시간 학습",forecast:"예측 워크벤치",stom:"예측 진단",rl:"RL Trading","daily-ohlcv":"Daily OHLCV","daily-rl-guide":"일봉 RL 설명서",artifacts:"아티팩트 & 모델",history:"기록 & 런","system-health":"시스템 상태",settings:"설정",docs:"문서 · Wiki"};let a=rt("live-training");Wc.subscribe(V=>W(a,V,!0));let i=rt(null);Tv.subscribe(V=>W(i,V,!0));let n=rt("-");Km.subscribe(V=>W(n,V,!0));let o=rt("light");mo.subscribe(V=>W(o,V,!0));let s=rt(Fr(Ft.kstTime(Date.now()))),l;mn(()=>{l=window.setInterval(()=>W(s,Ft.kstTime(Date.now()),!0),1e3)}),Xm(()=>{l!=null&&clearInterval(l)});function u(){window.matchMedia("(max-width: 900px)").matches?Kb.update(V=>!V):xc.update(V=>!V)}var v=bG(),c=y(v),d=y(c);Wn(d,()=>Ko.menu,!0);var f=_(c,2),h=_(y(f),2);Wn(h,()=>Ko.chevron_right,!0);var g=_(h,2),m=y(g),x=_(f,2),b=y(x);{var w=V=>{var B=xG(),z=_(y(B),2),H=y(z);U(()=>{xe(B,"data-level",p(i).readiness.level==="ready"||p(i).readiness.level==="training"?"live":"waiting"),A(H,p(i).readiness.label??p(i).readiness.level)}),F(V,B)};It(b,V=>{p(i)?.readiness?.level&&V(w)})}var S=_(x,2),C=y(S),T=_(y(C),2),k=y(T),D=_(C,2),M=_(y(D)),L=y(M),I=_(D,2),P=y(I);let E;Wn(P,()=>Ko.sun,!0);var N=_(P,2);let O;Wn(N,()=>Ko.moon,!0),U(()=>{A(m,e[p(a)]??p(a)),A(k,p(s)),A(L,p(n)),E=ha(P,"",E,{display:p(o)==="dark"?"none":""}),O=ha(N,"",O,{display:p(o)==="dark"?"":"none"})}),cr("click",c,u),cr("click",I,function(...V){aG?.apply(this,V)}),F(r,v),Yr()}an(["click"]);var SG=G(`
실시간 학습 모니터 · /api/training/status live

Hero 영역은 실행 핵심만 요약합니다. 전체 진행률과 단계 구간은 아래 긴 단계 바에서 확인하고, + 손실 곡선·GPU·데이터 범위는 각 전용 카드에서 분리해 확인합니다.

현재 단계
Step
Epoch
Loss
실행 요약
전체 진행
단계 구간은 아래 가로 진행 바로 확인
현재 단계 진행
완료 예상
처리 속도
samples / second
Polling 5초 마지막 업데이트
`);function TG(r,t){$r(t,!0);let e=rt(null);Tv.subscribe(me=>W(e,me,!0));let a=rt(Fr({}));lS.subscribe(me=>W(a,me,!0));let i=rt("-");Km.subscribe(me=>W(i,me,!0));let n=q(()=>p(e)?.latest_stage??{}),o=q(()=>p(n).stage_percent??0),s=q(()=>p(n).overall_percent??p(e)?.overall_percent??0),l=q(()=>p(n).samples_per_second??p(a).samplesPerSec??null),u=q(()=>p(n).eta_seconds??null),v=q(()=>Ft.durationCompact(p(u))),c=q(()=>Ft.finishKst(p(u),p(n).updated_at)),d=q(()=>p(n).train_stage??"-"),f=q(()=>p(a).runName??p(e)?.run_name??"-"),h=q(()=>p(e)?.readiness?.level??"waiting"),g=q(()=>p(h)==="ready"?"Predictor 완료":p(h)==="training"?"학습 진행 중":"Predictor 대기");var m=SG(),x=y(m),b=_(y(x),2),w=y(b),S=y(w),C=_(w),T=_(C),k=y(T),D=_(b,4),M=y(D),L=_(y(M),2),I=y(L),P=_(M,2),E=_(y(P),2),N=y(E),O=_(P,2),V=_(y(O),2),B=y(V),z=_(O,2),H=_(y(z),2),Y=y(H),$=_(x,2),Z=y($),Q=y(Z),at=_(y(Q),2),et=_(y(at),2),_t=y(et),Ot=_(Q,2),xt=y(Ot),mt=_(y(xt),2),wt=y(mt),ft=_(xt,2),K=_(y(ft),2),Ct=y(K),Mt=_(K,2),zt=y(Mt),Yt=_(Ot,2),we=y(Yt),Kt=_(y(we),2),qe=y(Kt),fr=_(Kt,2),re=y(fr),ge=_(we,2),ye=_(y(ge),2),se=y(ye),St=_(Yt,2),Et=_(y(St),2),ue=_(y(Et)),Se=y(ue);U((me,Xe,br,kt,_e,ke,Tr)=>{A(S,p(f)),A(C,` ${p(d)??""} 학습 중 `),A(k,`전체 ${me??""}`),A(I,p(d)),A(N,`${Xe??""} / ${br??""}`),A(B,`${p(a).epoch??"-"??""} / ${p(a).epochs??"-"??""}`),A(Y,kt),xe(at,"data-level",p(h)==="ready"||p(h)==="training"?"ready":"waiting"),A(_t,p(g)),A(wt,_e),A(Ct,ke),A(zt,p(d)),A(qe,p(v)),A(re,p(c)),A(se,Tr),A(Se,p(i))},[()=>Ft.pct(p(s),1),()=>Ft.int(p(n).step),()=>Ft.int(p(n).total_steps),()=>p(a).loss!=null?p(a).loss.toFixed(4):"—",()=>Ft.pct(p(s),1),()=>Ft.pct(p(o),1),()=>Ft.num(p(l),1)]),F(r,m),Yr()}/*! ***************************************************************************** +Copyright (c) Microsoft Corporation. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. +***************************************************************************** */var Qb=function(r,t){return Qb=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,a){e.__proto__=a}||function(e,a){for(var i in a)Object.prototype.hasOwnProperty.call(a,i)&&(e[i]=a[i])},Qb(r,t)};function tt(r,t){if(typeof t!="function"&&t!==null)throw new TypeError("Class extends value "+String(t)+" is not a constructor or null");Qb(r,t);function e(){this.constructor=r}r.prototype=t===null?Object.create(t):(e.prototype=t.prototype,new e)}var AG=function(){function r(){this.firefox=!1,this.ie=!1,this.edge=!1,this.newEdge=!1,this.weChat=!1}return r}(),CG=function(){function r(){this.browser=new AG,this.node=!1,this.wxa=!1,this.worker=!1,this.svgSupported=!1,this.touchEventsSupported=!1,this.pointerEventsSupported=!1,this.domSupported=!1,this.transformSupported=!1,this.transform3dSupported=!1,this.hasGlobalWindow=typeof window<"u"}return r}(),dr=new CG;typeof wx=="object"&&typeof wx.getSystemInfoSync=="function"?(dr.wxa=!0,dr.touchEventsSupported=!0):typeof document>"u"&&typeof self<"u"?dr.worker=!0:!dr.hasGlobalWindow||"Deno"in window?(dr.node=!0,dr.svgSupported=!0):kG(navigator.userAgent,dr);function kG(r,t){var e=t.browser,a=r.match(/Firefox\/([\d.]+)/),i=r.match(/MSIE\s([\d.]+)/)||r.match(/Trident\/.+?rv:(([\d.]+))/),n=r.match(/Edge?\/([\d.]+)/),o=/micromessenger/i.test(r);a&&(e.firefox=!0,e.version=a[1]),i&&(e.ie=!0,e.version=i[1]),n&&(e.edge=!0,e.version=n[1],e.newEdge=+n[1].split(".")[0]>18),o&&(e.weChat=!0),t.svgSupported=typeof SVGRect<"u",t.touchEventsSupported="ontouchstart"in window&&!e.ie&&!e.edge,t.pointerEventsSupported="onpointerdown"in window&&(e.edge||e.ie&&+e.version>=11),t.domSupported=typeof document<"u";var s=document.documentElement.style;t.transform3dSupported=(e.ie&&"transition"in s||e.edge||"WebKitCSSMatrix"in window&&"m11"in new WebKitCSSMatrix||"MozPerspective"in s)&&!("OTransition"in s),t.transformSupported=t.transform3dSupported||e.ie&&+e.version>=9}var uS=12,$E="sans-serif",Pl=uS+"px "+$E,DG=20,MG=100,LG="007LLmW'55;N0500LLLLLLLLLL00NNNLzWW\\\\WQb\\0FWLg\\bWb\\WQ\\WrWWQ000CL5LLFLL0LL**F*gLLLL5F0LF\\FFF5.5N";function IG(r){var t={};if(typeof JSON>"u")return t;for(var e=0;e=0)s=o*e.length;else for(var l=0;l>1)%2;s.cssText=["position: absolute","visibility: hidden","padding: 0","margin: 0","border-width: 0","user-select: none","width:0","height:0",a[l]+":0",i[u]+":0",a[1-l]+":auto",i[1-u]+":auto",""].join("!important;"),r.appendChild(o),e.push(o)}return e}function QG(r,t,e){for(var a=e?"invTrans":"trans",i=t[a],n=t.srcCoords,o=[],s=[],l=!0,u=0;u<4;u++){var v=r[u].getBoundingClientRect(),c=2*u,d=v.left,f=v.top;o.push(d,f),l=l&&n&&d===n[c]&&f===n[c+1],s.push(r[u].offsetLeft,r[u].offsetTop)}return l&&i?i:(t.srcCoords=o,t[a]=e?CC(s,o):CC(o,s))}function JE(r){return r.nodeName.toUpperCase()==="CANVAS"}var JG=/([&<>"'])/g,t8={"&":"&","<":"<",">":">",'"':""","'":"'"};function Ni(r){return r==null?"":(r+"").replace(JG,function(t,e){return t8[e]})}var e8=/^(?:mouse|pointer|contextmenu|drag|drop)|click/,p1=[],r8=dr.browser.firefox&&+dr.browser.version.split(".")[0]<39;function ow(r,t,e,a){return e=e||{},a?DC(r,t,e):r8&&t.layerX!=null&&t.layerX!==t.offsetX?(e.zrX=t.layerX,e.zrY=t.layerY):t.offsetX!=null?(e.zrX=t.offsetX,e.zrY=t.offsetY):DC(r,t,e),e}function DC(r,t,e){if(dr.domSupported&&r.getBoundingClientRect){var a=t.clientX,i=t.clientY;if(JE(r)){var n=r.getBoundingClientRect();e.zrX=a-n.left,e.zrY=i-n.top;return}else if(nw(p1,r,a,i)){e.zrX=p1[0],e.zrY=p1[1];return}}e.zrX=e.zrY=0}function gS(r){return r||window.event}function Mn(r,t,e){if(t=gS(t),t.zrX!=null)return t;var a=t.type,i=a&&a.indexOf("touch")>=0;if(i){var o=a!=="touchend"?t.targetTouches[0]:t.changedTouches[0];o&&ow(r,o,t,e)}else{ow(r,t,t,e);var n=a8(t);t.zrDelta=n?n/120:-(t.detail||0)/3}var s=t.button;return t.which==null&&s!==void 0&&e8.test(t.type)&&(t.which=s&1?1:s&2?3:s&4?2:0),t}function a8(r){var t=r.wheelDelta;if(t)return t;var e=r.deltaX,a=r.deltaY;if(e==null||a==null)return t;var i=Math.abs(a!==0?a:e),n=a>0?-1:a<0?1:e>0?-1:1;return 3*i*n}function sw(r,t,e,a){r.addEventListener(t,e,a)}function i8(r,t,e,a){r.removeEventListener(t,e,a)}var Os=function(r){r.preventDefault(),r.stopPropagation(),r.cancelBubble=!0};function MC(r){return r.which===2||r.which===3}var n8=function(){function r(){this._track=[]}return r.prototype.recognize=function(t,e,a){return this._doTrack(t,e,a),this._recognize(t)},r.prototype.clear=function(){return this._track.length=0,this},r.prototype._doTrack=function(t,e,a){var i=t.touches;if(i){for(var n={points:[],touches:[],target:e,event:t},o=0,s=i.length;o1&&a&&a.length>1){var n=LC(a)/LC(i);!isFinite(n)&&(n=1),t.pinchScale=n;var o=o8(a);return t.pinchX=o[0],t.pinchY=o[1],{type:"pinch",target:r[0].target,event:t}}}}};function pn(){return[1,0,0,1,0,0]}function r_(r){return r[0]=1,r[1]=0,r[2]=0,r[3]=1,r[4]=0,r[5]=0,r}function yS(r,t){return r[0]=t[0],r[1]=t[1],r[2]=t[2],r[3]=t[3],r[4]=t[4],r[5]=t[5],r}function Rs(r,t,e){var a=t[0]*e[0]+t[2]*e[1],i=t[1]*e[0]+t[3]*e[1],n=t[0]*e[2]+t[2]*e[3],o=t[1]*e[2]+t[3]*e[3],s=t[0]*e[4]+t[2]*e[5]+t[4],l=t[1]*e[4]+t[3]*e[5]+t[5];return r[0]=a,r[1]=i,r[2]=n,r[3]=o,r[4]=s,r[5]=l,r}function ss(r,t,e){return r[0]=t[0],r[1]=t[1],r[2]=t[2],r[3]=t[3],r[4]=t[4]+e[0],r[5]=t[5]+e[1],r}function Cv(r,t,e,a){a===void 0&&(a=[0,0]);var i=t[0],n=t[2],o=t[4],s=t[1],l=t[3],u=t[5],v=Math.sin(e),c=Math.cos(e);return r[0]=i*c+s*v,r[1]=-i*v+s*c,r[2]=n*c+l*v,r[3]=-n*v+c*l,r[4]=c*(o-a[0])+v*(u-a[1])+a[0],r[5]=c*(u-a[1])-v*(o-a[0])+a[1],r}function mS(r,t,e){var a=e[0],i=e[1];return r[0]=t[0]*a,r[1]=t[1]*i,r[2]=t[2]*a,r[3]=t[3]*i,r[4]=t[4]*a,r[5]=t[5]*i,r}function id(r,t){var e=t[0],a=t[2],i=t[4],n=t[1],o=t[3],s=t[5],l=e*o-n*a;return l?(l=1/l,r[0]=o*l,r[1]=-n*l,r[2]=-a*l,r[3]=e*l,r[4]=(a*s-o*i)*l,r[5]=(n*i-e*s)*l,r):null}function s8(r){var t=pn();return yS(t,r),t}var Je=function(){function r(t,e){this.x=t||0,this.y=e||0}return r.prototype.copy=function(t){return this.x=t.x,this.y=t.y,this},r.prototype.clone=function(){return new r(this.x,this.y)},r.prototype.set=function(t,e){return this.x=t,this.y=e,this},r.prototype.equal=function(t){return t.x===this.x&&t.y===this.y},r.prototype.add=function(t){return this.x+=t.x,this.y+=t.y,this},r.prototype.scale=function(t){this.x*=t,this.y*=t},r.prototype.scaleAndAdd=function(t,e){this.x+=t.x*e,this.y+=t.y*e},r.prototype.sub=function(t){return this.x-=t.x,this.y-=t.y,this},r.prototype.dot=function(t){return this.x*t.x+this.y*t.y},r.prototype.len=function(){return Math.sqrt(this.x*this.x+this.y*this.y)},r.prototype.lenSquare=function(){return this.x*this.x+this.y*this.y},r.prototype.normalize=function(){var t=this.len();return this.x/=t,this.y/=t,this},r.prototype.distance=function(t){var e=this.x-t.x,a=this.y-t.y;return Math.sqrt(e*e+a*a)},r.prototype.distanceSquare=function(t){var e=this.x-t.x,a=this.y-t.y;return e*e+a*a},r.prototype.negate=function(){return this.x=-this.x,this.y=-this.y,this},r.prototype.transform=function(t){if(t){var e=this.x,a=this.y;return this.x=t[0]*e+t[2]*a+t[4],this.y=t[1]*e+t[3]*a+t[5],this}},r.prototype.toArray=function(t){return t[0]=this.x,t[1]=this.y,t},r.prototype.fromArray=function(t){this.x=t[0],this.y=t[1]},r.set=function(t,e,a){t.x=e,t.y=a},r.copy=function(t,e){t.x=e.x,t.y=e.y},r.len=function(t){return Math.sqrt(t.x*t.x+t.y*t.y)},r.lenSquare=function(t){return t.x*t.x+t.y*t.y},r.dot=function(t,e){return t.x*e.x+t.y*e.y},r.add=function(t,e,a){t.x=e.x+a.x,t.y=e.y+a.y},r.sub=function(t,e,a){t.x=e.x-a.x,t.y=e.y-a.y},r.scale=function(t,e,a){t.x=e.x*a,t.y=e.y*a},r.scaleAndAdd=function(t,e,a,i){t.x=e.x+a.x*i,t.y=e.y+a.y*i},r.lerp=function(t,e,a,i){var n=1-i;t.x=n*e.x+i*a.x,t.y=n*e.y+i*a.y},r}(),pg=Math.min,gg=Math.max,tu=new Je,eu=new Je,ru=new Je,au=new Je,Fd=new Je,Gd=new Je,er=function(){function r(t,e,a,i){a<0&&(t=t+a,a=-a),i<0&&(e=e+i,i=-i),this.x=t,this.y=e,this.width=a,this.height=i}return r.prototype.union=function(t){var e=pg(t.x,this.x),a=pg(t.y,this.y);isFinite(this.x)&&isFinite(this.width)?this.width=gg(t.x+t.width,this.x+this.width)-e:this.width=t.width,isFinite(this.y)&&isFinite(this.height)?this.height=gg(t.y+t.height,this.y+this.height)-a:this.height=t.height,this.x=e,this.y=a},r.prototype.applyTransform=function(t){r.applyTransform(this,this,t)},r.prototype.calculateTransform=function(t){var e=this,a=t.width/e.width,i=t.height/e.height,n=pn();return ss(n,n,[-e.x,-e.y]),mS(n,n,[a,i]),ss(n,n,[t.x,t.y]),n},r.prototype.intersect=function(t,e){if(!t)return!1;t instanceof r||(t=r.create(t));var a=this,i=a.x,n=a.x+a.width,o=a.y,s=a.y+a.height,l=t.x,u=t.x+t.width,v=t.y,c=t.y+t.height,d=!(nh&&(h=w,gh&&(h=S,x=a.x&&t<=a.x+a.width&&e>=a.y&&e<=a.y+a.height},r.prototype.clone=function(){return new r(this.x,this.y,this.width,this.height)},r.prototype.copy=function(t){r.copy(this,t)},r.prototype.plain=function(){return{x:this.x,y:this.y,width:this.width,height:this.height}},r.prototype.isFinite=function(){return isFinite(this.x)&&isFinite(this.y)&&isFinite(this.width)&&isFinite(this.height)},r.prototype.isZero=function(){return this.width===0||this.height===0},r.create=function(t){return new r(t.x,t.y,t.width,t.height)},r.copy=function(t,e){t.x=e.x,t.y=e.y,t.width=e.width,t.height=e.height},r.applyTransform=function(t,e,a){if(!a){t!==e&&r.copy(t,e);return}if(a[1]<1e-5&&a[1]>-1e-5&&a[2]<1e-5&&a[2]>-1e-5){var i=a[0],n=a[3],o=a[4],s=a[5];t.x=e.x*i+o,t.y=e.y*n+s,t.width=e.width*i,t.height=e.height*n,t.width<0&&(t.x+=t.width,t.width=-t.width),t.height<0&&(t.y+=t.height,t.height=-t.height);return}tu.x=ru.x=e.x,tu.y=au.y=e.y,eu.x=au.x=e.x+e.width,eu.y=ru.y=e.y+e.height,tu.transform(a),au.transform(a),eu.transform(a),ru.transform(a),t.x=pg(tu.x,eu.x,ru.x,au.x),t.y=pg(tu.y,eu.y,ru.y,au.y);var l=gg(tu.x,eu.x,ru.x,au.x),u=gg(tu.y,eu.y,ru.y,au.y);t.width=l-t.x,t.height=u-t.y},r}(),t4="silent";function l8(r,t,e){return{type:r,event:e,target:t.target,topTarget:t.topTarget,cancelBubble:!1,offsetX:e.zrX,offsetY:e.zrY,gestureEvent:e.gestureEvent,pinchX:e.pinchX,pinchY:e.pinchY,pinchScale:e.pinchScale,wheelDelta:e.zrDelta,zrByTouch:e.zrByTouch,which:e.which,stop:u8}}function u8(){Os(this.event)}var v8=function(r){xa(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.handler=null,e}return t.prototype.dispose=function(){},t.prototype.setCursor=function(){},t}(Kn),Hd=function(){function r(t,e){this.x=t,this.y=e}return r}(),c8=["click","dblclick","mousewheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],y1=new er(0,0,0,0),e4=function(r){xa(t,r);function t(e,a,i,n,o){var s=r.call(this)||this;return s._hovered=new Hd(0,0),s.storage=e,s.painter=a,s.painterRoot=n,s._pointerSize=o,i=i||new v8,s.proxy=null,s.setHandlerProxy(i),s._draggingMgr=new ZG(s),s}return t.prototype.setHandlerProxy=function(e){this.proxy&&this.proxy.dispose(),e&&(R(c8,function(a){e.on&&e.on(a,this[a],this)},this),e.handler=this),this.proxy=e},t.prototype.mousemove=function(e){var a=e.zrX,i=e.zrY,n=r4(this,a,i),o=this._hovered,s=o.target;s&&!s.__zr&&(o=this.findHover(o.x,o.y),s=o.target);var l=this._hovered=n?new Hd(a,i):this.findHover(a,i),u=l.target,v=this.proxy;v.setCursor&&v.setCursor(u?u.cursor:"default"),s&&u!==s&&this.dispatchToElement(o,"mouseout",e),this.dispatchToElement(l,"mousemove",e),u&&u!==s&&this.dispatchToElement(l,"mouseover",e)},t.prototype.mouseout=function(e){var a=e.zrEventControl;a!=="only_globalout"&&this.dispatchToElement(this._hovered,"mouseout",e),a!=="no_globalout"&&this.trigger("globalout",{type:"globalout",event:e})},t.prototype.resize=function(){this._hovered=new Hd(0,0)},t.prototype.dispatch=function(e,a){var i=this[e];i&&i.call(this,a)},t.prototype.dispose=function(){this.proxy.dispose(),this.storage=null,this.proxy=null,this.painter=null},t.prototype.setCursorStyle=function(e){var a=this.proxy;a.setCursor&&a.setCursor(e)},t.prototype.dispatchToElement=function(e,a,i){e=e||{};var n=e.target;if(!(n&&n.silent)){for(var o="on"+a,s=l8(a,e,i);n&&(n[o]&&(s.cancelBubble=!!n[o].call(n,s)),n.trigger(a,s),n=n.__hostTarget?n.__hostTarget:n.parent,!s.cancelBubble););s.cancelBubble||(this.trigger(a,s),this.painter&&this.painter.eachOtherLayer&&this.painter.eachOtherLayer(function(l){typeof l[o]=="function"&&l[o].call(l,s),l.trigger&&l.trigger(a,s)}))}},t.prototype.findHover=function(e,a,i){var n=this.storage.getDisplayList(),o=new Hd(e,a);if(IC(n,o,e,a,i),this._pointerSize&&!o.target){for(var s=[],l=this._pointerSize,u=l/2,v=new er(e-u,a-u,l,l),c=n.length-1;c>=0;c--){var d=n[c];d!==i&&!d.ignore&&!d.ignoreCoarsePointer&&(!d.parent||!d.parent.ignoreCoarsePointer)&&(y1.copy(d.getBoundingRect()),d.transform&&y1.applyTransform(d.transform),y1.intersect(v)&&s.push(d))}if(s.length)for(var f=4,h=Math.PI/12,g=Math.PI*2,m=0;m4)return;this._downPoint=null}this.dispatchToElement(n,r,t)}});function d8(r,t,e){if(r[r.rectHover?"rectContain":"contain"](t,e)){for(var a=r,i=void 0,n=!1;a;){if(a.ignoreClip&&(n=!0),!n){var o=a.getClipPath();if(o&&!o.contain(t,e))return!1}a.silent&&(i=!0);var s=a.__hostTarget;a=s||a.parent}return i?t4:!0}return!1}function IC(r,t,e,a,i){for(var n=r.length-1;n>=0;n--){var o=r[n],s=void 0;if(o!==i&&!o.ignore&&(s=d8(o,e,a))&&(!t.topTarget&&(t.topTarget=o),s!==t4)){t.target=o;break}}}function r4(r,t,e){var a=r.painter;return t<0||t>a.getWidth()||e<0||e>a.getHeight()}var a4=32,Wd=7;function f8(r){for(var t=0;r>=a4;)t|=r&1,r>>=1;return r+t}function RC(r,t,e,a){var i=t+1;if(i===e)return 1;if(a(r[i++],r[t])<0){for(;i=0;)i++;return i-t}function h8(r,t,e){for(e--;t>>1,i(n,r[l])<0?s=l:o=l+1;var u=a-o;switch(u){case 3:r[o+3]=r[o+2];case 2:r[o+2]=r[o+1];case 1:r[o+1]=r[o];break;default:for(;u>0;)r[o+u]=r[o+u-1],u--}r[o]=n}}function m1(r,t,e,a,i,n){var o=0,s=0,l=1;if(n(r,t[e+i])>0){for(s=a-i;l0;)o=l,l=(l<<1)+1,l<=0&&(l=s);l>s&&(l=s),o+=i,l+=i}else{for(s=i+1;ls&&(l=s);var u=o;o=i-l,l=i-u}for(o++;o>>1);n(r,t[e+v])>0?o=v+1:l=v}return l}function _1(r,t,e,a,i,n){var o=0,s=0,l=1;if(n(r,t[e+i])<0){for(s=i+1;ls&&(l=s);var u=o;o=i-l,l=i-u}else{for(s=a-i;l=0;)o=l,l=(l<<1)+1,l<=0&&(l=s);l>s&&(l=s),o+=i,l+=i}for(o++;o>>1);n(r,t[e+v])<0?l=v:o=v+1}return l}function p8(r,t){var e=Wd,a,i,n=0,o=[];a=[],i=[];function s(f,h){a[n]=f,i[n]=h,n+=1}function l(){for(;n>1;){var f=n-2;if(f>=1&&i[f-1]<=i[f]+i[f+1]||f>=2&&i[f-2]<=i[f]+i[f-1])i[f-1]i[f+1])break;v(f)}}function u(){for(;n>1;){var f=n-2;f>0&&i[f-1]=Wd||k>=Wd);if(D)break;C<0&&(C=0),C+=2}if(e=C,e<1&&(e=1),h===1){for(x=0;x=0;x--)r[T+x]=r[C+x];r[S]=o[w];return}for(var k=e;;){var D=0,M=0,L=!1;do if(t(o[w],r[b])<0){if(r[S--]=r[b--],D++,M=0,--h===0){L=!0;break}}else if(r[S--]=o[w--],M++,D=0,--m===1){L=!0;break}while((D|M)=0;x--)r[T+x]=r[C+x];if(h===0){L=!0;break}}if(r[S--]=o[w--],--m===1){L=!0;break}if(M=m-m1(r[b],o,0,m,m-1,t),M!==0){for(S-=M,w-=M,m-=M,T=S+1,C=w+1,x=0;x=Wd||M>=Wd);if(L)break;k<0&&(k=0),k+=2}if(e=k,e<1&&(e=1),m===1){for(S-=h,b-=h,T=S+1,C=b+1,x=h-1;x>=0;x--)r[T+x]=r[C+x];r[S]=o[w]}else{if(m===0)throw new Error;for(C=S-(m-1),x=0;xs&&(l=s),PC(r,e,e+l,e+n,t),n=l}o.pushRun(e,n),o.mergeRuns(),i-=n,e+=n}while(i!==0);o.forceMergeRuns()}}var dn=1,mf=2,uc=4,EC=!1;function x1(){EC||(EC=!0,console.warn("z / z2 / zlevel of displayable is invalid, which may cause unexpected errors"))}function NC(r,t){return r.zlevel===t.zlevel?r.z===t.z?r.z2-t.z2:r.z-t.z:r.zlevel-t.zlevel}var g8=function(){function r(){this._roots=[],this._displayList=[],this._displayListLen=0,this.displayableSortFunc=NC}return r.prototype.traverse=function(t,e){for(var a=0;a0&&(v.__clipPaths=[]),isNaN(v.z)&&(x1(),v.z=0),isNaN(v.z2)&&(x1(),v.z2=0),isNaN(v.zlevel)&&(x1(),v.zlevel=0),this._displayList[this._displayListLen++]=v}var c=t.getDecalElement&&t.getDecalElement();c&&this._updateAndAddDisplayable(c,e,a);var d=t.getTextGuideLine();d&&this._updateAndAddDisplayable(d,e,a);var f=t.getTextContent();f&&this._updateAndAddDisplayable(f,e,a)}},r.prototype.addRoot=function(t){t.__zr&&t.__zr.storage===this||this._roots.push(t)},r.prototype.delRoot=function(t){if(t instanceof Array){for(var e=0,a=t.length;e=0&&this._roots.splice(i,1)},r.prototype.delAllRoots=function(){this._roots=[],this._displayList=[],this._displayListLen=0},r.prototype.getRoots=function(){return this._roots},r.prototype.dispose=function(){this._displayList=null,this._roots=null},r}(),qy;qy=dr.hasGlobalWindow&&(window.requestAnimationFrame&&window.requestAnimationFrame.bind(window)||window.msRequestAnimationFrame&&window.msRequestAnimationFrame.bind(window)||window.mozRequestAnimationFrame||window.webkitRequestAnimationFrame)||function(r){return setTimeout(r,16)};var Of={linear:function(r){return r},quadraticIn:function(r){return r*r},quadraticOut:function(r){return r*(2-r)},quadraticInOut:function(r){return(r*=2)<1?.5*r*r:-.5*(--r*(r-2)-1)},cubicIn:function(r){return r*r*r},cubicOut:function(r){return--r*r*r+1},cubicInOut:function(r){return(r*=2)<1?.5*r*r*r:.5*((r-=2)*r*r+2)},quarticIn:function(r){return r*r*r*r},quarticOut:function(r){return 1- --r*r*r*r},quarticInOut:function(r){return(r*=2)<1?.5*r*r*r*r:-.5*((r-=2)*r*r*r-2)},quinticIn:function(r){return r*r*r*r*r},quinticOut:function(r){return--r*r*r*r*r+1},quinticInOut:function(r){return(r*=2)<1?.5*r*r*r*r*r:.5*((r-=2)*r*r*r*r+2)},sinusoidalIn:function(r){return 1-Math.cos(r*Math.PI/2)},sinusoidalOut:function(r){return Math.sin(r*Math.PI/2)},sinusoidalInOut:function(r){return .5*(1-Math.cos(Math.PI*r))},exponentialIn:function(r){return r===0?0:Math.pow(1024,r-1)},exponentialOut:function(r){return r===1?1:1-Math.pow(2,-10*r)},exponentialInOut:function(r){return r===0?0:r===1?1:(r*=2)<1?.5*Math.pow(1024,r-1):.5*(-Math.pow(2,-10*(r-1))+2)},circularIn:function(r){return 1-Math.sqrt(1-r*r)},circularOut:function(r){return Math.sqrt(1- --r*r)},circularInOut:function(r){return(r*=2)<1?-.5*(Math.sqrt(1-r*r)-1):.5*(Math.sqrt(1-(r-=2)*r)+1)},elasticIn:function(r){var t,e=.1,a=.4;return r===0?0:r===1?1:(!e||e<1?(e=1,t=a/4):t=a*Math.asin(1/e)/(2*Math.PI),-(e*Math.pow(2,10*(r-=1))*Math.sin((r-t)*(2*Math.PI)/a)))},elasticOut:function(r){var t,e=.1,a=.4;return r===0?0:r===1?1:(!e||e<1?(e=1,t=a/4):t=a*Math.asin(1/e)/(2*Math.PI),e*Math.pow(2,-10*r)*Math.sin((r-t)*(2*Math.PI)/a)+1)},elasticInOut:function(r){var t,e=.1,a=.4;return r===0?0:r===1?1:(!e||e<1?(e=1,t=a/4):t=a*Math.asin(1/e)/(2*Math.PI),(r*=2)<1?-.5*(e*Math.pow(2,10*(r-=1))*Math.sin((r-t)*(2*Math.PI)/a)):e*Math.pow(2,-10*(r-=1))*Math.sin((r-t)*(2*Math.PI)/a)*.5+1)},backIn:function(r){var t=1.70158;return r*r*((t+1)*r-t)},backOut:function(r){var t=1.70158;return--r*r*((t+1)*r+t)+1},backInOut:function(r){var t=2.5949095;return(r*=2)<1?.5*(r*r*((t+1)*r-t)):.5*((r-=2)*r*((t+1)*r+t)+2)},bounceIn:function(r){return 1-Of.bounceOut(1-r)},bounceOut:function(r){return r<1/2.75?7.5625*r*r:r<2/2.75?7.5625*(r-=1.5/2.75)*r+.75:r<2.5/2.75?7.5625*(r-=2.25/2.75)*r+.9375:7.5625*(r-=2.625/2.75)*r+.984375},bounceInOut:function(r){return r<.5?Of.bounceIn(r*2)*.5:Of.bounceOut(r*2-1)*.5+.5}},yg=Math.pow,Dl=Math.sqrt,$y=1e-8,i4=1e-4,OC=Dl(3),mg=1/3,$o=Av(),On=Av(),bc=Av();function xl(r){return r>-$y&&r<$y}function n4(r){return r>$y||r<-$y}function Ka(r,t,e,a,i){var n=1-i;return n*n*(n*r+3*i*t)+i*i*(i*a+3*n*e)}function zC(r,t,e,a,i){var n=1-i;return 3*(((t-r)*n+2*(e-t)*i)*n+(a-e)*i*i)}function Yy(r,t,e,a,i,n){var o=a+3*(t-e)-r,s=3*(e-t*2+r),l=3*(t-r),u=r-i,v=s*s-3*o*l,c=s*l-9*o*u,d=l*l-3*s*u,f=0;if(xl(v)&&xl(c))if(xl(s))n[0]=0;else{var h=-l/s;h>=0&&h<=1&&(n[f++]=h)}else{var g=c*c-4*v*d;if(xl(g)){var m=c/v,h=-s/o+m,x=-m/2;h>=0&&h<=1&&(n[f++]=h),x>=0&&x<=1&&(n[f++]=x)}else if(g>0){var b=Dl(g),w=v*s+1.5*o*(-c+b),S=v*s+1.5*o*(-c-b);w<0?w=-yg(-w,mg):w=yg(w,mg),S<0?S=-yg(-S,mg):S=yg(S,mg);var h=(-s-(w+S))/(3*o);h>=0&&h<=1&&(n[f++]=h)}else{var C=(2*v*s-3*o*c)/(2*Dl(v*v*v)),T=Math.acos(C)/3,k=Dl(v),D=Math.cos(T),h=(-s-2*k*D)/(3*o),x=(-s+k*(D+OC*Math.sin(T)))/(3*o),M=(-s+k*(D-OC*Math.sin(T)))/(3*o);h>=0&&h<=1&&(n[f++]=h),x>=0&&x<=1&&(n[f++]=x),M>=0&&M<=1&&(n[f++]=M)}}return f}function o4(r,t,e,a,i){var n=6*e-12*t+6*r,o=9*t+3*a-3*r-9*e,s=3*t-3*r,l=0;if(xl(o)){if(n4(n)){var u=-s/n;u>=0&&u<=1&&(i[l++]=u)}}else{var v=n*n-4*o*s;if(xl(v))i[0]=-n/(2*o);else if(v>0){var c=Dl(v),u=(-n+c)/(2*o),d=(-n-c)/(2*o);u>=0&&u<=1&&(i[l++]=u),d>=0&&d<=1&&(i[l++]=d)}}return l}function Nl(r,t,e,a,i,n){var o=(t-r)*i+r,s=(e-t)*i+t,l=(a-e)*i+e,u=(s-o)*i+o,v=(l-s)*i+s,c=(v-u)*i+u;n[0]=r,n[1]=o,n[2]=u,n[3]=c,n[4]=c,n[5]=v,n[6]=l,n[7]=a}function s4(r,t,e,a,i,n,o,s,l,u,v){var c,d=.005,f=1/0,h,g,m,x;$o[0]=l,$o[1]=u;for(var b=0;b<1;b+=.05)On[0]=Ka(r,e,i,o,b),On[1]=Ka(t,a,n,s,b),m=ev($o,On),m=0&&m=0&&u<=1&&(i[l++]=u)}}else{var v=o*o-4*n*s;if(xl(v)){var u=-o/(2*n);u>=0&&u<=1&&(i[l++]=u)}else if(v>0){var c=Dl(v),u=(-o+c)/(2*n),d=(-o-c)/(2*n);u>=0&&u<=1&&(i[l++]=u),d>=0&&d<=1&&(i[l++]=d)}}return l}function l4(r,t,e){var a=r+e-2*t;return a===0?.5:(r-t)/a}function rh(r,t,e,a,i){var n=(t-r)*a+r,o=(e-t)*a+t,s=(o-n)*a+n;i[0]=r,i[1]=n,i[2]=s,i[3]=s,i[4]=o,i[5]=e}function u4(r,t,e,a,i,n,o,s,l){var u,v=.005,c=1/0;$o[0]=o,$o[1]=s;for(var d=0;d<1;d+=.05){On[0]=oi(r,e,i,d),On[1]=oi(t,a,n,d);var f=ev($o,On);f=0&&f=1?1:Yy(0,a,n,1,l,s)&&Ka(0,i,o,1,s[0])}}}var b8=function(){function r(t){this._inited=!1,this._startTime=0,this._pausedTime=0,this._paused=!1,this._life=t.life||1e3,this._delay=t.delay||0,this.loop=t.loop||!1,this.onframe=t.onframe||Fa,this.ondestroy=t.ondestroy||Fa,this.onrestart=t.onrestart||Fa,t.easing&&this.setEasing(t.easing)}return r.prototype.step=function(t,e){if(this._inited||(this._startTime=t+this._delay,this._inited=!0),this._paused){this._pausedTime+=e;return}var a=this._life,i=t-this._startTime-this._pausedTime,n=i/a;n<0&&(n=0),n=Math.min(n,1);var o=this.easingFunc,s=o?o(n):n;if(this.onframe(s),n===1)if(this.loop){var l=i%a;this._startTime=t-l,this._pausedTime=0,this.onrestart()}else return!0;return!1},r.prototype.pause=function(){this._paused=!0},r.prototype.resume=function(){this._paused=!1},r.prototype.setEasing=function(t){this.easing=t,this.easingFunc=le(t)?t:Of[t]||_S(t)},r}(),v4=function(){function r(t){this.value=t}return r}(),w8=function(){function r(){this._len=0}return r.prototype.insert=function(t){var e=new v4(t);return this.insertEntry(e),e},r.prototype.insertEntry=function(t){this.head?(this.tail.next=t,t.prev=this.tail,t.next=null,this.tail=t):this.head=this.tail=t,this._len++},r.prototype.remove=function(t){var e=t.prev,a=t.next;e?e.next=a:this.head=a,a?a.prev=e:this.tail=e,t.next=t.prev=null,this._len--},r.prototype.len=function(){return this._len},r.prototype.clear=function(){this.head=this.tail=null,this._len=0},r}(),Eh=function(){function r(t){this._list=new w8,this._maxSize=10,this._map={},this._maxSize=t}return r.prototype.put=function(t,e){var a=this._list,i=this._map,n=null;if(i[t]==null){var o=a.len(),s=this._lastRemovedEntry;if(o>=this._maxSize&&o>0){var l=a.head;a.remove(l),delete i[l.key],n=l.value,this._lastRemovedEntry=l}s?s.value=e:s=new v4(e),s.key=t,a.insertEntry(s),i[t]=s}return n},r.prototype.get=function(t){var e=this._map[t],a=this._list;if(e!=null)return e!==a.tail&&(a.remove(e),a.insertEntry(e)),e.value},r.prototype.clear=function(){this._list.clear(),this._map={}},r.prototype.len=function(){return this._list.len()},r}(),BC={transparent:[0,0,0,0],aliceblue:[240,248,255,1],antiquewhite:[250,235,215,1],aqua:[0,255,255,1],aquamarine:[127,255,212,1],azure:[240,255,255,1],beige:[245,245,220,1],bisque:[255,228,196,1],black:[0,0,0,1],blanchedalmond:[255,235,205,1],blue:[0,0,255,1],blueviolet:[138,43,226,1],brown:[165,42,42,1],burlywood:[222,184,135,1],cadetblue:[95,158,160,1],chartreuse:[127,255,0,1],chocolate:[210,105,30,1],coral:[255,127,80,1],cornflowerblue:[100,149,237,1],cornsilk:[255,248,220,1],crimson:[220,20,60,1],cyan:[0,255,255,1],darkblue:[0,0,139,1],darkcyan:[0,139,139,1],darkgoldenrod:[184,134,11,1],darkgray:[169,169,169,1],darkgreen:[0,100,0,1],darkgrey:[169,169,169,1],darkkhaki:[189,183,107,1],darkmagenta:[139,0,139,1],darkolivegreen:[85,107,47,1],darkorange:[255,140,0,1],darkorchid:[153,50,204,1],darkred:[139,0,0,1],darksalmon:[233,150,122,1],darkseagreen:[143,188,143,1],darkslateblue:[72,61,139,1],darkslategray:[47,79,79,1],darkslategrey:[47,79,79,1],darkturquoise:[0,206,209,1],darkviolet:[148,0,211,1],deeppink:[255,20,147,1],deepskyblue:[0,191,255,1],dimgray:[105,105,105,1],dimgrey:[105,105,105,1],dodgerblue:[30,144,255,1],firebrick:[178,34,34,1],floralwhite:[255,250,240,1],forestgreen:[34,139,34,1],fuchsia:[255,0,255,1],gainsboro:[220,220,220,1],ghostwhite:[248,248,255,1],gold:[255,215,0,1],goldenrod:[218,165,32,1],gray:[128,128,128,1],green:[0,128,0,1],greenyellow:[173,255,47,1],grey:[128,128,128,1],honeydew:[240,255,240,1],hotpink:[255,105,180,1],indianred:[205,92,92,1],indigo:[75,0,130,1],ivory:[255,255,240,1],khaki:[240,230,140,1],lavender:[230,230,250,1],lavenderblush:[255,240,245,1],lawngreen:[124,252,0,1],lemonchiffon:[255,250,205,1],lightblue:[173,216,230,1],lightcoral:[240,128,128,1],lightcyan:[224,255,255,1],lightgoldenrodyellow:[250,250,210,1],lightgray:[211,211,211,1],lightgreen:[144,238,144,1],lightgrey:[211,211,211,1],lightpink:[255,182,193,1],lightsalmon:[255,160,122,1],lightseagreen:[32,178,170,1],lightskyblue:[135,206,250,1],lightslategray:[119,136,153,1],lightslategrey:[119,136,153,1],lightsteelblue:[176,196,222,1],lightyellow:[255,255,224,1],lime:[0,255,0,1],limegreen:[50,205,50,1],linen:[250,240,230,1],magenta:[255,0,255,1],maroon:[128,0,0,1],mediumaquamarine:[102,205,170,1],mediumblue:[0,0,205,1],mediumorchid:[186,85,211,1],mediumpurple:[147,112,219,1],mediumseagreen:[60,179,113,1],mediumslateblue:[123,104,238,1],mediumspringgreen:[0,250,154,1],mediumturquoise:[72,209,204,1],mediumvioletred:[199,21,133,1],midnightblue:[25,25,112,1],mintcream:[245,255,250,1],mistyrose:[255,228,225,1],moccasin:[255,228,181,1],navajowhite:[255,222,173,1],navy:[0,0,128,1],oldlace:[253,245,230,1],olive:[128,128,0,1],olivedrab:[107,142,35,1],orange:[255,165,0,1],orangered:[255,69,0,1],orchid:[218,112,214,1],palegoldenrod:[238,232,170,1],palegreen:[152,251,152,1],paleturquoise:[175,238,238,1],palevioletred:[219,112,147,1],papayawhip:[255,239,213,1],peachpuff:[255,218,185,1],peru:[205,133,63,1],pink:[255,192,203,1],plum:[221,160,221,1],powderblue:[176,224,230,1],purple:[128,0,128,1],red:[255,0,0,1],rosybrown:[188,143,143,1],royalblue:[65,105,225,1],saddlebrown:[139,69,19,1],salmon:[250,128,114,1],sandybrown:[244,164,96,1],seagreen:[46,139,87,1],seashell:[255,245,238,1],sienna:[160,82,45,1],silver:[192,192,192,1],skyblue:[135,206,235,1],slateblue:[106,90,205,1],slategray:[112,128,144,1],slategrey:[112,128,144,1],snow:[255,250,250,1],springgreen:[0,255,127,1],steelblue:[70,130,180,1],tan:[210,180,140,1],teal:[0,128,128,1],thistle:[216,191,216,1],tomato:[255,99,71,1],turquoise:[64,224,208,1],violet:[238,130,238,1],wheat:[245,222,179,1],white:[255,255,255,1],whitesmoke:[245,245,245,1],yellow:[255,255,0,1],yellowgreen:[154,205,50,1]};function bo(r){return r=Math.round(r),r<0?0:r>255?255:r}function S8(r){return r=Math.round(r),r<0?0:r>360?360:r}function ah(r){return r<0?0:r>1?1:r}function b1(r){var t=r;return t.length&&t.charAt(t.length-1)==="%"?bo(parseFloat(t)/100*255):bo(parseInt(t,10))}function rv(r){var t=r;return t.length&&t.charAt(t.length-1)==="%"?ah(parseFloat(t)/100):ah(parseFloat(t))}function w1(r,t,e){return e<0?e+=1:e>1&&(e-=1),e*6<1?r+(t-r)*e*6:e*2<1?t:e*3<2?r+(t-r)*(2/3-e)*6:r}function bl(r,t,e){return r+(t-r)*e}function kn(r,t,e,a,i){return r[0]=t,r[1]=e,r[2]=a,r[3]=i,r}function uw(r,t){return r[0]=t[0],r[1]=t[1],r[2]=t[2],r[3]=t[3],r}var c4=new Eh(20),_g=null;function Hv(r,t){_g&&uw(_g,t),_g=c4.put(r,_g||t.slice())}function gn(r,t){if(r){t=t||[];var e=c4.get(r);if(e)return uw(t,e);r=r+"";var a=r.replace(/ /g,"").toLowerCase();if(a in BC)return uw(t,BC[a]),Hv(r,t),t;var i=a.length;if(a.charAt(0)==="#"){if(i===4||i===5){var n=parseInt(a.slice(1,4),16);if(!(n>=0&&n<=4095)){kn(t,0,0,0,1);return}return kn(t,(n&3840)>>4|(n&3840)>>8,n&240|(n&240)>>4,n&15|(n&15)<<4,i===5?parseInt(a.slice(4),16)/15:1),Hv(r,t),t}else if(i===7||i===9){var n=parseInt(a.slice(1,7),16);if(!(n>=0&&n<=16777215)){kn(t,0,0,0,1);return}return kn(t,(n&16711680)>>16,(n&65280)>>8,n&255,i===9?parseInt(a.slice(7),16)/255:1),Hv(r,t),t}return}var o=a.indexOf("("),s=a.indexOf(")");if(o!==-1&&s+1===i){var l=a.substr(0,o),u=a.substr(o+1,s-(o+1)).split(","),v=1;switch(l){case"rgba":if(u.length!==4)return u.length===3?kn(t,+u[0],+u[1],+u[2],1):kn(t,0,0,0,1);v=rv(u.pop());case"rgb":if(u.length>=3)return kn(t,b1(u[0]),b1(u[1]),b1(u[2]),u.length===3?v:rv(u[3])),Hv(r,t),t;kn(t,0,0,0,1);return;case"hsla":if(u.length!==4){kn(t,0,0,0,1);return}return u[3]=rv(u[3]),vw(u,t),Hv(r,t),t;case"hsl":if(u.length!==3){kn(t,0,0,0,1);return}return vw(u,t),Hv(r,t),t;default:return}}kn(t,0,0,0,1)}}function vw(r,t){var e=(parseFloat(r[0])%360+360)%360/360,a=rv(r[1]),i=rv(r[2]),n=i<=.5?i*(a+1):i+a-i*a,o=i*2-n;return t=t||[],kn(t,bo(w1(o,n,e+1/3)*255),bo(w1(o,n,e)*255),bo(w1(o,n,e-1/3)*255),1),r.length===4&&(t[3]=r[3]),t}function T8(r){if(r){var t=r[0]/255,e=r[1]/255,a=r[2]/255,i=Math.min(t,e,a),n=Math.max(t,e,a),o=n-i,s=(n+i)/2,l,u;if(o===0)l=0,u=0;else{s<.5?u=o/(n+i):u=o/(2-n-i);var v=((n-t)/6+o/2)/o,c=((n-e)/6+o/2)/o,d=((n-a)/6+o/2)/o;t===n?l=d-c:e===n?l=1/3+v-d:a===n&&(l=2/3+c-v),l<0&&(l+=1),l>1&&(l-=1)}var f=[l*360,u,s];return r[3]!=null&&f.push(r[3]),f}}function cw(r,t){var e=gn(r);if(e){for(var a=0;a<3;a++)t<0?e[a]=e[a]*(1-t)|0:e[a]=(255-e[a])*t+e[a]|0,e[a]>255?e[a]=255:e[a]<0&&(e[a]=0);return Ps(e,e.length===4?"rgba":"rgb")}}function S1(r,t,e){if(!(!(t&&t.length)||!(r>=0&&r<=1))){e=e||[];var a=r*(t.length-1),i=Math.floor(a),n=Math.ceil(a),o=t[i],s=t[n],l=a-i;return e[0]=bo(bl(o[0],s[0],l)),e[1]=bo(bl(o[1],s[1],l)),e[2]=bo(bl(o[2],s[2],l)),e[3]=ah(bl(o[3],s[3],l)),e}}function A8(r,t,e){if(!(!(t&&t.length)||!(r>=0&&r<=1))){var a=r*(t.length-1),i=Math.floor(a),n=Math.ceil(a),o=gn(t[i]),s=gn(t[n]),l=a-i,u=Ps([bo(bl(o[0],s[0],l)),bo(bl(o[1],s[1],l)),bo(bl(o[2],s[2],l)),ah(bl(o[3],s[3],l))],"rgba");return e?{color:u,leftIndex:i,rightIndex:n,value:a}:u}}function zf(r,t,e,a){var i=gn(r);if(r)return i=T8(i),t!=null&&(i[0]=S8(t)),e!=null&&(i[1]=rv(e)),a!=null&&(i[2]=rv(a)),Ps(vw(i),"rgba")}function Zy(r,t){var e=gn(r);if(e&&t!=null)return e[3]=ah(t),Ps(e,"rgba")}function Ps(r,t){if(!(!r||!r.length)){var e=r[0]+","+r[1]+","+r[2];return(t==="rgba"||t==="hsva"||t==="hsla")&&(e+=","+r[3]),t+"("+e+")"}}function Xy(r,t){var e=gn(r);return e?(.299*e[0]+.587*e[1]+.114*e[2])*e[3]/255+(1-e[3])*t:0}var VC=new Eh(100);function dw(r){if(Pt(r)){var t=VC.get(r);return t||(t=cw(r,-.1),VC.put(r,t)),t}else if(t_(r)){var e=lt({},r);return e.colorStops=pt(r.colorStops,function(a){return{offset:a.offset,color:cw(a.color,-.1)}}),e}return r}var jy=Math.round;function ih(r){var t;if(!r||r==="transparent")r="none";else if(typeof r=="string"&&r.indexOf("rgba")>-1){var e=gn(r);e&&(r="rgb("+e[0]+","+e[1]+","+e[2]+")",t=e[3])}return{color:r,opacity:t??1}}var FC=1e-4;function wl(r){return r-FC}function xg(r){return jy(r*1e3)/1e3}function fw(r){return jy(r*1e4)/1e4}function C8(r){return"matrix("+xg(r[0])+","+xg(r[1])+","+xg(r[2])+","+xg(r[3])+","+fw(r[4])+","+fw(r[5])+")"}var k8={left:"start",right:"end",center:"middle",middle:"middle"};function D8(r,t,e){return e==="top"?r+=t/2:e==="bottom"&&(r-=t/2),r}function M8(r){return r&&(r.shadowBlur||r.shadowOffsetX||r.shadowOffsetY)}function L8(r){var t=r.style,e=r.getGlobalScale();return[t.shadowColor,(t.shadowBlur||0).toFixed(2),(t.shadowOffsetX||0).toFixed(2),(t.shadowOffsetY||0).toFixed(2),e[0],e[1]].join(",")}function d4(r){return r&&!!r.image}function I8(r){return r&&!!r.svgElement}function xS(r){return d4(r)||I8(r)}function f4(r){return r.type==="linear"}function h4(r){return r.type==="radial"}function p4(r){return r&&(r.type==="linear"||r.type==="radial")}function a_(r){return"url(#"+r+")"}function g4(r){var t=r.getGlobalScale(),e=Math.max(t[0],t[1]);return Math.max(Math.ceil(Math.log(e)/Math.log(10)),1)}function y4(r){var t=r.x||0,e=r.y||0,a=(r.rotation||0)*wy,i=Be(r.scaleX,1),n=Be(r.scaleY,1),o=r.skewX||0,s=r.skewY||0,l=[];return(t||e)&&l.push("translate("+t+"px,"+e+"px)"),a&&l.push("rotate("+a+")"),(i!==1||n!==1)&&l.push("scale("+i+","+n+")"),(o||s)&&l.push("skew("+jy(o*wy)+"deg, "+jy(s*wy)+"deg)"),l.join(" ")}var R8=function(){return dr.hasGlobalWindow&&le(window.btoa)?function(r){return window.btoa(unescape(encodeURIComponent(r)))}:typeof Buffer<"u"?function(r){return Buffer.from(r).toString("base64")}:function(r){return null}}(),hw=Array.prototype.slice;function ws(r,t,e){return(t-r)*e+r}function T1(r,t,e,a){for(var i=t.length,n=0;na?t:r,n=Math.min(e,a),o=i[n-1]||{color:[0,0,0,0],offset:0},s=n;so;if(s)a.length=o;else for(var l=n;l=1},r.prototype.getAdditiveTrack=function(){return this._additiveTrack},r.prototype.addKeyframe=function(t,e,a){this._needsSort=!0;var i=this.keyframes,n=i.length,o=!1,s=HC,l=e;if(ki(e)){var u=O8(e);s=u,(u===1&&!Or(e[0])||u===2&&!Or(e[0][0]))&&(o=!0)}else if(Or(e)&&!th(e))s=wg;else if(Pt(e))if(!isNaN(+e))s=wg;else{var v=gn(e);v&&(l=v,s=_f)}else if(t_(e)){var c=lt({},l);c.colorStops=pt(e.colorStops,function(f){return{offset:f.offset,color:gn(f.color)}}),f4(e)?s=pw:h4(e)&&(s=gw),l=c}n===0?this.valType=s:(s!==this.valType||s===HC)&&(o=!0),this.discrete=this.discrete||o;var d={time:t,value:l,rawValue:e,percent:0};return a&&(d.easing=a,d.easingFunc=le(a)?a:Of[a]||_S(a)),i.push(d),d},r.prototype.prepare=function(t,e){var a=this.keyframes;this._needsSort&&a.sort(function(g,m){return g.time-m.time});for(var i=this.valType,n=a.length,o=a[n-1],s=this.discrete,l=Sg(i),u=WC(i),v=0;v=0&&!(o[v].percent<=e);v--);v=d(v,s-2)}else{for(v=c;ve);v++);v=d(v-1,s-2)}h=o[v+1],f=o[v]}if(f&&h){this._lastFr=v,this._lastFrP=e;var m=h.percent-f.percent,x=m===0?1:d((e-f.percent)/m,1);h.easingFunc&&(x=h.easingFunc(x));var b=a?this._additiveValue:u?Ud:t[l];if((Sg(n)||u)&&!b&&(b=this._additiveValue=[]),this.discrete)t[l]=x<1?f.rawValue:h.rawValue;else if(Sg(n))n===ky?T1(b,f[i],h[i],x):P8(b,f[i],h[i],x);else if(WC(n)){var w=f[i],S=h[i],C=n===pw;t[l]={type:C?"linear":"radial",x:ws(w.x,S.x,x),y:ws(w.y,S.y,x),colorStops:pt(w.colorStops,function(k,D){var M=S.colorStops[D];return{offset:ws(k.offset,M.offset,x),color:Cy(T1([],k.color,M.color,x))}}),global:S.global},C?(t[l].x2=ws(w.x2,S.x2,x),t[l].y2=ws(w.y2,S.y2,x)):t[l].r=ws(w.r,S.r,x)}else if(u)T1(b,f[i],h[i],x),a||(t[l]=Cy(b));else{var T=ws(f[i],h[i],x);a?this._additiveValue=T:t[l]=T}a&&this._addToTarget(t)}}},r.prototype._addToTarget=function(t){var e=this.valType,a=this.propName,i=this._additiveValue;e===wg?t[a]=t[a]+i:e===_f?(gn(t[a],Ud),bg(Ud,Ud,i,1),t[a]=Cy(Ud)):e===ky?bg(t[a],t[a],i,1):e===m4&&GC(t[a],t[a],i,1)},r}(),bS=function(){function r(t,e,a,i){if(this._tracks={},this._trackKeys=[],this._maxTime=0,this._started=0,this._clip=null,this._target=t,this._loop=e,e&&i){dS("Can' use additive animation on looped animation.");return}this._additiveAnimators=i,this._allowDiscrete=a}return r.prototype.getMaxTime=function(){return this._maxTime},r.prototype.getDelay=function(){return this._delay},r.prototype.getLoop=function(){return this._loop},r.prototype.getTarget=function(){return this._target},r.prototype.changeTarget=function(t){this._target=t},r.prototype.when=function(t,e,a){return this.whenWithKeys(t,e,xr(e),a)},r.prototype.whenWithKeys=function(t,e,a,i){for(var n=this._tracks,o=0;o0&&l.addKeyframe(0,Bf(u),i),this._trackKeys.push(s)}l.addKeyframe(t,Bf(e[s]),i)}return this._maxTime=Math.max(this._maxTime,t),this},r.prototype.pause=function(){this._clip.pause(),this._paused=!0},r.prototype.resume=function(){this._clip.resume(),this._paused=!1},r.prototype.isPaused=function(){return!!this._paused},r.prototype.duration=function(t){return this._maxTime=t,this._force=!0,this},r.prototype._doneCallback=function(){this._setTracksFinished(),this._clip=null;var t=this._doneCbs;if(t)for(var e=t.length,a=0;a0)){this._started=1;for(var e=this,a=[],i=this._maxTime||0,n=0;n1){var s=o.pop();n.addKeyframe(s.time,t[i]),n.prepare(this._maxTime,n.getAdditiveTrack())}}}},r}();function pc(){return new Date().getTime()}var B8=function(r){xa(t,r);function t(e){var a=r.call(this)||this;return a._running=!1,a._time=0,a._pausedTime=0,a._pauseStart=0,a._paused=!1,e=e||{},a.stage=e.stage||{},a}return t.prototype.addClip=function(e){e.animation&&this.removeClip(e),this._head?(this._tail.next=e,e.prev=this._tail,e.next=null,this._tail=e):this._head=this._tail=e,e.animation=this},t.prototype.addAnimator=function(e){e.animation=this;var a=e.getClip();a&&this.addClip(a)},t.prototype.removeClip=function(e){if(e.animation){var a=e.prev,i=e.next;a?a.next=i:this._head=i,i?i.prev=a:this._tail=a,e.next=e.prev=e.animation=null}},t.prototype.removeAnimator=function(e){var a=e.getClip();a&&this.removeClip(a),e.animation=null},t.prototype.update=function(e){for(var a=pc()-this._pausedTime,i=a-this._time,n=this._head;n;){var o=n.next,s=n.step(a,i);s&&(n.ondestroy(),this.removeClip(n)),n=o}this._time=a,e||(this.trigger("frame",i),this.stage.update&&this.stage.update())},t.prototype._startLoop=function(){var e=this;this._running=!0;function a(){e._running&&(qy(a),!e._paused&&e.update())}qy(a)},t.prototype.start=function(){this._running||(this._time=pc(),this._pausedTime=0,this._startLoop())},t.prototype.stop=function(){this._running=!1},t.prototype.pause=function(){this._paused||(this._pauseStart=pc(),this._paused=!0)},t.prototype.resume=function(){this._paused&&(this._pausedTime+=pc()-this._pauseStart,this._paused=!1)},t.prototype.clear=function(){for(var e=this._head;e;){var a=e.next;e.prev=e.next=e.animation=null,e=a}this._head=this._tail=null},t.prototype.isFinished=function(){return this._head==null},t.prototype.animate=function(e,a){a=a||{},this.start();var i=new bS(e,a.loop);return this.addAnimator(i),i},t}(Kn),V8=300,A1=dr.domSupported,C1=function(){var r=["click","dblclick","mousewheel","wheel","mouseout","mouseup","mousedown","mousemove","contextmenu"],t=["touchstart","touchend","touchmove"],e={pointerdown:1,pointerup:1,pointermove:1,pointerout:1},a=pt(r,function(i){var n=i.replace("mouse","pointer");return e.hasOwnProperty(n)?n:i});return{mouse:r,touch:t,pointer:a}}(),UC={mouse:["mousemove","mouseup"],pointer:["pointermove","pointerup"]},qC=!1;function yw(r){var t=r.pointerType;return t==="pen"||t==="touch"}function F8(r){r.touching=!0,r.touchTimer!=null&&(clearTimeout(r.touchTimer),r.touchTimer=null),r.touchTimer=setTimeout(function(){r.touching=!1,r.touchTimer=null},700)}function k1(r){r&&(r.zrByTouch=!0)}function G8(r,t){return Mn(r.dom,new H8(r,t),!0)}function _4(r,t){for(var e=t,a=!1;e&&e.nodeType!==9&&!(a=e.domBelongToZr||e!==t&&e===r.painterRoot);)e=e.parentNode;return a}var H8=function(){function r(t,e){this.stopPropagation=Fa,this.stopImmediatePropagation=Fa,this.preventDefault=Fa,this.type=e.type,this.target=this.currentTarget=t.dom,this.pointerType=e.pointerType,this.clientX=e.clientX,this.clientY=e.clientY}return r}(),uo={mousedown:function(r){r=Mn(this.dom,r),this.__mayPointerCapture=[r.zrX,r.zrY],this.trigger("mousedown",r)},mousemove:function(r){r=Mn(this.dom,r);var t=this.__mayPointerCapture;t&&(r.zrX!==t[0]||r.zrY!==t[1])&&this.__togglePointerCapture(!0),this.trigger("mousemove",r)},mouseup:function(r){r=Mn(this.dom,r),this.__togglePointerCapture(!1),this.trigger("mouseup",r)},mouseout:function(r){r=Mn(this.dom,r);var t=r.toElement||r.relatedTarget;_4(this,t)||(this.__pointerCapturing&&(r.zrEventControl="no_globalout"),this.trigger("mouseout",r))},wheel:function(r){qC=!0,r=Mn(this.dom,r),this.trigger("mousewheel",r)},mousewheel:function(r){qC||(r=Mn(this.dom,r),this.trigger("mousewheel",r))},touchstart:function(r){r=Mn(this.dom,r),k1(r),this.__lastTouchMoment=new Date,this.handler.processGesture(r,"start"),uo.mousemove.call(this,r),uo.mousedown.call(this,r)},touchmove:function(r){r=Mn(this.dom,r),k1(r),this.handler.processGesture(r,"change"),uo.mousemove.call(this,r)},touchend:function(r){r=Mn(this.dom,r),k1(r),this.handler.processGesture(r,"end"),uo.mouseup.call(this,r),+new Date-+this.__lastTouchMomentZC||r<-ZC}var nu=[],Wv=[],M1=pn(),L1=Math.abs,Ds=function(){function r(){}return r.prototype.getLocalTransform=function(t){return r.getLocalTransform(this,t)},r.prototype.setPosition=function(t){this.x=t[0],this.y=t[1]},r.prototype.setScale=function(t){this.scaleX=t[0],this.scaleY=t[1]},r.prototype.setSkew=function(t){this.skewX=t[0],this.skewY=t[1]},r.prototype.setOrigin=function(t){this.originX=t[0],this.originY=t[1]},r.prototype.needLocalTransform=function(){return iu(this.rotation)||iu(this.x)||iu(this.y)||iu(this.scaleX-1)||iu(this.scaleY-1)||iu(this.skewX)||iu(this.skewY)},r.prototype.updateTransform=function(){var t=this.parent&&this.parent.transform,e=this.needLocalTransform(),a=this.transform;if(!(e||t)){a&&(YC(a),this.invTransform=null);return}a=a||pn(),e?this.getLocalTransform(a):YC(a),t&&(e?Rs(a,t,a):yS(a,t)),this.transform=a,this._resolveGlobalScaleRatio(a)},r.prototype._resolveGlobalScaleRatio=function(t){var e=this.globalScaleRatio;if(e!=null&&e!==1){this.getGlobalScale(nu);var a=nu[0]<0?-1:1,i=nu[1]<0?-1:1,n=((nu[0]-a)*e+a)/nu[0]||0,o=((nu[1]-i)*e+i)/nu[1]||0;t[0]*=n,t[1]*=n,t[2]*=o,t[3]*=o}this.invTransform=this.invTransform||pn(),id(this.invTransform,t)},r.prototype.getComputedTransform=function(){for(var t=this,e=[];t;)e.push(t),t=t.parent;for(;t=e.pop();)t.updateTransform();return this.transform},r.prototype.setLocalTransform=function(t){if(t){var e=t[0]*t[0]+t[1]*t[1],a=t[2]*t[2]+t[3]*t[3],i=Math.atan2(t[1],t[0]),n=Math.PI/2+i-Math.atan2(t[3],t[2]);a=Math.sqrt(a)*Math.cos(n),e=Math.sqrt(e),this.skewX=n,this.skewY=0,this.rotation=-i,this.x=+t[4],this.y=+t[5],this.scaleX=e,this.scaleY=a,this.originX=0,this.originY=0}},r.prototype.decomposeTransform=function(){if(this.transform){var t=this.parent,e=this.transform;t&&t.transform&&(t.invTransform=t.invTransform||pn(),Rs(Wv,t.invTransform,e),e=Wv);var a=this.originX,i=this.originY;(a||i)&&(M1[4]=a,M1[5]=i,Rs(Wv,e,M1),Wv[4]-=a,Wv[5]-=i,e=Wv),this.setLocalTransform(e)}},r.prototype.getGlobalScale=function(t){var e=this.transform;return t=t||[],e?(t[0]=Math.sqrt(e[0]*e[0]+e[1]*e[1]),t[1]=Math.sqrt(e[2]*e[2]+e[3]*e[3]),e[0]<0&&(t[0]=-t[0]),e[3]<0&&(t[1]=-t[1]),t):(t[0]=1,t[1]=1,t)},r.prototype.transformCoordToLocal=function(t,e){var a=[t,e],i=this.invTransform;return i&&pi(a,a,i),a},r.prototype.transformCoordToGlobal=function(t,e){var a=[t,e],i=this.transform;return i&&pi(a,a,i),a},r.prototype.getLineScale=function(){var t=this.transform;return t&&L1(t[0]-1)>1e-10&&L1(t[3]-1)>1e-10?Math.sqrt(L1(t[0]*t[3]-t[2]*t[1])):1},r.prototype.copyTransform=function(t){b4(this,t)},r.getLocalTransform=function(t,e){e=e||[];var a=t.originX||0,i=t.originY||0,n=t.scaleX,o=t.scaleY,s=t.anchorX,l=t.anchorY,u=t.rotation||0,v=t.x,c=t.y,d=t.skewX?Math.tan(t.skewX):0,f=t.skewY?Math.tan(-t.skewY):0;if(a||i||s||l){var h=a+s,g=i+l;e[4]=-h*n-d*g*o,e[5]=-g*o-f*h*n}else e[4]=e[5]=0;return e[0]=n,e[3]=o,e[1]=f*n,e[2]=d*o,u&&Cv(e,e,u),e[4]+=a+v,e[5]+=i+c,e},r.initDefaultProps=function(){var t=r.prototype;t.scaleX=t.scaleY=t.globalScaleRatio=1,t.x=t.y=t.originX=t.originY=t.skewX=t.skewY=t.rotation=t.anchorX=t.anchorY=0}(),r}(),ls=["x","y","originX","originY","anchorX","anchorY","rotation","scaleX","scaleY","skewX","skewY"];function b4(r,t){for(var e=0;e=0?parseFloat(r)/100*t:parseFloat(r):r}function Qy(r,t,e){var a=t.position||"inside",i=t.distance!=null?t.distance:5,n=e.height,o=e.width,s=n/2,l=e.x,u=e.y,v="left",c="top";if(a instanceof Array)l+=Ao(a[0],e.width),u+=Ao(a[1],e.height),v=null,c=null;else switch(a){case"left":l-=i,u+=s,v="right",c="middle";break;case"right":l+=i+o,u+=s,c="middle";break;case"top":l+=o/2,u-=i,v="center",c="bottom";break;case"bottom":l+=o/2,u+=n+i,v="center";break;case"inside":l+=o/2,u+=s,v="center",c="middle";break;case"insideLeft":l+=i,u+=s,c="middle";break;case"insideRight":l+=o-i,u+=s,v="right",c="middle";break;case"insideTop":l+=o/2,u+=i,v="center";break;case"insideBottom":l+=o/2,u+=n-i,v="center",c="bottom";break;case"insideTopLeft":l+=i,u+=i;break;case"insideTopRight":l+=o-i,u+=i,v="right";break;case"insideBottomLeft":l+=i,u+=n-i,c="bottom";break;case"insideBottomRight":l+=o-i,u+=n-i,v="right",c="bottom";break}return r=r||{},r.x=l,r.y=u,r.align=v,r.verticalAlign=c,r}var I1="__zr_normal__",R1=ls.concat(["ignore"]),Y8=os(ls,function(r,t){return r[t]=!0,r},{ignore:!1}),Uv={},Z8=new er(0,0,0,0),n_=function(){function r(t){this.id=XE(),this.animators=[],this.currentStates=[],this.states={},this._init(t)}return r.prototype._init=function(t){this.attr(t)},r.prototype.drift=function(t,e,a){switch(this.draggable){case"horizontal":e=0;break;case"vertical":t=0;break}var i=this.transform;i||(i=this.transform=[1,0,0,1,0,0]),i[4]+=t,i[5]+=e,this.decomposeTransform(),this.markRedraw()},r.prototype.beforeUpdate=function(){},r.prototype.afterUpdate=function(){},r.prototype.update=function(){this.updateTransform(),this.__dirty&&this.updateInnerText()},r.prototype.updateInnerText=function(t){var e=this._textContent;if(e&&(!e.ignore||t)){this.textConfig||(this.textConfig={});var a=this.textConfig,i=a.local,n=e.innerTransformable,o=void 0,s=void 0,l=!1;n.parent=i?this:null;var u=!1;if(n.copyTransform(e),a.position!=null){var v=Z8;a.layoutRect?v.copy(a.layoutRect):v.copy(this.getBoundingRect()),i||v.applyTransform(this.transform),this.calculateTextPosition?this.calculateTextPosition(Uv,a,v):Qy(Uv,a,v),n.x=Uv.x,n.y=Uv.y,o=Uv.align,s=Uv.verticalAlign;var c=a.origin;if(c&&a.rotation!=null){var d=void 0,f=void 0;c==="center"?(d=v.width*.5,f=v.height*.5):(d=Ao(c[0],v.width),f=Ao(c[1],v.height)),u=!0,n.originX=-n.x+d+(i?0:v.x),n.originY=-n.y+f+(i?0:v.y)}}a.rotation!=null&&(n.rotation=a.rotation);var h=a.offset;h&&(n.x+=h[0],n.y+=h[1],u||(n.originX=-h[0],n.originY=-h[1]));var g=a.inside==null?typeof a.position=="string"&&a.position.indexOf("inside")>=0:a.inside,m=this._innerTextDefaultStyle||(this._innerTextDefaultStyle={}),x=void 0,b=void 0,w=void 0;g&&this.canBeInsideText()?(x=a.insideFill,b=a.insideStroke,(x==null||x==="auto")&&(x=this.getInsideTextFill()),(b==null||b==="auto")&&(b=this.getInsideTextStroke(x),w=!0)):(x=a.outsideFill,b=a.outsideStroke,(x==null||x==="auto")&&(x=this.getOutsideFill()),(b==null||b==="auto")&&(b=this.getOutsideStroke(x),w=!0)),x=x||"#000",(x!==m.fill||b!==m.stroke||w!==m.autoStroke||o!==m.align||s!==m.verticalAlign)&&(l=!0,m.fill=x,m.stroke=b,m.autoStroke=w,m.align=o,m.verticalAlign=s,e.setDefaultTextStyle(m)),e.__dirty|=dn,l&&e.dirtyStyle(!0)}},r.prototype.canBeInsideText=function(){return!0},r.prototype.getInsideTextFill=function(){return"#fff"},r.prototype.getInsideTextStroke=function(t){return"#000"},r.prototype.getOutsideFill=function(){return this.__zr&&this.__zr.isDarkMode()?bw:xw},r.prototype.getOutsideStroke=function(t){var e=this.__zr&&this.__zr.getBackgroundColor(),a=typeof e=="string"&&gn(e);a||(a=[255,255,255,1]);for(var i=a[3],n=this.__zr.isDarkMode(),o=0;o<3;o++)a[o]=a[o]*i+(n?0:255)*(1-i);return a[3]=1,Ps(a,"rgba")},r.prototype.traverse=function(t,e){},r.prototype.attrKV=function(t,e){t==="textConfig"?this.setTextConfig(e):t==="textContent"?this.setTextContent(e):t==="clipPath"?this.setClipPath(e):t==="extra"?(this.extra=this.extra||{},lt(this.extra,e)):this[t]=e},r.prototype.hide=function(){this.ignore=!0,this.markRedraw()},r.prototype.show=function(){this.ignore=!1,this.markRedraw()},r.prototype.attr=function(t,e){if(typeof t=="string")this.attrKV(t,e);else if(pe(t))for(var a=t,i=xr(a),n=0;n0},r.prototype.getState=function(t){return this.states[t]},r.prototype.ensureState=function(t){var e=this.states;return e[t]||(e[t]={}),e[t]},r.prototype.clearStates=function(t){this.useState(I1,!1,t)},r.prototype.useState=function(t,e,a,i){var n=t===I1,o=this.hasState();if(!(!o&&n)){var s=this.currentStates,l=this.stateTransition;if(!(ir(s,t)>=0&&(e||s.length===1))){var u;if(this.stateProxy&&!n&&(u=this.stateProxy(t)),u||(u=this.states&&this.states[t]),!u&&!n){dS("State "+t+" not exists.");return}n||this.saveCurrentToNormalState(u);var v=!!(u&&u.hoverLayer||i);v&&this._toggleHoverLayerFlag(!0),this._applyStateObj(t,u,this._normalState,e,!a&&!this.__inHover&&l&&l.duration>0,l);var c=this._textContent,d=this._textGuide;return c&&c.useState(t,e,a,v),d&&d.useState(t,e,a,v),n?(this.currentStates=[],this._normalState={}):e?this.currentStates.push(t):this.currentStates=[t],this._updateAnimationTargets(),this.markRedraw(),!v&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=~dn),u}}},r.prototype.useStates=function(t,e,a){if(!t.length)this.clearStates();else{var i=[],n=this.currentStates,o=t.length,s=o===n.length;if(s){for(var l=0;l0,h);var g=this._textContent,m=this._textGuide;g&&g.useStates(t,e,d),m&&m.useStates(t,e,d),this._updateAnimationTargets(),this.currentStates=t.slice(),this.markRedraw(),!d&&this.__inHover&&(this._toggleHoverLayerFlag(!1),this.__dirty&=~dn)}},r.prototype.isSilent=function(){for(var t=this.silent,e=this.parent;!t&&e;){if(e.silent){t=!0;break}e=e.parent}return t},r.prototype._updateAnimationTargets=function(){for(var t=0;t=0){var a=this.currentStates.slice();a.splice(e,1),this.useStates(a)}},r.prototype.replaceState=function(t,e,a){var i=this.currentStates.slice(),n=ir(i,t),o=ir(i,e)>=0;n>=0?o?i.splice(n,1):i[n]=e:a&&!o&&i.push(e),this.useStates(i)},r.prototype.toggleState=function(t,e){e?this.useState(t,!0):this.removeState(t)},r.prototype._mergeStates=function(t){for(var e={},a,i=0;i=0&&n.splice(o,1)}),this.animators.push(t),a&&a.animation.addAnimator(t),a&&a.wakeUp()},r.prototype.updateDuringAnimation=function(t){this.markRedraw()},r.prototype.stopAnimation=function(t,e){for(var a=this.animators,i=a.length,n=[],o=0;o0&&e.during&&n[0].during(function(h,g){e.during(g)});for(var d=0;d0||i.force&&!o.length){var D=void 0,M=void 0,L=void 0;if(s){M={},d&&(D={});for(var S=0;S=0&&(i.splice(n,0,e),this._doAdd(e))}return this},t.prototype.replace=function(e,a){var i=ir(this._children,e);return i>=0&&this.replaceAt(a,i),this},t.prototype.replaceAt=function(e,a){var i=this._children,n=i[a];if(e&&e!==this&&e.parent!==this&&e!==n){i[a]=e,n.parent=null;var o=this.__zr;o&&n.removeSelfFromZr(o),this._doAdd(e)}return this},t.prototype._doAdd=function(e){e.parent&&e.parent.remove(e),e.parent=this;var a=this.__zr;a&&a!==e.__zr&&e.addSelfToZr(a),a&&a.refresh()},t.prototype.remove=function(e){var a=this.__zr,i=this._children,n=ir(i,e);return n<0?this:(i.splice(n,1),e.parent=null,a&&e.removeSelfFromZr(a),a&&a.refresh(),this)},t.prototype.removeAll=function(){for(var e=this._children,a=this.__zr,i=0;i0&&(this._stillFrameAccum++,this._stillFrameAccum>this._sleepAfterStill&&this.animation.stop())},r.prototype.setSleepAfterStill=function(t){this._sleepAfterStill=t},r.prototype.wakeUp=function(){this._disposed||(this.animation.start(),this._stillFrameAccum=0)},r.prototype.refreshHover=function(){this._needsRefreshHover=!0},r.prototype.refreshHoverImmediately=function(){this._disposed||(this._needsRefreshHover=!1,this.painter.refreshHover&&this.painter.getType()==="canvas"&&this.painter.refreshHover())},r.prototype.resize=function(t){this._disposed||(t=t||{},this.painter.resize(t.width,t.height),this.handler.resize())},r.prototype.clearAnimation=function(){this._disposed||this.animation.clear()},r.prototype.getWidth=function(){if(!this._disposed)return this.painter.getWidth()},r.prototype.getHeight=function(){if(!this._disposed)return this.painter.getHeight()},r.prototype.setCursorStyle=function(t){this._disposed||this.handler.setCursorStyle(t)},r.prototype.findHover=function(t,e){if(!this._disposed)return this.handler.findHover(t,e)},r.prototype.on=function(t,e,a){return this._disposed||this.handler.on(t,e,a),this},r.prototype.off=function(t,e){this._disposed||this.handler.off(t,e)},r.prototype.trigger=function(t,e){this._disposed||this.handler.trigger(t,e)},r.prototype.clear=function(){if(!this._disposed){for(var t=this.storage.getRoots(),e=0;e0){if(r<=i)return o;if(r>=n)return s}else{if(r>=i)return o;if(r<=n)return s}else{if(r===i)return o;if(r===n)return s}return(r-i)/l*u+o}function Lt(r,t){switch(r){case"center":case"middle":r="50%";break;case"left":case"top":r="0%";break;case"right":case"bottom":r="100%";break}return Pt(r)?n9(r).match(/%$/)?parseFloat(r)/100*t:parseFloat(r):r==null?NaN:+r}function Ea(r,t,e){return t==null&&(t=10),t=Math.min(Math.max(0,t),T4),r=(+r).toFixed(t),e?r:+r}function Un(r){return r.sort(function(t,e){return t-e}),r}function Qo(r){if(r=+r,isNaN(r))return 0;if(r>1e-14){for(var t=1,e=0;e<15;e++,t*=10)if(Math.round(r*t)/t===r)return e}return o9(r)}function o9(r){var t=r.toString().toLowerCase(),e=t.indexOf("e"),a=e>0?+t.slice(e+1):0,i=e>0?e:t.length,n=t.indexOf("."),o=n<0?0:i-1-n;return Math.max(0,o-a)}function A4(r,t){var e=Math.log,a=Math.LN10,i=Math.floor(e(r[1]-r[0])/a),n=Math.round(e(Math.abs(t[1]-t[0]))/a),o=Math.min(Math.max(-i+n,0),20);return isFinite(o)?o:20}function s9(r,t){var e=os(r,function(f,h){return f+(isNaN(h)?0:h)},0);if(e===0)return[];for(var a=Math.pow(10,t),i=pt(r,function(f){return(isNaN(f)?0:f)/e*a*100}),n=a*100,o=pt(i,function(f){return Math.floor(f)}),s=os(o,function(f,h){return f+h},0),l=pt(i,function(f,h){return f-o[h]});su&&(u=l[c],v=c);++o[v],l[v]=0,++s}return pt(o,function(f){return f/a})}function l9(r,t){var e=Math.max(Qo(r),Qo(t)),a=r+t;return e>T4?a:Ea(a,e)}var JC=9007199254740991;function C4(r){var t=Math.PI*2;return(r%t+t)%t}function nh(r){return r>-QC&&r=10&&t++,t}function k4(r,t){var e=wS(r),a=Math.pow(10,e),i=r/a,n;return i<1.5?n=1:i<2.5?n=2:i<4?n=3:i<7?n=5:n=10,r=n*a,e>=-20?+r.toFixed(e<0?-e:0):r}function N1(r,t){var e=(r.length-1)*t+1,a=Math.floor(e),i=+r[a-1],n=e-a;return n?i+n*(r[a]-i):i}function tk(r){r.sort(function(l,u){return s(l,u,0)?-1:1});for(var t=-1/0,e=1,a=0;a=0||n&&ir(n,l)<0)){var u=a.getShallow(l,t);u!=null&&(o[r[s][0]]=u)}}return o}}var E9=[["fill","color"],["shadowBlur"],["shadowOffsetX"],["shadowOffsetY"],["opacity"],["shadowColor"]],N9=yv(E9),O9=function(){function r(){}return r.prototype.getAreaStyle=function(t,e){return N9(this,t,e)},r}(),Tw=new Eh(50);function z9(r){if(typeof r=="string"){var t=Tw.get(r);return t&&t.image}else return r}function CS(r,t,e,a,i){if(r)if(typeof r=="string"){if(t&&t.__zrImageSrc===r||!e)return t;var n=Tw.get(r),o={hostEl:e,cb:a,cbPayload:i};return n?(t=n.image,!s_(t)&&n.pending.push(o)):(t=El.loadImage(r,ik,ik),t.__zrImageSrc=r,Tw.put(r,t.__cachedImgObj={image:t,pending:[o]})),t}else return r;else return t}function ik(){var r=this.__cachedImgObj;this.onload=this.onerror=this.__cachedImgObj=null;for(var t=0;t=o;l++)s-=o;var u=yn(e,t);return u>s&&(e="",u=0),s=r-u,i.ellipsis=e,i.ellipsisWidth=u,i.contentWidth=s,i.containerWidth=r,i}function F4(r,t,e){var a=e.containerWidth,i=e.font,n=e.contentWidth;if(!a){r.textLine="",r.isTruncated=!1;return}var o=yn(t,i);if(o<=a){r.textLine=t,r.isTruncated=!1;return}for(var s=0;;s++){if(o<=n||s>=e.maxIterations){t+=e.ellipsis;break}var l=s===0?V9(t,n,e.ascCharWidth,e.cnCharWidth):o>0?Math.floor(t.length*n/o):0;t=t.substr(0,l),o=yn(t,i)}t===""&&(t=e.placeholder),r.textLine=t,r.isTruncated=!0}function V9(r,t,e,a){for(var i=0,n=0,o=r.length;nh&&u){var g=Math.floor(h/s);v=v||d.length>g,d=d.slice(0,g)}if(r&&n&&c!=null)for(var m=V4(c,i,t.ellipsis,{minChar:t.truncateMinChar,placeholder:t.placeholder}),x={},b=0;bs&&z1(e,r.substring(s,u),t,o),z1(e,l[2],t,o,l[1]),s=O1.lastIndex}si){var E=e.lines.length;T>0?(w.tokens=w.tokens.slice(0,T),x(w,C,S),e.lines=e.lines.slice(0,b+1)):e.lines=e.lines.slice(0,b),e.isTruncated=e.isTruncated||e.lines.length0&&h+a.accumWidth>a.width&&(v=t.split(` +`),u=!0),a.accumWidth=h}else{var g=G4(t,l,a.width,a.breakAll,a.accumWidth);a.accumWidth=g.accumWidth+f,c=g.linesWidths,v=g.lines}}else v=t.split(` +`);for(var m=0;m=32&&t<=591||t>=880&&t<=4351||t>=4608&&t<=5119||t>=7680&&t<=8303}var q9=os(",&?/;] ".split(""),function(r,t){return r[t]=!0,r},{});function $9(r){return U9(r)?!!q9[r]:!0}function G4(r,t,e,a,i){for(var n=[],o=[],s="",l="",u=0,v=0,c=0;ce:i+v+f>e){v?(s||l)&&(h?(s||(s=l,l="",u=0,v=u),n.push(s),o.push(v-u),l+=d,u+=f,s="",v=u):(l&&(s+=l,l="",u=0),n.push(s),o.push(v),s=d,v=f)):h?(n.push(l),o.push(u),l=d,u=f):(n.push(d),o.push(f));continue}v+=f,h?(l+=d,u+=f):(l&&(s+=l,l="",u=0),s+=d)}return!n.length&&!s&&(s=r,l="",u=0),l&&(s+=l),s&&(n.push(s),o.push(v)),n.length===1&&(v+=i),{accumWidth:v,lines:n,linesWidths:o}}var Aw="__zr_style_"+Math.round(Math.random()*10),av={shadowBlur:0,shadowOffsetX:0,shadowOffsetY:0,shadowColor:"#000",opacity:1,blend:"source-over"},l_={style:{shadowBlur:!0,shadowOffsetX:!0,shadowOffsetY:!0,shadowColor:!0,opacity:!0}};av[Aw]=!0;var ok=["z","z2","invisible"],Y9=["invisible"],jn=function(r){xa(t,r);function t(e){return r.call(this,e)||this}return t.prototype._init=function(e){for(var a=xr(e),i=0;i1e-4){s[0]=r-e,s[1]=t-a,l[0]=r+e,l[1]=t+a;return}if(Tg[0]=G1(i)*e+r,Tg[1]=F1(i)*a+t,Ag[0]=G1(n)*e+r,Ag[1]=F1(n)*a+t,u(s,Tg,Ag),v(l,Tg,Ag),i=i%su,i<0&&(i=i+su),n=n%su,n<0&&(n=n+su),i>n&&!o?n+=su:ii&&(Cg[0]=G1(f)*e+r,Cg[1]=F1(f)*a+t,u(s,Cg,s),v(l,Cg,l))}var fa={M:1,L:2,C:3,Q:4,A:5,Z:6,R:7},lu=[],uu=[],Eo=[],tl=[],No=[],Oo=[],H1=Math.min,W1=Math.max,vu=Math.cos,cu=Math.sin,ms=Math.abs,Cw=Math.PI,vl=Cw*2,U1=typeof Float32Array<"u",qd=[];function q1(r){var t=Math.round(r/Cw*1e8)/1e8;return t%2*Cw}function kS(r,t){var e=q1(r[0]);e<0&&(e+=vl);var a=e-r[0],i=r[1];i+=a,!t&&i-e>=vl?i=e+vl:t&&e-i>=vl?i=e-vl:!t&&e>i?i=e+(vl-q1(e-i)):t&&e0&&(this._ux=ms(a/Ky/t)||0,this._uy=ms(a/Ky/e)||0)},r.prototype.setDPR=function(t){this.dpr=t},r.prototype.setContext=function(t){this._ctx=t},r.prototype.getContext=function(){return this._ctx},r.prototype.beginPath=function(){return this._ctx&&this._ctx.beginPath(),this.reset(),this},r.prototype.reset=function(){this._saveData&&(this._len=0),this._pathSegLen&&(this._pathSegLen=null,this._pathLen=0),this._version++},r.prototype.moveTo=function(t,e){return this._drawPendingPt(),this.addData(fa.M,t,e),this._ctx&&this._ctx.moveTo(t,e),this._x0=t,this._y0=e,this._xi=t,this._yi=e,this},r.prototype.lineTo=function(t,e){var a=ms(t-this._xi),i=ms(e-this._yi),n=a>this._ux||i>this._uy;if(this.addData(fa.L,t,e),this._ctx&&n&&this._ctx.lineTo(t,e),n)this._xi=t,this._yi=e,this._pendingPtDist=0;else{var o=a*a+i*i;o>this._pendingPtDist&&(this._pendingPtX=t,this._pendingPtY=e,this._pendingPtDist=o)}return this},r.prototype.bezierCurveTo=function(t,e,a,i,n,o){return this._drawPendingPt(),this.addData(fa.C,t,e,a,i,n,o),this._ctx&&this._ctx.bezierCurveTo(t,e,a,i,n,o),this._xi=n,this._yi=o,this},r.prototype.quadraticCurveTo=function(t,e,a,i){return this._drawPendingPt(),this.addData(fa.Q,t,e,a,i),this._ctx&&this._ctx.quadraticCurveTo(t,e,a,i),this._xi=a,this._yi=i,this},r.prototype.arc=function(t,e,a,i,n,o){this._drawPendingPt(),qd[0]=i,qd[1]=n,kS(qd,o),i=qd[0],n=qd[1];var s=n-i;return this.addData(fa.A,t,e,a,a,i,s,0,o?0:1),this._ctx&&this._ctx.arc(t,e,a,i,n,o),this._xi=vu(n)*a+t,this._yi=cu(n)*a+e,this},r.prototype.arcTo=function(t,e,a,i,n){return this._drawPendingPt(),this._ctx&&this._ctx.arcTo(t,e,a,i,n),this},r.prototype.rect=function(t,e,a,i){return this._drawPendingPt(),this._ctx&&this._ctx.rect(t,e,a,i),this.addData(fa.R,t,e,a,i),this},r.prototype.closePath=function(){this._drawPendingPt(),this.addData(fa.Z);var t=this._ctx,e=this._x0,a=this._y0;return t&&t.closePath(),this._xi=e,this._yi=a,this},r.prototype.fill=function(t){t&&t.fill(),this.toStatic()},r.prototype.stroke=function(t){t&&t.stroke(),this.toStatic()},r.prototype.len=function(){return this._len},r.prototype.setData=function(t){var e=t.length;!(this.data&&this.data.length===e)&&U1&&(this.data=new Float32Array(e));for(var a=0;av.length&&(this._expandData(),v=this.data);for(var c=0;c0&&(this._ctx&&this._ctx.lineTo(this._pendingPtX,this._pendingPtY),this._pendingPtDist=0)},r.prototype._expandData=function(){if(!(this.data instanceof Array)){for(var t=[],e=0;e11&&(this.data=new Float32Array(t)))}},r.prototype.getBoundingRect=function(){Eo[0]=Eo[1]=No[0]=No[1]=Number.MAX_VALUE,tl[0]=tl[1]=Oo[0]=Oo[1]=-Number.MAX_VALUE;var t=this.data,e=0,a=0,i=0,n=0,o;for(o=0;oa||ms(w)>i||d===e-1)&&(g=Math.sqrt(b*b+w*w),n=m,o=x);break}case fa.C:{var S=t[d++],C=t[d++],m=t[d++],x=t[d++],T=t[d++],k=t[d++];g=y8(n,o,S,C,m,x,T,k,10),n=T,o=k;break}case fa.Q:{var S=t[d++],C=t[d++],m=t[d++],x=t[d++];g=_8(n,o,S,C,m,x,10),n=m,o=x;break}case fa.A:var D=t[d++],M=t[d++],L=t[d++],I=t[d++],P=t[d++],E=t[d++],N=E+P;d+=1,h&&(s=vu(P)*L+D,l=cu(P)*I+M),g=W1(L,I)*H1(vl,Math.abs(E)),n=vu(N)*L+D,o=cu(N)*I+M;break;case fa.R:{s=n=t[d++],l=o=t[d++];var O=t[d++],V=t[d++];g=O*2+V*2;break}case fa.Z:{var b=s-n,w=l-o;g=Math.sqrt(b*b+w*w),n=s,o=l;break}}g>=0&&(u[c++]=g,v+=g)}return this._pathLen=v,v},r.prototype.rebuildPath=function(t,e){var a=this.data,i=this._ux,n=this._uy,o=this._len,s,l,u,v,c,d,f=e<1,h,g,m=0,x=0,b,w=0,S,C;if(!(f&&(this._pathSegLen||this._calculateLength(),h=this._pathSegLen,g=this._pathLen,b=e*g,!b)))t:for(var T=0;T0&&(t.lineTo(S,C),w=0),k){case fa.M:s=u=a[T++],l=v=a[T++],t.moveTo(u,v);break;case fa.L:{c=a[T++],d=a[T++];var M=ms(c-u),L=ms(d-v);if(M>i||L>n){if(f){var I=h[x++];if(m+I>b){var P=(b-m)/I;t.lineTo(u*(1-P)+c*P,v*(1-P)+d*P);break t}m+=I}t.lineTo(c,d),u=c,v=d,w=0}else{var E=M*M+L*L;E>w&&(S=c,C=d,w=E)}break}case fa.C:{var N=a[T++],O=a[T++],V=a[T++],B=a[T++],z=a[T++],H=a[T++];if(f){var I=h[x++];if(m+I>b){var P=(b-m)/I;Nl(u,N,V,z,P,lu),Nl(v,O,B,H,P,uu),t.bezierCurveTo(lu[1],uu[1],lu[2],uu[2],lu[3],uu[3]);break t}m+=I}t.bezierCurveTo(N,O,V,B,z,H),u=z,v=H;break}case fa.Q:{var N=a[T++],O=a[T++],V=a[T++],B=a[T++];if(f){var I=h[x++];if(m+I>b){var P=(b-m)/I;rh(u,N,V,P,lu),rh(v,O,B,P,uu),t.quadraticCurveTo(lu[1],uu[1],lu[2],uu[2]);break t}m+=I}t.quadraticCurveTo(N,O,V,B),u=V,v=B;break}case fa.A:var Y=a[T++],$=a[T++],Z=a[T++],Q=a[T++],at=a[T++],et=a[T++],_t=a[T++],Ot=!a[T++],xt=Z>Q?Z:Q,mt=ms(Z-Q)>.001,wt=at+et,ft=!1;if(f){var I=h[x++];m+I>b&&(wt=at+et*(b-m)/I,ft=!0),m+=I}if(mt&&t.ellipse?t.ellipse(Y,$,Z,Q,_t,at,wt,Ot):t.arc(Y,$,xt,at,wt,Ot),ft)break t;D&&(s=vu(at)*Z+Y,l=cu(at)*Q+$),u=vu(wt)*Z+Y,v=cu(wt)*Q+$;break;case fa.R:s=u=a[T],l=v=a[T+1],c=a[T++],d=a[T++];var K=a[T++],Ct=a[T++];if(f){var I=h[x++];if(m+I>b){var Mt=b-m;t.moveTo(c,d),t.lineTo(c+H1(Mt,K),d),Mt-=K,Mt>0&&t.lineTo(c+K,d+H1(Mt,Ct)),Mt-=Ct,Mt>0&&t.lineTo(c+W1(K-Mt,0),d+Ct),Mt-=K,Mt>0&&t.lineTo(c,d+W1(Ct-Mt,0));break t}m+=I}t.rect(c,d,K,Ct);break;case fa.Z:if(f){var I=h[x++];if(m+I>b){var P=(b-m)/I;t.lineTo(u*(1-P)+s*P,v*(1-P)+l*P);break t}m+=I}t.closePath(),u=s,v=l}}},r.prototype.clone=function(){var t=new r,e=this.data;return t.data=e.slice?e.slice():Array.prototype.slice.call(e),t._len=this._len,t},r.CMD=fa,r.initDefaultProps=function(){var t=r.prototype;t._saveData=!0,t._ux=0,t._uy=0,t._pendingPtDist=0,t._version=0}(),r}();function dl(r,t,e,a,i,n,o){if(i===0)return!1;var s=i,l=0,u=r;if(o>t+s&&o>a+s||or+s&&n>e+s||nt+c&&v>a+c&&v>n+c&&v>s+c||vr+c&&u>e+c&&u>i+c&&u>o+c||ut+u&&l>a+u&&l>n+u||lr+u&&s>e+u&&s>i+u||se||v+ui&&(i+=$d);var d=Math.atan2(l,s);return d<0&&(d+=$d),d>=a&&d<=i||d+$d>=a&&d+$d<=i}function Ss(r,t,e,a,i,n){if(n>t&&n>a||ni?s:0}var el=vs.CMD,du=Math.PI*2,tH=1e-4;function eH(r,t){return Math.abs(r-t)t&&u>a&&u>n&&u>s||u1&&rH(),f=Ka(t,a,n,s,Pn[0]),d>1&&(h=Ka(t,a,n,s,Pn[1]))),d===2?mt&&s>a&&s>n||s=0&&u<=1){for(var v=0,c=oi(t,a,n,u),d=0;de||s<-e)return 0;var l=Math.sqrt(e*e-s*s);Pi[0]=-l,Pi[1]=l;var u=Math.abs(a-i);if(u<1e-4)return 0;if(u>=du-1e-4){a=0,i=du;var v=n?1:-1;return o>=Pi[0]+r&&o<=Pi[1]+r?v:0}if(a>i){var c=a;a=i,i=c}a<0&&(a+=du,i+=du);for(var d=0,f=0;f<2;f++){var h=Pi[f];if(h+r>o){var g=Math.atan2(s,h),v=n?1:-1;g<0&&(g=du+g),(g>=a&&g<=i||g+du>=a&&g+du<=i)&&(g>Math.PI/2&&g1&&(e||(s+=Ss(l,u,v,c,a,i))),m&&(l=n[h],u=n[h+1],v=l,c=u),g){case el.M:v=n[h++],c=n[h++],l=v,u=c;break;case el.L:if(e){if(dl(l,u,n[h],n[h+1],t,a,i))return!0}else s+=Ss(l,u,n[h],n[h+1],a,i)||0;l=n[h++],u=n[h++];break;case el.C:if(e){if(Q9(l,u,n[h++],n[h++],n[h++],n[h++],n[h],n[h+1],t,a,i))return!0}else s+=aH(l,u,n[h++],n[h++],n[h++],n[h++],n[h],n[h+1],a,i)||0;l=n[h++],u=n[h++];break;case el.Q:if(e){if(H4(l,u,n[h++],n[h++],n[h],n[h+1],t,a,i))return!0}else s+=iH(l,u,n[h++],n[h++],n[h],n[h+1],a,i)||0;l=n[h++],u=n[h++];break;case el.A:var x=n[h++],b=n[h++],w=n[h++],S=n[h++],C=n[h++],T=n[h++];h+=1;var k=!!(1-n[h++]);d=Math.cos(C)*w+x,f=Math.sin(C)*S+b,m?(v=d,c=f):s+=Ss(l,u,d,f,a,i);var D=(a-x)*S/w+x;if(e){if(J9(x,b,S,C,C+T,k,t,D,i))return!0}else s+=nH(x,b,S,C,C+T,k,D,i);l=Math.cos(C+T)*w+x,u=Math.sin(C+T)*S+b;break;case el.R:v=l=n[h++],c=u=n[h++];var M=n[h++],L=n[h++];if(d=v+M,f=c+L,e){if(dl(v,c,d,c,t,a,i)||dl(d,c,d,f,t,a,i)||dl(d,f,v,f,t,a,i)||dl(v,f,v,c,t,a,i))return!0}else s+=Ss(d,c,d,f,a,i),s+=Ss(v,f,v,c,a,i);break;case el.Z:if(e){if(dl(l,u,v,c,t,a,i))return!0}else s+=Ss(l,u,v,c,a,i);l=v,u=c;break}}return!e&&!eH(u,c)&&(s+=Ss(l,u,v,c,a,i)||0),s!==0}function oH(r,t,e){return W4(r,0,!1,t,e)}function sH(r,t,e,a){return W4(r,t,!0,e,a)}var Jy=ve({fill:"#000",stroke:null,strokePercent:1,fillOpacity:1,strokeOpacity:1,lineDashOffset:0,lineWidth:1,lineCap:"butt",miterLimit:10,strokeNoScale:!1,strokeFirst:!1},av),lH={style:ve({fill:!0,stroke:!0,strokePercent:!0,fillOpacity:!0,strokeOpacity:!0,lineDashOffset:!0,lineWidth:!0,miterLimit:!0},l_.style)},$1=ls.concat(["invisible","culling","z","z2","zlevel","parent"]),ur=function(r){xa(t,r);function t(e){return r.call(this,e)||this}return t.prototype.update=function(){var e=this;r.prototype.update.call(this);var a=this.style;if(a.decal){var i=this._decalEl=this._decalEl||new t;i.buildPath===t.prototype.buildPath&&(i.buildPath=function(l){e.buildPath(l,e.shape)}),i.silent=!0;var n=i.style;for(var o in a)n[o]!==a[o]&&(n[o]=a[o]);n.fill=a.fill?a.decal:null,n.decal=null,n.shadowColor=null,a.strokeFirst&&(n.stroke=null);for(var s=0;s<$1.length;++s)i[$1[s]]=this[$1[s]];i.__dirty|=dn}else this._decalEl&&(this._decalEl=null)},t.prototype.getDecalElement=function(){return this._decalEl},t.prototype._init=function(e){var a=xr(e);this.shape=this.getDefaultShape();var i=this.getDefaultStyle();i&&this.useStyle(i);for(var n=0;n.5?xw:a>.2?$8:bw}else if(e)return bw}return xw},t.prototype.getInsideTextStroke=function(e){var a=this.style.fill;if(Pt(a)){var i=this.__zr,n=!!(i&&i.isDarkMode()),o=Xy(e,0)<_w;if(n===o)return a}},t.prototype.buildPath=function(e,a,i){},t.prototype.pathUpdated=function(){this.__dirty&=~uc},t.prototype.getUpdatedPathProxy=function(e){return!this.path&&this.createPathProxy(),this.path.beginPath(),this.buildPath(this.path,this.shape,e),this.path},t.prototype.createPathProxy=function(){this.path=new vs(!1)},t.prototype.hasStroke=function(){var e=this.style,a=e.stroke;return!(a==null||a==="none"||!(e.lineWidth>0))},t.prototype.hasFill=function(){var e=this.style,a=e.fill;return a!=null&&a!=="none"},t.prototype.getBoundingRect=function(){var e=this._rect,a=this.style,i=!e;if(i){var n=!1;this.path||(n=!0,this.createPathProxy());var o=this.path;(n||this.__dirty&uc)&&(o.beginPath(),this.buildPath(o,this.shape,!1),this.pathUpdated()),e=o.getBoundingRect()}if(this._rect=e,this.hasStroke()&&this.path&&this.path.len()>0){var s=this._rectStroke||(this._rectStroke=e.clone());if(this.__dirty||i){s.copy(e);var l=a.strokeNoScale?this.getLineScale():1,u=a.lineWidth;if(!this.hasFill()){var v=this.strokeContainThreshold;u=Math.max(u,v??4)}l>1e-10&&(s.width+=u/l,s.height+=u/l,s.x-=u/l/2,s.y-=u/l/2)}return s}return e},t.prototype.contain=function(e,a){var i=this.transformCoordToLocal(e,a),n=this.getBoundingRect(),o=this.style;if(e=i[0],a=i[1],n.contain(e,a)){var s=this.path;if(this.hasStroke()){var l=o.lineWidth,u=o.strokeNoScale?this.getLineScale():1;if(u>1e-10&&(this.hasFill()||(l=Math.max(l,this.strokeContainThreshold)),sH(s,l/u,e,a)))return!0}if(this.hasFill())return oH(s,e,a)}return!1},t.prototype.dirtyShape=function(){this.__dirty|=uc,this._rect&&(this._rect=null),this._decalEl&&this._decalEl.dirtyShape(),this.markRedraw()},t.prototype.dirty=function(){this.dirtyStyle(),this.dirtyShape()},t.prototype.animateShape=function(e){return this.animate("shape",e)},t.prototype.updateDuringAnimation=function(e){e==="style"?this.dirtyStyle():e==="shape"?this.dirtyShape():this.markRedraw()},t.prototype.attrKV=function(e,a){e==="shape"?this.setShape(a):r.prototype.attrKV.call(this,e,a)},t.prototype.setShape=function(e,a){var i=this.shape;return i||(i=this.shape={}),typeof e=="string"?i[e]=a:lt(i,e),this.dirtyShape(),this},t.prototype.shapeChanged=function(){return!!(this.__dirty&uc)},t.prototype.createStyle=function(e){return e_(Jy,e)},t.prototype._innerSaveToNormal=function(e){r.prototype._innerSaveToNormal.call(this,e);var a=this._normalState;e.shape&&!a.shape&&(a.shape=lt({},this.shape))},t.prototype._applyStateObj=function(e,a,i,n,o,s){r.prototype._applyStateObj.call(this,e,a,i,n,o,s);var l=!(a&&n),u;if(a&&a.shape?o?n?u=a.shape:(u=lt({},i.shape),lt(u,a.shape)):(u=lt({},n?this.shape:i.shape),lt(u,a.shape)):l&&(u=i.shape),u)if(o){this.shape=lt({},this.shape);for(var v={},c=xr(u),d=0;d0},t.prototype.hasFill=function(){var e=this.style,a=e.fill;return a!=null&&a!=="none"},t.prototype.createStyle=function(e){return e_(uH,e)},t.prototype.setBoundingRect=function(e){this._rect=e},t.prototype.getBoundingRect=function(){var e=this.style;if(!this._rect){var a=e.text;a!=null?a+="":a="";var i=Nh(a,e.font,e.textAlign,e.textBaseline);if(i.x+=e.x||0,i.y+=e.y||0,this.hasStroke()){var n=e.lineWidth;i.x-=n/2,i.y-=n/2,i.width+=n,i.height+=n}this._rect=i}return this._rect},t.initDefaultProps=function(){var e=t.prototype;e.dirtyRectTolerance=10}(),t}(jn);qc.prototype.type="tspan";var vH=ve({x:0,y:0},av),cH={style:ve({x:!0,y:!0,width:!0,height:!0,sx:!0,sy:!0,sWidth:!0,sHeight:!0},l_.style)};function dH(r){return!!(r&&typeof r!="string"&&r.width&&r.height)}var ui=function(r){xa(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.createStyle=function(e){return e_(vH,e)},t.prototype._getSize=function(e){var a=this.style,i=a[e];if(i!=null)return i;var n=dH(a.image)?a.image:this.__image;if(!n)return 0;var o=e==="width"?"height":"width",s=a[o];return s==null?n[e]:n[e]/n[o]*s},t.prototype.getWidth=function(){return this._getSize("width")},t.prototype.getHeight=function(){return this._getSize("height")},t.prototype.getAnimationStyleProps=function(){return cH},t.prototype.getBoundingRect=function(){var e=this.style;return this._rect||(this._rect=new er(e.x||0,e.y||0,this.getWidth(),this.getHeight())),this._rect},t}(jn);ui.prototype.type="image";function fH(r,t){var e=t.x,a=t.y,i=t.width,n=t.height,o=t.r,s,l,u,v;i<0&&(e=e+i,i=-i),n<0&&(a=a+n,n=-n),typeof o=="number"?s=l=u=v=o:o instanceof Array?o.length===1?s=l=u=v=o[0]:o.length===2?(s=u=o[0],l=v=o[1]):o.length===3?(s=o[0],l=v=o[1],u=o[2]):(s=o[0],l=o[1],u=o[2],v=o[3]):s=l=u=v=0;var c;s+l>i&&(c=s+l,s*=i/c,l*=i/c),u+v>i&&(c=u+v,u*=i/c,v*=i/c),l+u>n&&(c=l+u,l*=n/c,u*=n/c),s+v>n&&(c=s+v,s*=n/c,v*=n/c),r.moveTo(e+s,a),r.lineTo(e+i-l,a),l!==0&&r.arc(e+i-l,a+l,l,-Math.PI/2,0),r.lineTo(e+i,a+n-u),u!==0&&r.arc(e+i-u,a+n-u,u,0,Math.PI/2),r.lineTo(e+v,a+n),v!==0&&r.arc(e+v,a+n-v,v,Math.PI/2,Math.PI),r.lineTo(e,a+s),s!==0&&r.arc(e+s,a+s,s,Math.PI,Math.PI*1.5)}var gc=Math.round;function U4(r,t,e){if(t){var a=t.x1,i=t.x2,n=t.y1,o=t.y2;r.x1=a,r.x2=i,r.y1=n,r.y2=o;var s=e&&e.lineWidth;return s&&(gc(a*2)===gc(i*2)&&(r.x1=r.x2=Vu(a,s,!0)),gc(n*2)===gc(o*2)&&(r.y1=r.y2=Vu(n,s,!0))),r}}function q4(r,t,e){if(t){var a=t.x,i=t.y,n=t.width,o=t.height;r.x=a,r.y=i,r.width=n,r.height=o;var s=e&&e.lineWidth;return s&&(r.x=Vu(a,s,!0),r.y=Vu(i,s,!0),r.width=Math.max(Vu(a+n,s,!1)-r.x,n===0?0:1),r.height=Math.max(Vu(i+o,s,!1)-r.y,o===0?0:1)),r}}function Vu(r,t,e){if(!t)return r;var a=gc(r*2);return(a+gc(t))%2===0?a/2:(a+(e?1:-1))/2}var hH=function(){function r(){this.x=0,this.y=0,this.width=0,this.height=0}return r}(),pH={},Mr=function(r){xa(t,r);function t(e){return r.call(this,e)||this}return t.prototype.getDefaultShape=function(){return new hH},t.prototype.buildPath=function(e,a){var i,n,o,s;if(this.subPixelOptimize){var l=q4(pH,a,this.style);i=l.x,n=l.y,o=l.width,s=l.height,l.r=a.r,a=l}else i=a.x,n=a.y,o=a.width,s=a.height;a.r?fH(e,a):e.rect(i,n,o,s)},t.prototype.isZeroArea=function(){return!this.shape.width||!this.shape.height},t}(ur);Mr.prototype.type="rect";var ck={fill:"#000"},dk=2,gH={style:ve({fill:!0,stroke:!0,fillOpacity:!0,strokeOpacity:!0,lineWidth:!0,fontSize:!0,lineHeight:!0,width:!0,height:!0,textShadowColor:!0,textShadowBlur:!0,textShadowOffsetX:!0,textShadowOffsetY:!0,backgroundColor:!0,padding:!0,borderColor:!0,borderWidth:!0,borderRadius:!0},l_.style)},Rr=function(r){xa(t,r);function t(e){var a=r.call(this)||this;return a.type="text",a._children=[],a._defaultStyle=ck,a.attr(e),a}return t.prototype.childrenRef=function(){return this._children},t.prototype.update=function(){r.prototype.update.call(this),this.styleChanged()&&this._updateSubTexts();for(var e=0;e0,P=e.width!=null&&(e.overflow==="truncate"||e.overflow==="break"||e.overflow==="breakAll"),E=o.calculatedLineHeight,N=0;N=0&&(N=T[E],N.align==="right");)this._placeToken(N,e,D,x,P,"right",w),M-=N.width,P-=N.width,E--;for(I+=(n-(I-m)-(b-P)-M)/2;L<=E;)N=T[L],this._placeToken(N,e,D,x,I+N.width/2,"center",w),I+=N.width,L++;x+=D}},t.prototype._placeToken=function(e,a,i,n,o,s,l){var u=a.rich[e.styleName]||{};u.text=e.text;var v=e.verticalAlign,c=n+i/2;v==="top"?c=n+e.height/2:v==="bottom"&&(c=n+i-e.height/2);var d=!e.isLineHolder&&Y1(u);d&&this._renderBackground(u,a,s==="right"?o-e.width:s==="center"?o-e.width/2:o,c-e.height/2,e.width,e.height);var f=!!u.backgroundColor,h=e.textPadding;h&&(o=mk(o,s,h),c-=e.height/2-h[0]-e.innerHeight/2);var g=this._getOrCreateChild(qc),m=g.createStyle();g.useStyle(m);var x=this._defaultStyle,b=!1,w=0,S=yk("fill"in u?u.fill:"fill"in a?a.fill:(b=!0,x.fill)),C=gk("stroke"in u?u.stroke:"stroke"in a?a.stroke:!f&&!l&&(!x.autoStroke||b)?(w=dk,x.stroke):null),T=u.textShadowBlur>0||a.textShadowBlur>0;m.text=e.text,m.x=o,m.y=c,T&&(m.shadowBlur=u.textShadowBlur||a.textShadowBlur||0,m.shadowColor=u.textShadowColor||a.textShadowColor||"transparent",m.shadowOffsetX=u.textShadowOffsetX||a.textShadowOffsetX||0,m.shadowOffsetY=u.textShadowOffsetY||a.textShadowOffsetY||0),m.textAlign=s,m.textBaseline="middle",m.font=e.font||Pl,m.opacity=as(u.opacity,a.opacity,1),hk(m,u),C&&(m.lineWidth=as(u.lineWidth,a.lineWidth,w),m.lineDash=Be(u.lineDash,a.lineDash),m.lineDashOffset=a.lineDashOffset||0,m.stroke=C),S&&(m.fill=S);var k=e.contentWidth,D=e.contentHeight;g.setBoundingRect(new er(xf(m.x,k,m.textAlign),vc(m.y,D,m.textBaseline),k,D))},t.prototype._renderBackground=function(e,a,i,n,o,s){var l=e.backgroundColor,u=e.borderWidth,v=e.borderColor,c=l&&l.image,d=l&&!c,f=e.borderRadius,h=this,g,m;if(d||e.lineHeight||u&&v){g=this._getOrCreateChild(Mr),g.useStyle(g.createStyle()),g.style.fill=null;var x=g.shape;x.x=i,x.y=n,x.width=o,x.height=s,x.r=f,g.dirtyShape()}if(d){var b=g.style;b.fill=l||null,b.fillOpacity=Be(e.fillOpacity,1)}else if(c){m=this._getOrCreateChild(ui),m.onload=function(){h.dirtyStyle()};var w=m.style;w.image=l.image,w.x=i,w.y=n,w.width=o,w.height=s}if(u&&v){var b=g.style;b.lineWidth=u,b.stroke=v,b.strokeOpacity=Be(e.strokeOpacity,1),b.lineDash=e.borderDash,b.lineDashOffset=e.borderDashOffset||0,g.strokeContainThreshold=0,g.hasFill()&&g.hasStroke()&&(b.strokeFirst=!0,b.lineWidth*=2)}var S=(g||m).style;S.shadowBlur=e.shadowBlur||0,S.shadowColor=e.shadowColor||"transparent",S.shadowOffsetX=e.shadowOffsetX||0,S.shadowOffsetY=e.shadowOffsetY||0,S.opacity=as(e.opacity,a.opacity,1)},t.makeFont=function(e){var a="";return Y4(e)&&(a=[e.fontStyle,e.fontWeight,$4(e.fontSize),e.fontFamily||"sans-serif"].join(" ")),a&&_o(a)||e.textFont||e.font},t}(jn),yH={left:!0,right:1,center:1},mH={top:1,bottom:1,middle:1},fk=["fontStyle","fontWeight","fontSize","fontFamily"];function $4(r){return typeof r=="string"&&(r.indexOf("px")!==-1||r.indexOf("rem")!==-1||r.indexOf("em")!==-1)?r:isNaN(+r)?uS+"px":r+"px"}function hk(r,t){for(var e=0;e=0,n=!1;if(r instanceof ur){var o=Z4(r),s=i&&o.selectFill||o.normalFill,l=i&&o.selectStroke||o.normalStroke;if(qv(s)||qv(l)){a=a||{};var u=a.style||{};u.fill==="inherit"?(n=!0,a=lt({},a),u=lt({},u),u.fill=s):!qv(u.fill)&&qv(s)?(n=!0,a=lt({},a),u=lt({},u),u.fill=dw(s)):!qv(u.stroke)&&qv(l)&&(n||(a=lt({},a),u=lt({},u)),u.stroke=dw(l)),a.style=u}}if(a&&a.z2==null){n||(a=lt({},a));var v=r.z2EmphasisLift;a.z2=r.z2+(v??od)}return a}function AH(r,t,e){if(e&&e.z2==null){e=lt({},e);var a=r.z2SelectLift;e.z2=r.z2+(a??xH)}return e}function CH(r,t,e){var a=ir(r.currentStates,t)>=0,i=r.style.opacity,n=a?null:SH(r,["opacity"],t,{opacity:1});e=e||{};var o=e.style||{};return o.opacity==null&&(e=lt({},e),o=lt({opacity:a?i:n.opacity*.1},o),e.style=o),e}function Z1(r,t){var e=this.states[r];if(this.style){if(r==="emphasis")return TH(this,r,t,e);if(r==="blur")return CH(this,r,e);if(r==="select")return AH(this,r,e)}return e}function mv(r){r.stateProxy=Z1;var t=r.getTextContent(),e=r.getTextGuideLine();t&&(t.stateProxy=Z1),e&&(e.stateProxy=Z1)}function Sk(r,t){!e5(r,t)&&!r.__highByOuter&&qs(r,X4)}function Tk(r,t){!e5(r,t)&&!r.__highByOuter&&qs(r,j4)}function Bs(r,t){r.__highByOuter|=1<<(t||0),qs(r,X4)}function Vs(r,t){!(r.__highByOuter&=~(1<<(t||0)))&&qs(r,j4)}function Q4(r){qs(r,LS)}function IS(r){qs(r,K4)}function J4(r){qs(r,bH)}function t5(r){qs(r,wH)}function e5(r,t){return r.__highDownSilentOnTouch&&t.zrByTouch}function r5(r){var t=r.getModel(),e=[],a=[];t.eachComponent(function(i,n){var o=DS(n),s=i==="series",l=s?r.getViewOfSeriesModel(n):r.getViewOfComponentModel(n);!s&&a.push(l),o.isBlured&&(l.group.traverse(function(u){K4(u)}),s&&e.push(n)),o.isBlured=!1}),R(a,function(i){i&&i.toggleBlurSeries&&i.toggleBlurSeries(e,!1,t)})}function Dw(r,t,e,a){var i=a.getModel();e=e||"coordinateSystem";function n(u,v){for(var c=0;c0){var s={dataIndex:o,seriesIndex:e.seriesIndex};n!=null&&(s.dataType=n),t.push(s)}})}),t}function nv(r,t,e){Fu(r,!0),qs(r,mv),Lw(r,t,e)}function RH(r){Fu(r,!1)}function Ia(r,t,e,a){a?RH(r):nv(r,t,e)}function Lw(r,t,e){var a=Ie(r);t!=null?(a.focus=t,a.blurScope=e):a.focus&&(a.focus=null)}var Ck=["emphasis","blur","select"],PH={itemStyle:"getItemStyle",lineStyle:"getLineStyle",areaStyle:"getAreaStyle"};function yi(r,t,e,a){e=e||"itemStyle";for(var i=0;i1&&(o*=X1(h),s*=X1(h));var g=(i===n?-1:1)*X1((o*o*(s*s)-o*o*(f*f)-s*s*(d*d))/(o*o*(f*f)+s*s*(d*d)))||0,m=g*o*f/s,x=g*-s*d/o,b=(r+e)/2+Dg(c)*m-kg(c)*x,w=(t+a)/2+kg(c)*m+Dg(c)*x,S=Lk([1,0],[(d-m)/o,(f-x)/s]),C=[(d-m)/o,(f-x)/s],T=[(-1*d-m)/o,(-1*f-x)/s],k=Lk(C,T);if(Rw(C,T)<=-1&&(k=Yd),Rw(C,T)>=1&&(k=0),k<0){var D=Math.round(k/Yd*1e6)/1e6;k=Yd*2+D%2*Yd}v.addData(u,b,w,o,s,S,k,c,n)}var VH=/([mlvhzcqtsa])([^mlvhzcqtsa]*)/ig,FH=/-?([0-9]*\.)?[0-9]+([eE]-?[0-9]+)?/g;function GH(r){var t=new vs;if(!r)return t;var e=0,a=0,i=e,n=a,o,s=vs.CMD,l=r.match(VH);if(!l)return t;for(var u=0;uN*N+O*O&&(D=L,M=I),{cx:D,cy:M,x0:-v,y0:-c,x1:D*(i/C-1),y1:M*(i/C-1)}}function ZH(r){var t;if(ht(r)){var e=r.length;if(!e)return r;e===1?t=[r[0],r[0],0,0]:e===2?t=[r[0],r[0],r[1],r[1]]:e===3?t=r.concat(r[2]):t=r}else t=[r,r,r,r];return t}function XH(r,t){var e,a=bf(t.r,0),i=bf(t.r0||0,0),n=a>0,o=i>0;if(!(!n&&!o)){if(n||(a=i,i=0),i>a){var s=a;a=i,i=s}var l=t.startAngle,u=t.endAngle;if(!(isNaN(l)||isNaN(u))){var v=t.cx,c=t.cy,d=!!t.clockwise,f=Rk(u-l),h=f>j1&&f%j1;if(h>so&&(f=h),!(a>so))r.moveTo(v,c);else if(f>j1-so)r.moveTo(v+a*Yv(l),c+a*fu(l)),r.arc(v,c,a,l,u,!d),i>so&&(r.moveTo(v+i*Yv(u),c+i*fu(u)),r.arc(v,c,i,u,l,d));else{var g=void 0,m=void 0,x=void 0,b=void 0,w=void 0,S=void 0,C=void 0,T=void 0,k=void 0,D=void 0,M=void 0,L=void 0,I=void 0,P=void 0,E=void 0,N=void 0,O=a*Yv(l),V=a*fu(l),B=i*Yv(u),z=i*fu(u),H=f>so;if(H){var Y=t.cornerRadius;Y&&(e=ZH(Y),g=e[0],m=e[1],x=e[2],b=e[3]);var $=Rk(a-i)/2;if(w=zo($,x),S=zo($,b),C=zo($,g),T=zo($,m),M=k=bf(w,S),L=D=bf(C,T),(k>so||D>so)&&(I=a*Yv(u),P=a*fu(u),E=i*Yv(l),N=i*fu(l),fso){var mt=zo(x,M),wt=zo(b,M),ft=Mg(E,N,O,V,a,mt,d),K=Mg(I,P,B,z,a,wt,d);r.moveTo(v+ft.cx+ft.x0,c+ft.cy+ft.y0),M0&&r.arc(v+ft.cx,c+ft.cy,mt,bi(ft.y0,ft.x0),bi(ft.y1,ft.x1),!d),r.arc(v,c,a,bi(ft.cy+ft.y1,ft.cx+ft.x1),bi(K.cy+K.y1,K.cx+K.x1),!d),wt>0&&r.arc(v+K.cx,c+K.cy,wt,bi(K.y1,K.x1),bi(K.y0,K.x0),!d))}else r.moveTo(v+O,c+V),r.arc(v,c,a,l,u,!d);if(!(i>so)||!H)r.lineTo(v+B,c+z);else if(L>so){var mt=zo(g,L),wt=zo(m,L),ft=Mg(B,z,I,P,i,-wt,d),K=Mg(O,V,E,N,i,-mt,d);r.lineTo(v+ft.cx+ft.x0,c+ft.cy+ft.y0),L0&&r.arc(v+ft.cx,c+ft.cy,wt,bi(ft.y0,ft.x0),bi(ft.y1,ft.x1),!d),r.arc(v,c,i,bi(ft.cy+ft.y1,ft.cx+ft.x1),bi(K.cy+K.y1,K.cx+K.x1),d),mt>0&&r.arc(v+K.cx,c+K.cy,mt,bi(K.y1,K.x1),bi(K.y0,K.x0),!d))}else r.lineTo(v+B,c+z),r.arc(v,c,i,u,l,d)}r.closePath()}}}var jH=function(){function r(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=Math.PI*2,this.clockwise=!0,this.cornerRadius=0}return r}(),Gi=function(r){xa(t,r);function t(e){return r.call(this,e)||this}return t.prototype.getDefaultShape=function(){return new jH},t.prototype.buildPath=function(e,a){XH(e,a)},t.prototype.isZeroArea=function(){return this.shape.startAngle===this.shape.endAngle||this.shape.r===this.shape.r0},t}(ur);Gi.prototype.type="sector";var KH=function(){function r(){this.cx=0,this.cy=0,this.r=0,this.r0=0}return r}(),Bh=function(r){xa(t,r);function t(e){return r.call(this,e)||this}return t.prototype.getDefaultShape=function(){return new KH},t.prototype.buildPath=function(e,a){var i=a.cx,n=a.cy,o=Math.PI*2;e.moveTo(i+a.r,n),e.arc(i,n,a.r,0,o,!1),e.moveTo(i+a.r0,n),e.arc(i,n,a.r0,0,o,!0)},t}(ur);Bh.prototype.type="ring";function QH(r,t,e,a){var i=[],n=[],o=[],s=[],l,u,v,c;if(a){v=[1/0,1/0],c=[-1/0,-1/0];for(var d=0,f=r.length;d=2){if(a){var n=QH(i,a,e,t.smoothConstraint);r.moveTo(i[0][0],i[0][1]);for(var o=i.length,s=0;s<(e?o:o-1);s++){var l=n[s*2],u=n[s*2+1],v=i[(s+1)%o];r.bezierCurveTo(l[0],l[1],u[0],u[1],v[0],v[1])}}else{r.moveTo(i[0][0],i[0][1]);for(var s=1,c=i.length;spu[1]){if(s=!1,n)return s;var v=Math.abs(pu[0]-hu[1]),c=Math.abs(hu[0]-pu[1]);Math.min(v,c)>i.len()&&(v0){var c=v.duration,d=v.delay,f=v.easing,h={duration:c,delay:d||0,easing:f,done:n,force:!!n||!!o,setToFinal:!u,scope:r,during:o};s?t.animateFrom(e,h):t.animateTo(e,h)}else t.stopAnimation(),!s&&t.attr(e),o&&o(1),n&&n()}function Gr(r,t,e,a,i,n){NS("update",r,t,e,a,i,n)}function Ta(r,t,e,a,i,n){NS("enter",r,t,e,a,i,n)}function wc(r){if(!r.__zr)return!0;for(var t=0;tMath.abs(n[1])?n[0]>0?"right":"left":n[1]>0?"bottom":"top"}function Nk(r){return!r.isGroup}function dW(r){return r.shape!=null}function Gh(r,t,e){if(!r||!t)return;function a(o){var s={};return o.traverse(function(l){Nk(l)&&l.anid&&(s[l.anid]=l)}),s}function i(o){var s={x:o.x,y:o.y,rotation:o.rotation};return dW(o)&&(s.shape=lt({},o.shape)),s}var n=a(r);t.traverse(function(o){if(Nk(o)&&o.anid){var s=n[o.anid];if(s){var l=i(o);o.attr(i(s)),Gr(o,l,e,Ie(o).dataIndex)}}})}function g5(r,t){return pt(r,function(e){var a=e[0];a=rm(a,t.x),a=am(a,t.x+t.width);var i=e[1];return i=rm(i,t.y),i=am(i,t.y+t.height),[a,i]})}function fW(r,t){var e=rm(r.x,t.x),a=am(r.x+r.width,t.x+t.width),i=rm(r.y,t.y),n=am(r.y+r.height,t.y+t.height);if(a>=e&&n>=i)return{x:e,y:i,width:a-e,height:n-i}}function Hh(r,t,e){var a=lt({rectHover:!0},t),i=a.style={strokeNoScale:!0};if(e=e||{x:-1,y:-1,width:2,height:2},r)return r.indexOf("image://")===0?(i.image=r.slice(8),ve(i,e),new ui(a)):h_(r.replace("path://",""),a,e,"center")}function wf(r,t,e,a,i){for(var n=0,o=i[i.length-1];n1)return!1;var m=K1(f,h,v,c)/d;return!(m<0||m>1)}function K1(r,t,e,a){return r*a-e*t}function hW(r){return r<=1e-6&&r>=-1e-6}function kv(r){var t=r.itemTooltipOption,e=r.componentModel,a=r.itemName,i=Pt(t)?{formatter:t}:t,n=e.mainType,o=e.componentIndex,s={componentType:n,name:a,$vars:["name"]};s[n+"Index"]=o;var l=r.formatterParamsExtra;l&&R(xr(l),function(v){Vt(s,v)||(s[v]=l[v],s.$vars.push(v))});var u=Ie(r.el);u.componentMainType=n,u.componentIndex=o,u.tooltipConfig={name:a,option:ve({content:a,encodeHTMLContent:!0,formatterParams:s},i)}}function Ok(r,t){var e;r.isGroup&&(e=t(r)),e||r.traverse(t)}function Wl(r,t){if(r)if(ht(r))for(var e=0;e=0&&s.push(l)}),s}}function Ul(r,t){return Ze(Ze({},r,!0),t,!0)}const CW={time:{month:["January","February","March","April","May","June","July","August","September","October","November","December"],monthAbbr:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],dayOfWeekAbbr:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"]},legend:{selector:{all:"All",inverse:"Inv"}},toolbox:{brush:{title:{rect:"Box Select",polygon:"Lasso Select",lineX:"Horizontally Select",lineY:"Vertically Select",keep:"Keep Selections",clear:"Clear Selections"}},dataView:{title:"Data View",lang:["Data View","Close","Refresh"]},dataZoom:{title:{zoom:"Zoom",back:"Zoom Reset"}},magicType:{title:{line:"Switch to Line Chart",bar:"Switch to Bar Chart",stack:"Stack",tiled:"Tile"}},restore:{title:"Restore"},saveAsImage:{title:"Save as Image",lang:["Right Click to Save Image"]}},series:{typeNames:{pie:"Pie chart",bar:"Bar chart",line:"Line chart",scatter:"Scatter plot",effectScatter:"Ripple scatter plot",radar:"Radar chart",tree:"Tree",treemap:"Treemap",boxplot:"Boxplot",candlestick:"Candlestick",k:"K line chart",heatmap:"Heat map",map:"Map",parallel:"Parallel coordinate map",lines:"Line graph",graph:"Relationship graph",sankey:"Sankey diagram",funnel:"Funnel chart",gauge:"Gauge",pictorialBar:"Pictorial bar",themeRiver:"Theme River Map",sunburst:"Sunburst",custom:"Custom chart",chart:"Chart"}},aria:{general:{withTitle:'This is a chart about "{title}"',withoutTitle:"This is a chart"},series:{single:{prefix:"",withName:" with type {seriesType} named {seriesName}.",withoutName:" with type {seriesType}."},multiple:{prefix:". It consists of {seriesCount} series count.",withName:" The {seriesId} series is a {seriesType} representing {seriesName}.",withoutName:" The {seriesId} series is a {seriesType}.",separator:{middle:"",end:""}}},data:{allData:"The data is as follows: ",partialData:"The first {displayCnt} items are: ",withName:"the data for {name} is {value}",withoutName:"{value}",separator:{middle:", ",end:". "}}}},kW={time:{month:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],monthAbbr:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"],dayOfWeekAbbr:["日","一","二","三","四","五","六"]},legend:{selector:{all:"全选",inverse:"反选"}},toolbox:{brush:{title:{rect:"矩形选择",polygon:"圈选",lineX:"横向选择",lineY:"纵向选择",keep:"保持选择",clear:"清除选择"}},dataView:{title:"数据视图",lang:["数据视图","关闭","刷新"]},dataZoom:{title:{zoom:"区域缩放",back:"区域缩放还原"}},magicType:{title:{line:"切换为折线图",bar:"切换为柱状图",stack:"切换为堆叠",tiled:"切换为平铺"}},restore:{title:"还原"},saveAsImage:{title:"保存为图片",lang:["右键另存为图片"]}},series:{typeNames:{pie:"饼图",bar:"柱状图",line:"折线图",scatter:"散点图",effectScatter:"涟漪散点图",radar:"雷达图",tree:"树图",treemap:"矩形树图",boxplot:"箱型图",candlestick:"K线图",k:"K线图",heatmap:"热力图",map:"地图",parallel:"平行坐标图",lines:"线图",graph:"关系图",sankey:"桑基图",funnel:"漏斗图",gauge:"仪表盘图",pictorialBar:"象形柱图",themeRiver:"主题河流图",sunburst:"旭日图",custom:"自定义图表",chart:"图表"}},aria:{general:{withTitle:"这是一个关于“{title}”的图表。",withoutTitle:"这是一个图表,"},series:{single:{prefix:"",withName:"图表类型是{seriesType},表示{seriesName}。",withoutName:"图表类型是{seriesType}。"},multiple:{prefix:"它由{seriesCount}个图表系列组成。",withName:"第{seriesId}个系列是一个表示{seriesName}的{seriesType},",withoutName:"第{seriesId}个系列是一个{seriesType},",separator:{middle:";",end:"。"}}},data:{allData:"其数据是——",partialData:"其中,前{displayCnt}项是——",withName:"{name}的数据是{value}",withoutName:"{value}",separator:{middle:",",end:""}}}};var nm="ZH",BS="EN",Sc=BS,Py={},VS={},S5=dr.domSupported?function(){var r=(document.documentElement.lang||navigator.language||navigator.browserLanguage||Sc).toUpperCase();return r.indexOf(nm)>-1?nm:Sc}():Sc;function T5(r,t){r=r.toUpperCase(),VS[r]=new ia(t),Py[r]=t}function DW(r){if(Pt(r)){var t=Py[r.toUpperCase()]||{};return r===nm||r===BS?be(t):Ze(be(t),be(Py[Sc]),!1)}else return Ze(be(r),be(Py[Sc]),!1)}function Nw(r){return VS[r]}function MW(){return VS[Sc]}T5(BS,CW);T5(nm,kW);var FS=1e3,GS=FS*60,Uf=GS*60,Fn=Uf*24,Gk=Fn*365,Sf={year:"{yyyy}",month:"{MMM}",day:"{d}",hour:"{HH}:{mm}",minute:"{HH}:{mm}",second:"{HH}:{mm}:{ss}",millisecond:"{HH}:{mm}:{ss} {SSS}",none:"{yyyy}-{MM}-{dd} {HH}:{mm}:{ss} {SSS}"},Rg="{yyyy}-{MM}-{dd}",Hk={year:"{yyyy}",month:"{yyyy}-{MM}",day:Rg,hour:Rg+" "+Sf.hour,minute:Rg+" "+Sf.minute,second:Rg+" "+Sf.second,millisecond:Sf.none},tx=["year","month","day","hour","minute","second","millisecond"],A5=["year","half-year","quarter","month","week","half-week","day","half-day","quarter-day","hour","minute","second","millisecond"];function rl(r,t){return r+="","0000".substr(0,t-r.length)+r}function Tc(r){switch(r){case"half-year":case"quarter":return"month";case"week":case"half-week":return"day";case"half-day":case"quarter-day":return"hour";default:return r}}function LW(r){return r===Tc(r)}function IW(r){switch(r){case"year":case"month":return"day";case"millisecond":return"millisecond";default:return"second"}}function y_(r,t,e,a){var i=us(r),n=i[HS(e)](),o=i[Ac(e)]()+1,s=Math.floor((o-1)/3)+1,l=i[m_(e)](),u=i["get"+(e?"UTC":"")+"Day"](),v=i[vh(e)](),c=(v-1)%12+1,d=i[__(e)](),f=i[x_(e)](),h=i[b_(e)](),g=v>=12?"pm":"am",m=g.toUpperCase(),x=a instanceof ia?a:Nw(a||S5)||MW(),b=x.getModel("time"),w=b.get("month"),S=b.get("monthAbbr"),C=b.get("dayOfWeek"),T=b.get("dayOfWeekAbbr");return(t||"").replace(/{a}/g,g+"").replace(/{A}/g,m+"").replace(/{yyyy}/g,n+"").replace(/{yy}/g,rl(n%100+"",2)).replace(/{Q}/g,s+"").replace(/{MMMM}/g,w[o-1]).replace(/{MMM}/g,S[o-1]).replace(/{MM}/g,rl(o,2)).replace(/{M}/g,o+"").replace(/{dd}/g,rl(l,2)).replace(/{d}/g,l+"").replace(/{eeee}/g,C[u]).replace(/{ee}/g,T[u]).replace(/{e}/g,u+"").replace(/{HH}/g,rl(v,2)).replace(/{H}/g,v+"").replace(/{hh}/g,rl(c+"",2)).replace(/{h}/g,c+"").replace(/{mm}/g,rl(d,2)).replace(/{m}/g,d+"").replace(/{ss}/g,rl(f,2)).replace(/{s}/g,f+"").replace(/{SSS}/g,rl(h,3)).replace(/{S}/g,h+"")}function RW(r,t,e,a,i){var n=null;if(Pt(e))n=e;else if(le(e))n=e(r.value,t,{level:r.level});else{var o=lt({},Sf);if(r.level>0)for(var s=0;s=0;--s)if(l[u]){n=l[u];break}n=n||o.none}if(ht(n)){var c=r.level==null?0:r.level>=0?r.level:n.length+r.level;c=Math.min(c,n.length-1),n=n[c]}}return y_(new Date(r.value),n,i,a)}function C5(r,t){var e=us(r),a=e[Ac(t)]()+1,i=e[m_(t)](),n=e[vh(t)](),o=e[__(t)](),s=e[x_(t)](),l=e[b_(t)](),u=l===0,v=u&&s===0,c=v&&o===0,d=c&&n===0,f=d&&i===1,h=f&&a===1;return h?"year":f?"month":d?"day":c?"hour":v?"minute":u?"second":"millisecond"}function Wk(r,t,e){var a=Or(r)?us(r):r;switch(t=t||C5(r,e),t){case"year":return a[HS(e)]();case"half-year":return a[Ac(e)]()>=6?1:0;case"quarter":return Math.floor((a[Ac(e)]()+1)/4);case"month":return a[Ac(e)]();case"day":return a[m_(e)]();case"half-day":return a[vh(e)]()/24;case"hour":return a[vh(e)]();case"minute":return a[__(e)]();case"second":return a[x_(e)]();case"millisecond":return a[b_(e)]()}}function HS(r){return r?"getUTCFullYear":"getFullYear"}function Ac(r){return r?"getUTCMonth":"getMonth"}function m_(r){return r?"getUTCDate":"getDate"}function vh(r){return r?"getUTCHours":"getHours"}function __(r){return r?"getUTCMinutes":"getMinutes"}function x_(r){return r?"getUTCSeconds":"getSeconds"}function b_(r){return r?"getUTCMilliseconds":"getMilliseconds"}function PW(r){return r?"setUTCFullYear":"setFullYear"}function k5(r){return r?"setUTCMonth":"setMonth"}function D5(r){return r?"setUTCDate":"setDate"}function M5(r){return r?"setUTCHours":"setHours"}function L5(r){return r?"setUTCMinutes":"setMinutes"}function I5(r){return r?"setUTCSeconds":"setSeconds"}function R5(r){return r?"setUTCMilliseconds":"setMilliseconds"}function P5(r){if(!D4(r))return Pt(r)?r:"-";var t=(r+"").split(".");return t[0].replace(/(\d{1,3})(?=(?:\d{3})+(?!\d))/g,"$1,")+(t.length>1?"."+t[1]:"")}function E5(r,t){return r=(r||"").toLowerCase().replace(/-(.)/g,function(e,a){return a.toUpperCase()}),t&&r&&(r=r.charAt(0).toUpperCase()+r.slice(1)),r}var vd=pS;function Ow(r,t,e){var a="{yyyy}-{MM}-{dd} {HH}:{mm}:{ss}";function i(v){return v&&_o(v)?v:"-"}function n(v){return!!(v!=null&&!isNaN(v)&&isFinite(v))}var o=t==="time",s=r instanceof Date;if(o||s){var l=o?us(r):r;if(isNaN(+l)){if(s)return"-"}else return y_(l,a,e)}if(t==="ordinal")return Jb(r)?i(r):Or(r)&&n(r)?r+"":"-";var u=zs(r);return n(u)?P5(u):Jb(r)?i(r):typeof r=="boolean"?r+"":"-"}var Uk=["a","b","c","d","e","f","g"],ex=function(r,t){return"{"+r+(t??"")+"}"};function N5(r,t,e){ht(t)||(t=[t]);var a=t.length;if(!a)return"";for(var i=t[0].$vars||[],n=0;n':'';var o=e.markerId||"markerX";return{renderMode:n,content:"{"+o+"|} ",style:i==="subItem"?{width:4,height:4,borderRadius:2,backgroundColor:a}:{width:10,height:10,borderRadius:5,backgroundColor:a}}}function _v(r,t){return t=t||"transparent",Pt(r)?r:pe(r)&&r.colorStops&&(r.colorStops[0]||{}).color||t}function om(r,t){if(t==="_blank"||t==="blank"){var e=window.open();e.opener=null,e.location.href=r}else window.open(r,t)}var Ey=R,O5=["left","right","top","bottom","width","height"],Gu=[["width","left","right"],["height","top","bottom"]];function WS(r,t,e,a,i){var n=0,o=0;a==null&&(a=1/0),i==null&&(i=1/0);var s=0;t.eachChild(function(l,u){var v=l.getBoundingRect(),c=t.childAt(u+1),d=c&&c.getBoundingRect(),f,h;if(r==="horizontal"){var g=v.width+(d?-d.x+v.x:0);f=n+g,f>a||l.newline?(n=0,f=g,o+=s+e,s=v.height):s=Math.max(s,v.height)}else{var m=v.height+(d?-d.y+v.y:0);h=o+m,h>i||l.newline?(n+=s+e,o=0,h=m,s=v.width):s=Math.max(s,v.width)}l.newline||(l.x=n,l.y=o,l.markRedraw(),r==="horizontal"?n=f+e:o=h+e)})}var sv=WS;We(WS,"vertical");We(WS,"horizontal");function OW(r,t,e){var a=t.width,i=t.height,n=Lt(r.left,a),o=Lt(r.top,i),s=Lt(r.right,a),l=Lt(r.bottom,i);return(isNaN(n)||isNaN(parseFloat(r.left)))&&(n=0),(isNaN(s)||isNaN(parseFloat(r.right)))&&(s=a),(isNaN(o)||isNaN(parseFloat(r.top)))&&(o=0),(isNaN(l)||isNaN(parseFloat(r.bottom)))&&(l=i),e=vd(e||0),{width:Math.max(s-n-e[1]-e[3],0),height:Math.max(l-o-e[0]-e[2],0)}}function Xa(r,t,e){e=vd(e||0);var a=t.width,i=t.height,n=Lt(r.left,a),o=Lt(r.top,i),s=Lt(r.right,a),l=Lt(r.bottom,i),u=Lt(r.width,a),v=Lt(r.height,i),c=e[2]+e[0],d=e[1]+e[3],f=r.aspect;switch(isNaN(u)&&(u=a-s-d-n),isNaN(v)&&(v=i-l-c-o),f!=null&&(isNaN(u)&&isNaN(v)&&(f>a/i?u=a*.8:v=i*.8),isNaN(u)&&(u=f*v),isNaN(v)&&(v=u/f)),isNaN(n)&&(n=a-s-u-d),isNaN(o)&&(o=i-l-v-c),r.left||r.right){case"center":n=a/2-u/2-e[3];break;case"right":n=a-u-d;break}switch(r.top||r.bottom){case"middle":case"center":o=i/2-v/2-e[0];break;case"bottom":o=i-v-c;break}n=n||0,o=o||0,isNaN(u)&&(u=a-d-n-(s||0)),isNaN(v)&&(v=i-c-o-(l||0));var h=new er(n+e[3],o+e[0],u,v);return h.margin=e,h}function w_(r,t,e,a,i,n){var o=!i||!i.hv||i.hv[0],s=!i||!i.hv||i.hv[1],l=i&&i.boundingMode||"all";if(n=n||r,n.x=r.x,n.y=r.y,!o&&!s)return!1;var u;if(l==="raw")u=r.type==="group"?new er(0,0,+t.width||0,+t.height||0):r.getBoundingRect();else if(u=r.getBoundingRect(),r.needLocalTransform()){var v=r.getLocalTransform();u=u.clone(),u.applyTransform(v)}var c=Xa(ve({width:u.width,height:u.height},t),e,a),d=o?c.x-u.x:0,f=s?c.y-u.y:0;return l==="raw"?(n.x=d,n.y=f):(n.x+=d,n.y+=f),n===r&&r.markRedraw(),!0}function zW(r,t){return r[Gu[t][0]]!=null||r[Gu[t][1]]!=null&&r[Gu[t][2]]!=null}function ch(r){var t=r.layoutMode||r.constructor.layoutMode;return pe(t)?t:t?{type:t}:null}function zl(r,t,e){var a=e&&e.ignoreSize;!ht(a)&&(a=[a,a]);var i=o(Gu[0],0),n=o(Gu[1],1);u(Gu[0],r,i),u(Gu[1],r,n);function o(v,c){var d={},f=0,h={},g=0,m=2;if(Ey(v,function(w){h[w]=r[w]}),Ey(v,function(w){s(t,w)&&(d[w]=h[w]=t[w]),l(d,w)&&f++,l(h,w)&&g++}),a[c])return l(t,v[1])?h[v[2]]=null:l(t,v[2])&&(h[v[1]]=null),h;if(g===m||!f)return h;if(f>=m)return d;for(var x=0;x=0;l--)s=Ze(s,i[l],!0);a.defaultOption=s}return a.defaultOption},t.prototype.getReferringComponents=function(e,a){var i=e+"Index",n=e+"Id";return Oh(this.ecModel,e,{index:this.get(i,!0),id:this.get(n,!0)},a)},t.prototype.getBoxLayoutParams=function(){var e=this;return{left:e.get("left"),top:e.get("top"),right:e.get("right"),bottom:e.get("bottom"),width:e.get("width"),height:e.get("height")}},t.prototype.getZLevelKey=function(){return""},t.prototype.setZLevel=function(e){this.option.zlevel=e},t.protoInitialize=function(){var e=t.prototype;e.type="component",e.id="",e.name="",e.mainType="",e.subType="",e.componentIndex=0}(),t}(ia);B4(_r,ia);o_(_r);TW(_r);AW(_r,VW);function VW(r){var t=[];return R(_r.getClassesByMainType(r),function(e){t=t.concat(e.dependencies||e.prototype.dependencies||[])}),t=pt(t,function(e){return Jo(e).main}),r!=="dataset"&&ir(t,"dataset")<=0&&t.unshift("dataset"),t}var B5="";typeof navigator<"u"&&(B5=navigator.platform||"");var Zv="rgba(0, 0, 0, 0.2)";const FW={darkMode:"auto",colorBy:"series",color:["#5470c6","#91cc75","#fac858","#ee6666","#73c0de","#3ba272","#fc8452","#9a60b4","#ea7ccc"],gradientColor:["#f6efa6","#d88273","#bf444c"],aria:{decal:{decals:[{color:Zv,dashArrayX:[1,0],dashArrayY:[2,5],symbolSize:1,rotation:Math.PI/6},{color:Zv,symbol:"circle",dashArrayX:[[8,8],[0,8,8,0]],dashArrayY:[6,0],symbolSize:.8},{color:Zv,dashArrayX:[1,0],dashArrayY:[4,3],rotation:-Math.PI/4},{color:Zv,dashArrayX:[[6,6],[0,6,6,0]],dashArrayY:[6,0]},{color:Zv,dashArrayX:[[1,0],[1,6]],dashArrayY:[1,0,6,0],rotation:Math.PI/4},{color:Zv,symbol:"triangle",dashArrayX:[[9,9],[0,9,9,0]],dashArrayY:[7,2],symbolSize:.75}]}},textStyle:{fontFamily:B5.match(/^Win/)?"Microsoft YaHei":"sans-serif",fontSize:12,fontStyle:"normal",fontWeight:"normal"},blendMode:null,stateAnimation:{duration:300,easing:"cubicOut"},animation:"auto",animationDuration:1e3,animationDurationUpdate:500,animationEasing:"cubicInOut",animationEasingUpdate:"cubicInOut",animationThreshold:2e3,progressiveThreshold:3e3,progressive:400,hoverLayerThreshold:3e3,useUTC:!1};var V5=Wt(["tooltip","label","itemName","itemId","itemGroupId","itemChildGroupId","seriesName"]),Qn="original",Di="arrayRows",Jn="objectRows",cs="keyedColumns",Ml="typedArray",F5="unknown",is="column",dd="row",hi={Must:1,Might:2,Not:3},G5=Lr();function GW(r){G5(r).datasetMap=Wt()}function H5(r,t,e){var a={},i=qS(t);if(!i||!r)return a;var n=[],o=[],s=t.ecModel,l=G5(s).datasetMap,u=i.uid+"_"+e.seriesLayoutBy,v,c;r=r.slice(),R(r,function(g,m){var x=pe(g)?g:r[m]={name:g};x.type==="ordinal"&&v==null&&(v=m,c=h(x)),a[x.name]=[]});var d=l.get(u)||l.set(u,{categoryWayDim:c,valueWayDim:0});R(r,function(g,m){var x=g.name,b=h(g);if(v==null){var w=d.valueWayDim;f(a[x],w,b),f(o,w,b),d.valueWayDim+=b}else if(v===m)f(a[x],0,b),f(n,0,b);else{var w=d.categoryWayDim;f(a[x],w,b),f(o,w,b),d.categoryWayDim+=b}});function f(g,m,x){for(var b=0;bt)return r[a];return r[e-1]}function q5(r,t,e,a,i,n,o){n=n||r;var s=t(n),l=s.paletteIdx||0,u=s.paletteNameMap=s.paletteNameMap||{};if(u.hasOwnProperty(i))return u[i];var v=o==null||!a?e:$W(a,o);if(v=v||e,!(!v||!v.length)){var c=v[l];return i&&(u[i]=c),s.paletteIdx=(l+1)%v.length,c}}function YW(r,t){t(r).paletteIdx=0,t(r).paletteNameMap={}}var Pg,Zd,$k,Yk="\0_ec_inner",ZW=1,YS=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.init=function(e,a,i,n,o,s){n=n||{},this.option=null,this._theme=new ia(n),this._locale=new ia(o),this._optionManager=s},t.prototype.setOption=function(e,a,i){var n=jk(a);this._optionManager.setOption(e,i,n),this._resetOption(null,n)},t.prototype.resetOption=function(e,a){return this._resetOption(e,jk(a))},t.prototype._resetOption=function(e,a){var i=!1,n=this._optionManager;if(!e||e==="recreate"){var o=n.mountOption(e==="recreate");!this.option||e==="recreate"?$k(this,o):(this.restoreData(),this._mergeOption(o,a)),i=!0}if((e==="timeline"||e==="media")&&this.restoreData(),!e||e==="recreate"||e==="timeline"){var s=n.getTimelineOption(this);s&&(i=!0,this._mergeOption(s,a))}if(!e||e==="recreate"||e==="media"){var l=n.getMediaOption(this);l.length&&R(l,function(u){i=!0,this._mergeOption(u,a)},this)}return i},t.prototype.mergeOption=function(e){this._mergeOption(e,null)},t.prototype._mergeOption=function(e,a){var i=this.option,n=this._componentsMap,o=this._componentsCount,s=[],l=Wt(),u=a&&a.replaceMergeMainTypeMap;GW(this),R(e,function(c,d){c!=null&&(_r.hasClass(d)?d&&(s.push(d),l.set(d,!0)):i[d]=i[d]==null?be(c):Ze(i[d],c,!0))}),u&&u.each(function(c,d){_r.hasClass(d)&&!l.get(d)&&(s.push(d),l.set(d,!0))}),_r.topologicalTravel(s,_r.getAllClassMainTypes(),v,this);function v(c){var d=UW(this,c,la(e[c])),f=n.get(c),h=f?u&&u.get(c)?"replaceMerge":"normalMerge":"replaceAll",g=P4(f,d,h);_9(g,c,_r),i[c]=null,n.set(c,null),o.set(c,0);var m=[],x=[],b=0,w;R(g,function(S,C){var T=S.existing,k=S.newOption;if(!k)T&&(T.mergeOption({},this),T.optionUpdated({},!1));else{var D=c==="series",M=_r.getClass(c,S.keyInfo.subType,!D);if(!M)return;if(c==="tooltip"){if(w)return;w=!0}if(T&&T.constructor===M)T.name=S.keyInfo.name,T.mergeOption(k,this),T.optionUpdated(k,!1);else{var L=lt({componentIndex:C},S.keyInfo);T=new M(k,this,this,L),lt(T,L),S.brandNew&&(T.__requireNewView=!0),T.init(k,this,this),T.optionUpdated(null,!0)}}T?(m.push(T.option),x.push(T),b++):(m.push(void 0),x.push(void 0))},this),i[c]=m,n.set(c,x),o.set(c,b),c==="series"&&Pg(this)}this._seriesIndices||Pg(this)},t.prototype.getOption=function(){var e=be(this.option);return R(e,function(a,i){if(_r.hasClass(i)){for(var n=la(a),o=n.length,s=!1,l=o-1;l>=0;l--)n[l]&&!oh(n[l])?s=!0:(n[l]=null,!s&&o--);n.length=o,e[i]=n}}),delete e[Yk],e},t.prototype.getTheme=function(){return this._theme},t.prototype.getLocaleModel=function(){return this._locale},t.prototype.setUpdatePayload=function(e){this._payload=e},t.prototype.getUpdatePayload=function(){return this._payload},t.prototype.getComponent=function(e,a){var i=this._componentsMap.get(e);if(i){var n=i[a||0];if(n)return n;if(a==null){for(var o=0;o=t:e==="max"?r<=t:r===t}function aU(r,t){return r.join(",")===t.join(",")}var no=R,dh=pe,Kk=["areaStyle","lineStyle","nodeStyle","linkStyle","chordStyle","label","labelLine"];function ax(r){var t=r&&r.itemStyle;if(t)for(var e=0,a=Kk.length;e=0;m--){var x=r[m];if(s||(h=x.data.rawIndexOf(x.stackedByDimension,f)),h>=0){var b=x.data.getByRawIndex(x.stackResultDimension,h);if(l==="all"||l==="positive"&&b>0||l==="negative"&&b<0||l==="samesign"&&d>=0&&b>0||l==="samesign"&&d<=0&&b<0){d=l9(d,b),g=b;break}}}return a[0]=d,a[1]=g,a})})}var S_=function(){function r(t){this.data=t.data||(t.sourceFormat===cs?{}:[]),this.sourceFormat=t.sourceFormat||F5,this.seriesLayoutBy=t.seriesLayoutBy||is,this.startIndex=t.startIndex||0,this.dimensionsDetectedCount=t.dimensionsDetectedCount,this.metaRawOption=t.metaRawOption;var e=this.dimensionsDefine=t.dimensionsDefine;if(e)for(var a=0;ag&&(g=w)}f[0]=h,f[1]=g}},i=function(){return this._data?this._data.length/this._dimSize:0};iD=(t={},t[Di+"_"+is]={pure:!0,appendData:n},t[Di+"_"+dd]={pure:!0,appendData:function(){throw new Error('Do not support appendData when set seriesLayoutBy: "row".')}},t[Jn]={pure:!0,appendData:n},t[cs]={pure:!0,appendData:function(o){var s=this._data;R(o,function(l,u){for(var v=s[u]||(s[u]=[]),c=0;c<(l||[]).length;c++)v.push(l[c])})}},t[Qn]={appendData:n},t[Ml]={persistent:!1,pure:!0,appendData:function(o){this._data=o},clean:function(){this._offset+=this.count(),this._data=null}},t);function n(o){for(var s=0;s=0&&(g=o.interpolatedValue[m])}return g!=null?g+"":""})}},r.prototype.getRawValue=function(t,e){return Yc(this.getData(e),t)},r.prototype.formatTooltip=function(t,e,a){},r}();function lD(r){var t,e;return pe(r)?r.type&&(e=r):t=r,{text:t,frag:e}}function qf(r){return new xU(r)}var xU=function(){function r(t){t=t||{},this._reset=t.reset,this._plan=t.plan,this._count=t.count,this._onDirty=t.onDirty,this._dirty=!0}return r.prototype.perform=function(t){var e=this._upstream,a=t&&t.skip;if(this._dirty&&e){var i=this.context;i.data=i.outputData=e.context.outputData}this.__pipeline&&(this.__pipeline.currentTask=this);var n;this._plan&&!a&&(n=this._plan(this.context));var o=v(this._modBy),s=this._modDataCount||0,l=v(t&&t.modBy),u=t&&t.modDataCount||0;(o!==l||s!==u)&&(n="reset");function v(b){return!(b>=1)&&(b=1),b}var c;(this._dirty||n==="reset")&&(this._dirty=!1,c=this._doReset(a)),this._modBy=l,this._modDataCount=u;var d=t&&t.step;if(e?this._dueEnd=e._outputDueEnd:this._dueEnd=this._count?this._count(this.context):1/0,this._progress){var f=this._dueIndex,h=Math.min(d!=null?this._dueIndex+d:1/0,this._dueEnd);if(!a&&(c||f1&&a>0?s:o}};return n;function o(){return t=r?null:lt},gte:function(r,t){return r>=t}},wU=function(){function r(t,e){if(!Or(e)){var a="";Jr(a)}this._opFn=r3[t],this._rvalFloat=zs(e)}return r.prototype.evaluate=function(t){return Or(t)?this._opFn(t,this._rvalFloat):this._opFn(zs(t),this._rvalFloat)},r}(),a3=function(){function r(t,e){var a=t==="desc";this._resultLT=a?1:-1,e==null&&(e=a?"min":"max"),this._incomparable=e==="min"?-1/0:1/0}return r.prototype.evaluate=function(t,e){var a=Or(t)?t:zs(t),i=Or(e)?e:zs(e),n=isNaN(a),o=isNaN(i);if(n&&(a=this._incomparable),o&&(i=this._incomparable),n&&o){var s=Pt(t),l=Pt(e);s&&(a=l?t:0),l&&(i=s?e:0)}return ai?-this._resultLT:0},r}(),SU=function(){function r(t,e){this._rval=e,this._isEQ=t,this._rvalTypeof=typeof e,this._rvalFloat=zs(e)}return r.prototype.evaluate=function(t){var e=t===this._rval;if(!e){var a=typeof t;a!==this._rvalTypeof&&(a==="number"||this._rvalTypeof==="number")&&(e=zs(t)===this._rvalFloat)}return this._isEQ?e:!e},r}();function TU(r,t){return r==="eq"||r==="ne"?new SU(r==="eq",t):Vt(r3,r)?new wU(r,t):null}var AU=function(){function r(){}return r.prototype.getRawData=function(){throw new Error("not supported")},r.prototype.getRawDataItem=function(t){throw new Error("not supported")},r.prototype.cloneRawData=function(){},r.prototype.getDimensionInfo=function(t){},r.prototype.cloneAllDimensionInfo=function(){},r.prototype.count=function(){},r.prototype.retrieveValue=function(t,e){},r.prototype.retrieveValueFromItem=function(t,e){},r.prototype.convertValue=function(t,e){return Ll(t,e)},r}();function CU(r,t){var e=new AU,a=r.data,i=e.sourceFormat=r.sourceFormat,n=r.startIndex,o="";r.seriesLayoutBy!==is&&Jr(o);var s=[],l={},u=r.dimensionsDefine;if(u)R(u,function(g,m){var x=g.name,b={index:m,name:x,displayName:g.displayName};if(s.push(b),x!=null){var w="";Vt(l,x)&&Jr(w),l[x]=b}});else for(var v=0;v65535?EU:NU}function jv(){return[1/0,-1/0]}function OU(r){var t=r.constructor;return t===Array?r.slice():new t(r)}function cD(r,t,e,a,i){var n=o3[e||"float"];if(i){var o=r[t],s=o&&o.length;if(s!==a){for(var l=new n(a),u=0;um[1]&&(m[1]=g)}return this._rawCount=this._count=l,{start:s,end:l}},r.prototype._initDataFromProvider=function(t,e,a){for(var i=this._provider,n=this._chunks,o=this._dimensions,s=o.length,l=this._rawExtent,u=pt(o,function(b){return b.property}),v=0;vx[1]&&(x[1]=m)}}!i.persistent&&i.clean&&i.clean(),this._rawCount=this._count=e,this._extent=[]},r.prototype.count=function(){return this._count},r.prototype.get=function(t,e){if(!(e>=0&&e=0&&e=this._rawCount||t<0)return-1;if(!this._indices)return t;var e=this._indices,a=e[t];if(a!=null&&at)n=o-1;else return o}return-1},r.prototype.indicesOfNearest=function(t,e,a){var i=this._chunks,n=i[t],o=[];if(!n)return o;a==null&&(a=1/0);for(var s=1/0,l=-1,u=0,v=0,c=this.count();v=0&&l<0)&&(s=h,l=f,u=0),f===l&&(o[u++]=v))}return o.length=u,o},r.prototype.getIndices=function(){var t,e=this._indices;if(e){var a=e.constructor,i=this._count;if(a===Array){t=new a(i);for(var n=0;n=c&&b<=d||isNaN(b))&&(l[u++]=g),g++}h=!0}else if(n===2){for(var m=f[i[0]],w=f[i[1]],S=t[i[1]][0],C=t[i[1]][1],x=0;x=c&&b<=d||isNaN(b))&&(T>=S&&T<=C||isNaN(T))&&(l[u++]=g),g++}h=!0}}if(!h)if(n===1)for(var x=0;x=c&&b<=d||isNaN(b))&&(l[u++]=k)}else for(var x=0;xt[L][1])&&(D=!1)}D&&(l[u++]=e.getRawIndex(x))}return ux[1]&&(x[1]=m)}}}},r.prototype.lttbDownSample=function(t,e){var a=this.clone([t],!0),i=a._chunks,n=i[t],o=this.count(),s=0,l=Math.floor(1/e),u=this.getRawIndex(0),v,c,d,f=new(Xv(this._rawCount))(Math.min((Math.ceil(o/l)+2)*2,o));f[s++]=u;for(var h=1;hv&&(v=c,d=S)}I>0&&Is&&(g=s-v);for(var m=0;mh&&(h=b,f=v+m)}var w=this.getRawIndex(c),S=this.getRawIndex(f);cv-h&&(l=v-h,s.length=l);for(var g=0;gc[1]&&(c[1]=x),d[f++]=b}return n._count=f,n._indices=d,n._updateGetRawIdx(),n},r.prototype.each=function(t,e){if(this._count)for(var a=t.length,i=this._chunks,n=0,o=this.count();nl&&(l=c)}return o=[s,l],this._extent[t]=o,o},r.prototype.getRawDataItem=function(t){var e=this.getRawIndex(t);if(this._provider.persistent)return this._provider.getItem(e);for(var a=[],i=this._chunks,n=0;n=0?this._indices[t]:-1},r.prototype._updateGetRawIdx=function(){this.getRawIndex=this._indices?this._getRawIdx:this._getRawIdxIdentity},r.internalField=function(){function t(e,a,i,n){return Ll(e[n],this._dimensions[n])}ox={arrayRows:t,objectRows:function(e,a,i,n){return Ll(e[a],this._dimensions[n])},keyedColumns:t,original:function(e,a,i,n){var o=e&&(e.value==null?e:e.value);return Ll(o instanceof Array?o[n]:o,this._dimensions[n])},typedArray:function(e,a,i,n){return e[n]}}}(),r}(),s3=function(){function r(t){this._sourceList=[],this._storeList=[],this._upstreamSignList=[],this._versionSignBase=0,this._dirty=!0,this._sourceHost=t}return r.prototype.dirty=function(){this._setLocalSource([],[]),this._storeList=[],this._dirty=!0},r.prototype._setLocalSource=function(t,e){this._sourceList=t,this._upstreamSignList=e,this._versionSignBase++,this._versionSignBase>9e10&&(this._versionSignBase=0)},r.prototype._getVersionSign=function(){return this._sourceHost.uid+"_"+this._versionSignBase},r.prototype.prepareSource=function(){this._isDirty()&&(this._createSource(),this._dirty=!1)},r.prototype._createSource=function(){this._setLocalSource([],[]);var t=this._sourceHost,e=this._getUpstreamSourceManagers(),a=!!e.length,i,n;if(Eg(t)){var o=t,s=void 0,l=void 0,u=void 0;if(a){var v=e[0];v.prepareSource(),u=v.getSource(),s=u.data,l=u.sourceFormat,n=[v._getVersionSign()]}else s=o.get("data",!0),l=rn(s)?Ml:Qn,n=[];var c=this._getSourceMetaRawOption()||{},d=u&&u.metaRawOption||{},f=Be(c.seriesLayoutBy,d.seriesLayoutBy)||null,h=Be(c.sourceHeader,d.sourceHeader),g=Be(c.dimensions,d.dimensions),m=f!==d.seriesLayoutBy||!!h!=!!d.sourceHeader||g;i=m?[Vw(s,{seriesLayoutBy:f,sourceHeader:h,dimensions:g},l)]:[]}else{var x=t;if(a){var b=this._applyTransform(e);i=b.sourceList,n=b.upstreamSignList}else{var w=x.get("source",!0);i=[Vw(w,this._getSourceMetaRawOption(),null)],n=[]}}this._setLocalSource(i,n)},r.prototype._applyTransform=function(t){var e=this._sourceHost,a=e.get("transform",!0),i=e.get("fromTransformResult",!0);if(i!=null){var n="";t.length!==1&&fD(n)}var o,s=[],l=[];return R(t,function(u){u.prepareSource();var v=u.getSource(i||0),c="";i!=null&&!v&&fD(c),s.push(v),l.push(u._getVersionSign())}),a?o=RU(a,s,{datasetIndex:e.componentIndex}):i!=null&&(o=[fU(s[0])]),{sourceList:o,upstreamSignList:l}},r.prototype._isDirty=function(){if(this._dirty)return!0;for(var t=this._getUpstreamSourceManagers(),e=0;e1||e>0&&!r.noHeader;return R(r.blocks,function(i){var n=c3(i);n>=t&&(t=n+ +(a&&(!n||Gw(i)&&!i.noHeader)))}),t}return 0}function FU(r,t,e,a){var i=t.noHeader,n=HU(c3(t)),o=[],s=t.blocks||[];Vi(!s||ht(s)),s=s||[];var l=r.orderMode;if(t.sortBlocks&&l){s=s.slice();var u={valueAsc:"asc",valueDesc:"desc"};if(Vt(u,l)){var v=new a3(u[l],null);s.sort(function(g,m){return v.evaluate(g.sortParam,m.sortParam)})}else l==="seriesDesc"&&s.reverse()}R(s,function(g,m){var x=t.valueFormatter,b=v3(g)(x?lt(lt({},r),{valueFormatter:x}):r,g,m>0?n.html:0,a);b!=null&&o.push(b)});var c=r.renderMode==="richText"?o.join(n.richText):Hw(a,o.join(""),i?e:n.html);if(i)return c;var d=Ow(t.header,"ordinal",r.useUTC),f=u3(a,r.renderMode).nameStyle,h=l3(a);return r.renderMode==="richText"?d3(r,d,f)+n.richText+c:Hw(a,'
'+Ni(d)+"
"+c,e)}function GU(r,t,e,a){var i=r.renderMode,n=t.noName,o=t.noValue,s=!t.markerType,l=t.name,u=r.useUTC,v=t.valueFormatter||r.valueFormatter||function(S){return S=ht(S)?S:[S],pt(S,function(C,T){return Ow(C,ht(f)?f[T]:f,u)})};if(!(n&&o)){var c=s?"":r.markupStyleCreator.makeTooltipMarker(t.markerType,t.markerColor||"#333",i),d=n?"":Ow(l,"ordinal",u),f=t.valueType,h=o?[]:v(t.value,t.dataIndex),g=!s||!n,m=!s&&n,x=u3(a,i),b=x.nameStyle,w=x.valueStyle;return i==="richText"?(s?"":c)+(n?"":d3(r,d,b))+(o?"":qU(r,h,g,m,w)):Hw(a,(s?"":c)+(n?"":WU(d,!s,b))+(o?"":UU(h,g,m,w)),e)}}function hD(r,t,e,a,i,n){if(r){var o=v3(r),s={useUTC:i,renderMode:e,orderMode:a,markupStyleCreator:t,valueFormatter:r.valueFormatter};return o(s,r,0,n)}}function HU(r){return{html:BU[r],richText:VU[r]}}function Hw(r,t,e){var a='
',i="margin: "+e+"px 0 0",n=l3(r);return'
'+t+a+"
"}function WU(r,t,e){var a=t?"margin-left:2px":"";return''+Ni(r)+""}function UU(r,t,e,a){var i=e?"10px":"20px",n=t?"float:right;margin-left:"+i:"";return r=ht(r)?r:[r],''+pt(r,function(o){return Ni(o)}).join("  ")+""}function d3(r,t,e){return r.markupStyleCreator.wrapRichTextStyle(t,e)}function qU(r,t,e,a,i){var n=[i],o=a?10:20;return e&&n.push({padding:[0,0,0,o],align:"right"}),r.markupStyleCreator.wrapRichTextStyle(ht(t)?t.join(" "):t,n)}function f3(r,t){var e=r.getData().getItemVisual(t,"style"),a=e[r.visualDrawType];return _v(a)}function h3(r,t){var e=r.get("padding");return e??(t==="richText"?[8,10]:10)}var sx=function(){function r(){this.richTextStyles={},this._nextStyleNameId=M4()}return r.prototype._generateStyleName=function(){return"__EC_aUTo_"+this._nextStyleNameId++},r.prototype.makeTooltipMarker=function(t,e,a){var i=a==="richText"?this._generateStyleName():null,n=NW({color:e,type:t,renderMode:a,markerId:i});return Pt(n)?n:(this.richTextStyles[i]=n.style,n.content)},r.prototype.wrapRichTextStyle=function(t,e){var a={};ht(e)?R(e,function(n){return lt(a,n)}):lt(a,e);var i=this._generateStyleName();return this.richTextStyles[i]=a,"{"+i+"|"+t+"}"},r}();function p3(r){var t=r.series,e=r.dataIndex,a=r.multipleSeries,i=t.getData(),n=i.mapDimensionsAll("defaultedTooltip"),o=n.length,s=t.getRawValue(e),l=ht(s),u=f3(t,e),v,c,d,f;if(o>1||l&&!o){var h=$U(s,t,e,n,u);v=h.inlineValues,c=h.inlineValueTypes,d=h.blocks,f=h.inlineValues[0]}else if(o){var g=i.getDimensionInfo(n[0]);f=v=Yc(i,e,n[0]),c=g.type}else f=v=l?s[0]:s;var m=SS(t),x=m&&t.name||"",b=i.getName(e),w=a?x:b;return ii("section",{header:x,noHeader:a||!m,sortParam:f,blocks:[ii("nameValue",{markerType:"item",markerColor:u,name:w,noName:!_o(w),value:v,valueType:c,dataIndex:e})].concat(d||[])})}function $U(r,t,e,a,i){var n=t.getData(),o=os(r,function(c,d,f){var h=n.getDimensionInfo(f);return c=c||h&&h.tooltip!==!1&&h.displayName!=null},!1),s=[],l=[],u=[];a.length?R(a,function(c){v(Yc(n,e,c),c)}):R(r,v);function v(c,d){var f=n.getDimensionInfo(d);!f||f.otherDims.tooltip===!1||(o?u.push(ii("nameValue",{markerType:"subItem",markerColor:i,name:f.displayName,value:c,valueType:f.type})):(s.push(c),l.push(f.type)))}return{inlineValues:s,inlineValueTypes:l,blocks:u}}var al=Lr();function Ng(r,t){return r.getName(t)||r.getId(t)}var Ny="__universalTransitionEnabled",ma=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e._selectedDataIndicesMap={},e}return t.prototype.init=function(e,a,i){this.seriesIndex=this.componentIndex,this.dataTask=qf({count:ZU,reset:XU}),this.dataTask.context={model:this},this.mergeDefaultAndTheme(e,i);var n=al(this).sourceManager=new s3(this);n.prepareSource();var o=this.getInitialData(e,i);gD(o,this),this.dataTask.context.data=o,al(this).dataBeforeProcessed=o,pD(this),this._initSelectedMapFromData(o)},t.prototype.mergeDefaultAndTheme=function(e,a){var i=ch(this),n=i?cd(e):{},o=this.subType;_r.hasClass(o)&&(o+="Series"),Ze(e,a.getTheme().get(this.subType)),Ze(e,this.getDefaultOption()),pv(e,"label",["show"]),this.fillDataTextStyle(e.data),i&&zl(e,n,i)},t.prototype.mergeOption=function(e,a){e=Ze(this.option,e,!0),this.fillDataTextStyle(e.data);var i=ch(this);i&&zl(this.option,e,i);var n=al(this).sourceManager;n.dirty(),n.prepareSource();var o=this.getInitialData(e,a);gD(o,this),this.dataTask.dirty(),this.dataTask.context.data=o,al(this).dataBeforeProcessed=o,pD(this),this._initSelectedMapFromData(o)},t.prototype.fillDataTextStyle=function(e){if(e&&!rn(e))for(var a=["show"],i=0;ithis.getShallow("animationThreshold")&&(a=!1),!!a},t.prototype.restoreData=function(){this.dataTask.dirty()},t.prototype.getColorFromPalette=function(e,a,i){var n=this.ecModel,o=$S.prototype.getColorFromPalette.call(this,e,a,i);return o||(o=n.getColorFromPalette(e,a,i)),o},t.prototype.coordDimToDataDim=function(e){return this.getRawData().mapDimensionsAll(e)},t.prototype.getProgressive=function(){return this.get("progressive")},t.prototype.getProgressiveThreshold=function(){return this.get("progressiveThreshold")},t.prototype.select=function(e,a){this._innerSelect(this.getData(a),e)},t.prototype.unselect=function(e,a){var i=this.option.selectedMap;if(i){var n=this.option.selectedMode,o=this.getData(a);if(n==="series"||i==="all"){this.option.selectedMap={},this._selectedDataIndicesMap={};return}for(var s=0;s=0&&i.push(o)}return i},t.prototype.isSelected=function(e,a){var i=this.option.selectedMap;if(!i)return!1;var n=this.getData(a);return(i==="all"||i[Ng(n,e)])&&!n.getItemModel(e).get(["select","disabled"])},t.prototype.isUniversalTransitionEnabled=function(){if(this[Ny])return!0;var e=this.option.universalTransition;return e?e===!0?!0:e&&e.enabled:!1},t.prototype._innerSelect=function(e,a){var i,n,o=this.option,s=o.selectedMode,l=a.length;if(!(!s||!l)){if(s==="series")o.selectedMap="all";else if(s==="multiple"){pe(o.selectedMap)||(o.selectedMap={});for(var u=o.selectedMap,v=0;v0&&this._innerSelect(e,a)}},t.registerClass=function(e){return _r.registerClass(e)},t.protoInitialize=function(){var e=t.prototype;e.type="series.__base__",e.seriesIndex=0,e.ignoreStyleOnData=!1,e.hasSymbolVisual=!1,e.defaultSymbol="circle",e.visualStyleAccessPath="itemStyle",e.visualDrawType="fill"}(),t}(_r);$a(ma,T_);$a(ma,$S);B4(ma,_r);function pD(r){var t=r.name;SS(r)||(r.name=YU(r)||t)}function YU(r){var t=r.getRawData(),e=t.mapDimensionsAll("seriesName"),a=[];return R(e,function(i){var n=t.getDimensionInfo(i);n.displayName&&a.push(n.displayName)}),a.join(" ")}function ZU(r){return r.model.getRawData().count()}function XU(r){var t=r.model;return t.setData(t.getRawData().cloneShallow()),jU}function jU(r,t){t.outputData&&r.end>t.outputData.count()&&t.model.getRawData().cloneShallow(t.outputData)}function gD(r,t){R(eh(r.CHANGABLE_METHODS,r.DOWNSAMPLE_METHODS),function(e){r.wrapMethod(e,We(KU,t))})}function KU(r,t){var e=Ww(r);return e&&e.setOutputEnd((t||this).count()),t}function Ww(r){var t=(r.ecModel||{}).scheduler,e=t&&t.getPipeline(r.uid);if(e){var a=e.currentTask;if(a){var i=a.agentStubMap;i&&(a=i.get(r.uid))}return a}}var Ma=function(){function r(){this.group=new Ae,this.uid=ud("viewComponent")}return r.prototype.init=function(t,e){},r.prototype.render=function(t,e,a,i){},r.prototype.dispose=function(t,e){},r.prototype.updateView=function(t,e,a,i){},r.prototype.updateLayout=function(t,e,a,i){},r.prototype.updateVisual=function(t,e,a,i){},r.prototype.toggleBlurSeries=function(t,e,a){},r.prototype.eachRendered=function(t){var e=this.group;e&&e.traverse(t)},r}();AS(Ma);o_(Ma);function fd(){var r=Lr();return function(t){var e=r(t),a=t.pipelineContext,i=!!e.large,n=!!e.progressiveRender,o=e.large=!!(a&&a.large),s=e.progressiveRender=!!(a&&a.progressiveRender);return(i!==o||n!==s)&&"reset"}}var g3=Lr(),QU=fd(),ca=function(){function r(){this.group=new Ae,this.uid=ud("viewChart"),this.renderTask=qf({plan:JU,reset:tq}),this.renderTask.context={view:this}}return r.prototype.init=function(t,e){},r.prototype.render=function(t,e,a,i){},r.prototype.highlight=function(t,e,a,i){var n=t.getData(i&&i.dataType);n&&mD(n,i,"emphasis")},r.prototype.downplay=function(t,e,a,i){var n=t.getData(i&&i.dataType);n&&mD(n,i,"normal")},r.prototype.remove=function(t,e){this.group.removeAll()},r.prototype.dispose=function(t,e){},r.prototype.updateView=function(t,e,a,i){this.render(t,e,a,i)},r.prototype.updateLayout=function(t,e,a,i){this.render(t,e,a,i)},r.prototype.updateVisual=function(t,e,a,i){this.render(t,e,a,i)},r.prototype.eachRendered=function(t){Wl(this.group,t)},r.markUpdateMethod=function(t,e){g3(t).updateMethod=e},r.protoInitialize=function(){var t=r.prototype;t.type="chart"}(),r}();function yD(r,t,e){r&&lh(r)&&(t==="emphasis"?Bs:Vs)(r,e)}function mD(r,t,e){var a=gv(r,t),i=t&&t.highlightKey!=null?NH(t.highlightKey):null;a!=null?R(la(a),function(n){yD(r.getItemGraphicEl(n),e,i)}):r.eachItemGraphicEl(function(n){yD(n,e,i)})}AS(ca);o_(ca);function JU(r){return QU(r.model)}function tq(r){var t=r.model,e=r.ecModel,a=r.api,i=r.payload,n=t.pipelineContext.progressiveRender,o=r.view,s=i&&g3(i).updateMethod,l=n?"incrementalPrepareRender":s&&o[s]?s:"render";return l!=="render"&&o[l](t,e,a,i),eq[l]}var eq={incrementalPrepareRender:{progress:function(r,t){t.view.incrementalRender(r,t.model,t.ecModel,t.api,t.payload)}},render:{forceFirstProgress:!0,progress:function(r,t){t.view.render(t.model,t.ecModel,t.api,t.payload)}}},sm="\0__throttleOriginMethod",_D="\0__throttleRate",xD="\0__throttleType";function QS(r,t,e){var a,i=0,n=0,o=null,s,l,u,v;t=t||0;function c(){n=new Date().getTime(),o=null,r.apply(l,u||[])}var d=function(){for(var f=[],h=0;h=0?c():o=setTimeout(c,-s),i=a};return d.clear=function(){o&&(clearTimeout(o),o=null)},d.debounceNextCall=function(f){v=f},d}function hd(r,t,e,a){var i=r[t];if(i){var n=i[sm]||i,o=i[xD],s=i[_D];if(s!==e||o!==a){if(e==null||!a)return r[t]=n;i=r[t]=QS(n,e,a==="debounce"),i[sm]=n,i[xD]=a,i[_D]=e}return i}}function fh(r,t){var e=r[t];e&&e[sm]&&(e.clear&&e.clear(),r[t]=e[sm])}var bD=Lr(),wD={itemStyle:yv(w5,!0),lineStyle:yv(b5,!0)},rq={lineStyle:"stroke",itemStyle:"fill"};function y3(r,t){var e=r.visualStyleMapper||wD[t];return e||(console.warn("Unknown style type '"+t+"'."),wD.itemStyle)}function m3(r,t){var e=r.visualDrawType||rq[t];return e||(console.warn("Unknown style type '"+t+"'."),"fill")}var aq={createOnAllSeries:!0,performRawSeries:!0,reset:function(r,t){var e=r.getData(),a=r.visualStyleAccessPath||"itemStyle",i=r.getModel(a),n=y3(r,a),o=n(i),s=i.getShallow("decal");s&&(e.setVisual("decal",s),s.dirty=!0);var l=m3(r,a),u=o[l],v=le(u)?u:null,c=o.fill==="auto"||o.stroke==="auto";if(!o[l]||v||c){var d=r.getColorFromPalette(r.name,null,t.getSeriesCount());o[l]||(o[l]=d,e.setVisual("colorFromPalette",!0)),o.fill=o.fill==="auto"||le(o.fill)?d:o.fill,o.stroke=o.stroke==="auto"||le(o.stroke)?d:o.stroke}if(e.setVisual("style",o),e.setVisual("drawType",l),!t.isSeriesFiltered(r)&&v)return e.setVisual("colorFromPalette",!1),{dataEach:function(f,h){var g=r.getDataParams(h),m=lt({},o);m[l]=v(g),f.setItemVisual(h,"style",m)}}}},jd=new ia,iq={createOnAllSeries:!0,performRawSeries:!0,reset:function(r,t){if(!(r.ignoreStyleOnData||t.isSeriesFiltered(r))){var e=r.getData(),a=r.visualStyleAccessPath||"itemStyle",i=y3(r,a),n=e.getVisual("drawType");return{dataEach:e.hasItemOption?function(o,s){var l=o.getRawDataItem(s);if(l&&l[a]){jd.option=l[a];var u=i(jd),v=o.ensureUniqueItemVisual(s,"style");lt(v,u),jd.option.decal&&(o.setItemVisual(s,"decal",jd.option.decal),jd.option.decal.dirty=!0),n in u&&o.setItemVisual(s,"colorFromPalette",!1)}}:null}}}},nq={performRawSeries:!0,overallReset:function(r){var t=Wt();r.eachSeries(function(e){var a=e.getColorBy();if(!e.isColorBySeries()){var i=e.type+"-"+a,n=t.get(i);n||(n={},t.set(i,n)),bD(e).scope=n}}),r.eachSeries(function(e){if(!(e.isColorBySeries()||r.isSeriesFiltered(e))){var a=e.getRawData(),i={},n=e.getData(),o=bD(e).scope,s=e.visualStyleAccessPath||"itemStyle",l=m3(e,s);n.each(function(u){var v=n.getRawIndex(u);i[v]=u}),a.each(function(u){var v=i[u],c=n.getItemVisual(v,"colorFromPalette");if(c){var d=n.ensureUniqueItemVisual(v,"style"),f=a.getName(u)||u+"",h=a.count();d[l]=e.getColorFromPalette(f,o,h)}})}})}},Og=Math.PI;function oq(r,t){t=t||{},ve(t,{text:"loading",textColor:"#000",fontSize:12,fontWeight:"normal",fontStyle:"normal",fontFamily:"sans-serif",maskColor:"rgba(255, 255, 255, 0.8)",showSpinner:!0,color:"#5470c6",spinnerRadius:10,lineWidth:5,zlevel:0});var e=new Ae,a=new Mr({style:{fill:t.maskColor},zlevel:t.zlevel,z:1e4});e.add(a);var i=new Rr({style:{text:t.text,fill:t.textColor,fontSize:t.fontSize,fontWeight:t.fontWeight,fontStyle:t.fontStyle,fontFamily:t.fontFamily},zlevel:t.zlevel,z:10001}),n=new Mr({style:{fill:"none"},textContent:i,textConfig:{position:"right",distance:10},zlevel:t.zlevel,z:10001});e.add(n);var o;return t.showSpinner&&(o=new f_({shape:{startAngle:-Og/2,endAngle:-Og/2+.1,r:t.spinnerRadius},style:{stroke:t.color,lineCap:"round",lineWidth:t.lineWidth},zlevel:t.zlevel,z:10001}),o.animateShape(!0).when(1e3,{endAngle:Og*3/2}).start("circularInOut"),o.animateShape(!0).when(1e3,{startAngle:Og*3/2}).delay(300).start("circularInOut"),e.add(o)),e.resize=function(){var s=i.getBoundingRect().width,l=t.showSpinner?t.spinnerRadius:0,u=(r.getWidth()-l*2-(t.showSpinner&&s?10:0)-s)/2-(t.showSpinner&&s?0:5+s/2)+(t.showSpinner?0:s/2)+(s?0:l),v=r.getHeight()/2;t.showSpinner&&o.setShape({cx:u,cy:v}),n.setShape({x:u-l,y:v-l,width:l*2,height:l*2}),a.setShape({x:0,y:0,width:r.getWidth(),height:r.getHeight()})},e.resize(),e}var _3=function(){function r(t,e,a,i){this._stageTaskMap=Wt(),this.ecInstance=t,this.api=e,a=this._dataProcessorHandlers=a.slice(),i=this._visualHandlers=i.slice(),this._allHandlers=a.concat(i)}return r.prototype.restoreData=function(t,e){t.restoreData(e),this._stageTaskMap.each(function(a){var i=a.overallTask;i&&i.dirty()})},r.prototype.getPerformArgs=function(t,e){if(t.__pipeline){var a=this._pipelineMap.get(t.__pipeline.id),i=a.context,n=!e&&a.progressiveEnabled&&(!i||i.progressiveRender)&&t.__idxInPipeline>a.blockIndex,o=n?a.step:null,s=i&&i.modDataCount,l=s!=null?Math.ceil(s/o):null;return{step:o,modBy:l,modDataCount:s}}},r.prototype.getPipeline=function(t){return this._pipelineMap.get(t)},r.prototype.updateStreamModes=function(t,e){var a=this._pipelineMap.get(t.uid),i=t.getData(),n=i.count(),o=a.progressiveEnabled&&e.incrementalPrepareRender&&n>=a.threshold,s=t.get("large")&&n>=t.get("largeThreshold"),l=t.get("progressiveChunkMode")==="mod"?n:null;t.pipelineContext=a.context={progressiveRender:o,modDataCount:l,large:s}},r.prototype.restorePipelines=function(t){var e=this,a=e._pipelineMap=Wt();t.eachSeries(function(i){var n=i.getProgressive(),o=i.uid;a.set(o,{id:o,head:null,tail:null,threshold:i.getProgressiveThreshold(),progressiveEnabled:n&&!(i.preventIncremental&&i.preventIncremental()),blockIndex:-1,step:Math.round(n||700),count:0}),e._pipe(i,i.dataTask)})},r.prototype.prepareStageTasks=function(){var t=this._stageTaskMap,e=this.api.getModel(),a=this.api;R(this._allHandlers,function(i){var n=t.get(i.uid)||t.set(i.uid,{}),o="";Vi(!(i.reset&&i.overallReset),o),i.reset&&this._createSeriesStageTask(i,n,e,a),i.overallReset&&this._createOverallStageTask(i,n,e,a)},this)},r.prototype.prepareView=function(t,e,a,i){var n=t.renderTask,o=n.context;o.model=e,o.ecModel=a,o.api=i,n.__block=!t.incrementalPrepareRender,this._pipe(e,n)},r.prototype.performDataProcessorTasks=function(t,e){this._performStageTasks(this._dataProcessorHandlers,t,e,{block:!0})},r.prototype.performVisualTasks=function(t,e,a){this._performStageTasks(this._visualHandlers,t,e,a)},r.prototype._performStageTasks=function(t,e,a,i){i=i||{};var n=!1,o=this;R(t,function(l,u){if(!(i.visualType&&i.visualType!==l.visualType)){var v=o._stageTaskMap.get(l.uid),c=v.seriesTaskMap,d=v.overallTask;if(d){var f,h=d.agentStubMap;h.each(function(m){s(i,m)&&(m.dirty(),f=!0)}),f&&d.dirty(),o.updatePayload(d,a);var g=o.getPerformArgs(d,i.block);h.each(function(m){m.perform(g)}),d.perform(g)&&(n=!0)}else c&&c.each(function(m,x){s(i,m)&&m.dirty();var b=o.getPerformArgs(m,i.block);b.skip=!l.performRawSeries&&e.isSeriesFiltered(m.context.model),o.updatePayload(m,a),m.perform(b)&&(n=!0)})}});function s(l,u){return l.setDirty&&(!l.dirtyMap||l.dirtyMap.get(u.__pipeline.id))}this.unfinished=n||this.unfinished},r.prototype.performSeriesTasks=function(t){var e;t.eachSeries(function(a){e=a.dataTask.perform()||e}),this.unfinished=e||this.unfinished},r.prototype.plan=function(){this._pipelineMap.each(function(t){var e=t.tail;do{if(e.__block){t.blockIndex=e.__idxInPipeline;break}e=e.getUpstream()}while(e)})},r.prototype.updatePayload=function(t,e){e!=="remain"&&(t.context.payload=e)},r.prototype._createSeriesStageTask=function(t,e,a,i){var n=this,o=e.seriesTaskMap,s=e.seriesTaskMap=Wt(),l=t.seriesType,u=t.getTargetSeries;t.createOnAllSeries?a.eachRawSeries(v):l?a.eachRawSeriesByType(l,v):u&&u(a,i).each(v);function v(c){var d=c.uid,f=s.set(d,o&&o.get(d)||qf({plan:cq,reset:dq,count:hq}));f.context={model:c,ecModel:a,api:i,useClearVisual:t.isVisual&&!t.isLayout,plan:t.plan,reset:t.reset,scheduler:n},n._pipe(c,f)}},r.prototype._createOverallStageTask=function(t,e,a,i){var n=this,o=e.overallTask=e.overallTask||qf({reset:sq});o.context={ecModel:a,api:i,overallReset:t.overallReset,scheduler:n};var s=o.agentStubMap,l=o.agentStubMap=Wt(),u=t.seriesType,v=t.getTargetSeries,c=!0,d=!1,f="";Vi(!t.createOnAllSeries,f),u?a.eachRawSeriesByType(u,h):v?v(a,i).each(h):(c=!1,R(a.getSeries(),h));function h(g){var m=g.uid,x=l.set(m,s&&s.get(m)||(d=!0,qf({reset:lq,onDirty:vq})));x.context={model:g,overallProgress:c},x.agent=o,x.__block=c,n._pipe(g,x)}d&&o.dirty()},r.prototype._pipe=function(t,e){var a=t.uid,i=this._pipelineMap.get(a);!i.head&&(i.head=e),i.tail&&i.tail.pipe(e),i.tail=e,e.__idxInPipeline=i.count++,e.__pipeline=i},r.wrapStageHandler=function(t,e){return le(t)&&(t={overallReset:t,seriesType:pq(t)}),t.uid=ud("stageHandler"),e&&(t.visualType=e),t},r}();function sq(r){r.overallReset(r.ecModel,r.api,r.payload)}function lq(r){return r.overallProgress&&uq}function uq(){this.agent.dirty(),this.getDownstream().dirty()}function vq(){this.agent&&this.agent.dirty()}function cq(r){return r.plan?r.plan(r.model,r.ecModel,r.api,r.payload):null}function dq(r){r.useClearVisual&&r.data.clearAllVisual();var t=r.resetDefines=la(r.reset(r.model,r.ecModel,r.api,r.payload));return t.length>1?pt(t,function(e,a){return x3(a)}):fq}var fq=x3(0);function x3(r){return function(t,e){var a=e.data,i=e.resetDefines[r];if(i&&i.dataEach)for(var n=t.start;n0&&f===u.length-d.length){var h=u.slice(0,f);h!=="data"&&(e.mainType=h,e[d.toLowerCase()]=l,v=!0)}}s.hasOwnProperty(u)&&(a[u]=l,v=!0),v||(i[u]=l)})}return{cptQuery:e,dataQuery:a,otherQuery:i}},r.prototype.filter=function(t,e){var a=this.eventInfo;if(!a)return!0;var i=a.targetEl,n=a.packedEvent,o=a.model,s=a.view;if(!o||!s)return!0;var l=e.cptQuery,u=e.dataQuery;return v(l,o,"mainType")&&v(l,o,"subType")&&v(l,o,"index","componentIndex")&&v(l,o,"name")&&v(l,o,"id")&&v(u,n,"name")&&v(u,n,"dataIndex")&&v(u,n,"dataType")&&(!s.filterForExposedEvent||s.filterForExposedEvent(t,e.otherQuery,i,n));function v(c,d,f,h){return c[f]==null||d[h||f]===c[f]}},r.prototype.afterTrigger=function(){this.eventInfo=null},r}(),Uw=["symbol","symbolSize","symbolRotate","symbolOffset"],CD=Uw.concat(["symbolKeepAspect"]),mq={createOnAllSeries:!0,performRawSeries:!0,reset:function(r,t){var e=r.getData();if(r.legendIcon&&e.setVisual("legendIcon",r.legendIcon),!r.hasSymbolVisual)return;for(var a={},i={},n=!1,o=0;o=0&&Wu(l)?l:.5;var u=r.createRadialGradient(o,s,0,o,s,l);return u}function qw(r,t,e){for(var a=t.type==="radial"?Pq(r,t,e):Rq(r,t,e),i=t.colorStops,n=0;n0)?null:r==="dashed"?[4*t,2*t]:r==="dotted"?[t]:Or(r)?[r]:ht(r)?r:null}function tT(r){var t=r.style,e=t.lineDash&&t.lineWidth>0&&Nq(t.lineDash,t.lineWidth),a=t.lineDashOffset;if(e){var i=t.strokeNoScale&&r.getLineScale?r.getLineScale():1;i&&i!==1&&(e=pt(e,function(n){return n/i}),a/=i)}return[e,a]}var Oq=new vs(!0);function vm(r){var t=r.stroke;return!(t==null||t==="none"||!(r.lineWidth>0))}function kD(r){return typeof r=="string"&&r!=="none"}function cm(r){var t=r.fill;return t!=null&&t!=="none"}function DD(r,t){if(t.fillOpacity!=null&&t.fillOpacity!==1){var e=r.globalAlpha;r.globalAlpha=t.fillOpacity*t.opacity,r.fill(),r.globalAlpha=e}else r.fill()}function MD(r,t){if(t.strokeOpacity!=null&&t.strokeOpacity!==1){var e=r.globalAlpha;r.globalAlpha=t.strokeOpacity*t.opacity,r.stroke(),r.globalAlpha=e}else r.stroke()}function $w(r,t,e){var a=CS(t.image,t.__image,e);if(s_(a)){var i=r.createPattern(a,t.repeat||"repeat");if(typeof DOMMatrix=="function"&&i&&i.setTransform){var n=new DOMMatrix;n.translateSelf(t.x||0,t.y||0),n.rotateSelf(0,0,(t.rotation||0)*wy),n.scaleSelf(t.scaleX||1,t.scaleY||1),i.setTransform(n)}return i}}function zq(r,t,e,a){var i,n=vm(e),o=cm(e),s=e.strokePercent,l=s<1,u=!t.path;(!t.silent||l)&&u&&t.createPathProxy();var v=t.path||Oq,c=t.__dirty;if(!a){var d=e.fill,f=e.stroke,h=o&&!!d.colorStops,g=n&&!!f.colorStops,m=o&&!!d.image,x=n&&!!f.image,b=void 0,w=void 0,S=void 0,C=void 0,T=void 0;(h||g)&&(T=t.getBoundingRect()),h&&(b=c?qw(r,d,T):t.__canvasFillGradient,t.__canvasFillGradient=b),g&&(w=c?qw(r,f,T):t.__canvasStrokeGradient,t.__canvasStrokeGradient=w),m&&(S=c||!t.__canvasFillPattern?$w(r,d,t):t.__canvasFillPattern,t.__canvasFillPattern=S),x&&(C=c||!t.__canvasStrokePattern?$w(r,f,t):t.__canvasStrokePattern,t.__canvasStrokePattern=S),h?r.fillStyle=b:m&&(S?r.fillStyle=S:o=!1),g?r.strokeStyle=w:x&&(C?r.strokeStyle=C:n=!1)}var k=t.getGlobalScale();v.setScale(k[0],k[1],t.segmentIgnoreThreshold);var D,M;r.setLineDash&&e.lineDash&&(i=tT(t),D=i[0],M=i[1]);var L=!0;(u||c&uc)&&(v.setDPR(r.dpr),l?v.setContext(null):(v.setContext(r),L=!1),v.reset(),t.buildPath(v,t.shape,a),v.toStatic(),t.pathUpdated()),L&&v.rebuildPath(r,l?s:1),D&&(r.setLineDash(D),r.lineDashOffset=M),a||(e.strokeFirst?(n&&MD(r,e),o&&DD(r,e)):(o&&DD(r,e),n&&MD(r,e))),D&&r.setLineDash([])}function Bq(r,t,e){var a=t.__image=CS(e.image,t.__image,t,t.onload);if(!(!a||!s_(a))){var i=e.x||0,n=e.y||0,o=t.getWidth(),s=t.getHeight(),l=a.width/a.height;if(o==null&&s!=null?o=s*l:s==null&&o!=null?s=o/l:o==null&&s==null&&(o=a.width,s=a.height),e.sWidth&&e.sHeight){var u=e.sx||0,v=e.sy||0;r.drawImage(a,u,v,e.sWidth,e.sHeight,i,n,o,s)}else if(e.sx&&e.sy){var u=e.sx,v=e.sy,c=o-u,d=s-v;r.drawImage(a,u,v,c,d,i,n,o,s)}else r.drawImage(a,i,n,o,s)}}function Vq(r,t,e){var a,i=e.text;if(i!=null&&(i+=""),i){r.font=e.font||Pl,r.textAlign=e.textAlign,r.textBaseline=e.textBaseline;var n=void 0,o=void 0;r.setLineDash&&e.lineDash&&(a=tT(t),n=a[0],o=a[1]),n&&(r.setLineDash(n),r.lineDashOffset=o),e.strokeFirst?(vm(e)&&r.strokeText(i,e.x,e.y),cm(e)&&r.fillText(i,e.x,e.y)):(cm(e)&&r.fillText(i,e.x,e.y),vm(e)&&r.strokeText(i,e.x,e.y)),n&&r.setLineDash([])}}var LD=["shadowBlur","shadowOffsetX","shadowOffsetY"],ID=[["lineCap","butt"],["lineJoin","miter"],["miterLimit",10]];function C3(r,t,e,a,i){var n=!1;if(!a&&(e=e||{},t===e))return!1;if(a||t.opacity!==e.opacity){tn(r,i),n=!0;var o=Math.max(Math.min(t.opacity,1),0);r.globalAlpha=isNaN(o)?av.opacity:o}(a||t.blend!==e.blend)&&(n||(tn(r,i),n=!0),r.globalCompositeOperation=t.blend||av.blend);for(var s=0;s0&&e.unfinished);e.unfinished||this._zr.flush()}}},t.prototype.getDom=function(){return this._dom},t.prototype.getId=function(){return this.id},t.prototype.getZr=function(){return this._zr},t.prototype.isSSR=function(){return this._ssr},t.prototype.setOption=function(e,a,i){if(!this[wi]){if(this._disposed){this.id;return}var n,o,s;if(pe(a)&&(i=a.lazyUpdate,n=a.silent,o=a.replaceMerge,s=a.transition,a=a.notMerge),this[wi]=!0,!this._model||a){var l=new JW(this._api),u=this._theme,v=this._model=new YS;v.scheduler=this._scheduler,v.ssr=this._ssr,v.init(null,null,null,u,this._locale,l)}this._model.setOption(e,{replaceMerge:o},Zw);var c={seriesTransition:s,optionChanged:!0};if(i)this[Zi]={silent:n,updateParams:c},this[wi]=!1,this.getZr().wakeUp();else{try{Qv(this),il.update.call(this,null,c)}catch(d){throw this[Zi]=null,this[wi]=!1,d}this._ssr||this._zr.flush(),this[Zi]=null,this[wi]=!1,Kd.call(this,n),Qd.call(this,n)}}},t.prototype.setTheme=function(){},t.prototype.getModel=function(){return this._model},t.prototype.getOption=function(){return this._model&&this._model.getOption()},t.prototype.getWidth=function(){return this._zr.getWidth()},t.prototype.getHeight=function(){return this._zr.getHeight()},t.prototype.getDevicePixelRatio=function(){return this._zr.painter.dpr||dr.hasGlobalWindow&&window.devicePixelRatio||1},t.prototype.getRenderedCanvas=function(e){return this.renderToCanvas(e)},t.prototype.renderToCanvas=function(e){e=e||{};var a=this._zr.painter;return a.getRenderedCanvas({backgroundColor:e.backgroundColor||this._model.get("backgroundColor"),pixelRatio:e.pixelRatio||this.getDevicePixelRatio()})},t.prototype.renderToSVGString=function(e){e=e||{};var a=this._zr.painter;return a.renderToString({useViewBox:e.useViewBox})},t.prototype.getSvgDataURL=function(){if(dr.svgSupported){var e=this._zr,a=e.storage.getDisplayList();return R(a,function(i){i.stopAnimation(null,!0)}),e.painter.toDataURL()}},t.prototype.getDataURL=function(e){if(this._disposed){this.id;return}e=e||{};var a=e.excludeComponents,i=this._model,n=[],o=this;R(a,function(l){i.eachComponent({mainType:l},function(u){var v=o._componentsMap[u.__viewId];v.group.ignore||(n.push(v),v.group.ignore=!0)})});var s=this._zr.painter.getType()==="svg"?this.getSvgDataURL():this.renderToCanvas(e).toDataURL("image/"+(e&&e.type||"png"));return R(n,function(l){l.group.ignore=!1}),s},t.prototype.getConnectedDataURL=function(e){if(this._disposed){this.id;return}var a=e.type==="svg",i=this.group,n=Math.min,o=Math.max,s=1/0;if($D[i]){var l=s,u=s,v=-s,c=-s,d=[],f=e&&e.pixelRatio||this.getDevicePixelRatio();R(Yf,function(w,S){if(w.group===i){var C=a?w.getZr().painter.getSvgDom().innerHTML:w.renderToCanvas(be(e)),T=w.getDom().getBoundingClientRect();l=n(T.left,l),u=n(T.top,u),v=o(T.right,v),c=o(T.bottom,c),d.push({dom:C,left:T.left,top:T.top})}}),l*=f,u*=f,v*=f,c*=f;var h=v-l,g=c-u,m=El.createCanvas(),x=KC(m,{renderer:a?"svg":"canvas"});if(x.resize({width:h,height:g}),a){var b="";return R(d,function(w){var S=w.left-l,C=w.top-u;b+=''+w.dom+""}),x.painter.getSvgRoot().innerHTML=b,e.connectedBackgroundColor&&x.painter.setBackgroundColor(e.connectedBackgroundColor),x.refreshImmediately(),x.painter.toDataURL()}else return e.connectedBackgroundColor&&x.add(new Mr({shape:{x:0,y:0,width:h,height:g},style:{fill:e.connectedBackgroundColor}})),R(d,function(w){var S=new ui({style:{x:w.left*f-l,y:w.top*f-u,image:w.dom}});x.add(S)}),x.refreshImmediately(),m.toDataURL("image/"+(e&&e.type||"png"))}else return this.getDataURL(e)},t.prototype.convertToPixel=function(e,a){return dx(this,"convertToPixel",e,a)},t.prototype.convertFromPixel=function(e,a){return dx(this,"convertFromPixel",e,a)},t.prototype.containPixel=function(e,a){if(this._disposed){this.id;return}var i=this._model,n,o=Ff(i,e);return R(o,function(s,l){l.indexOf("Models")>=0&&R(s,function(u){var v=u.coordinateSystem;if(v&&v.containPoint)n=n||!!v.containPoint(a);else if(l==="seriesModels"){var c=this._chartsMap[u.__viewId];c&&c.containPoint&&(n=n||c.containPoint(a,u))}},this)},this),!!n},t.prototype.getVisual=function(e,a){var i=this._model,n=Ff(i,e,{defaultMainType:"series"}),o=n.seriesModel,s=o.getData(),l=n.hasOwnProperty("dataIndexInside")?n.dataIndexInside:n.hasOwnProperty("dataIndex")?s.indexOfRawIndex(n.dataIndex):null;return l!=null?JS(s,l,a):Uh(s,a)},t.prototype.getViewOfComponentModel=function(e){return this._componentsMap[e.__viewId]},t.prototype.getViewOfSeriesModel=function(e){return this._chartsMap[e.__viewId]},t.prototype._initEvents=function(){var e=this;R(v$,function(a){var i=function(n){var o=e.getModel(),s=n.target,l,u=a==="globalout";if(u?l={}:s&&Hu(s,function(h){var g=Ie(h);if(g&&g.dataIndex!=null){var m=g.dataModel||o.getSeriesByIndex(g.seriesIndex);return l=m&&m.getDataParams(g.dataIndex,g.dataType,s)||{},!0}else if(g.eventData)return l=lt({},g.eventData),!0},!0),l){var v=l.componentType,c=l.componentIndex;(v==="markLine"||v==="markPoint"||v==="markArea")&&(v="series",c=l.seriesIndex);var d=v&&c!=null&&o.getComponent(v,c),f=d&&e[d.mainType==="series"?"_chartsMap":"_componentsMap"][d.__viewId];l.event=n,l.type=a,e._$eventProcessor.eventInfo={targetEl:s,packedEvent:l,model:d,view:f},e.trigger(a,l)}};i.zrEventfulCallAtLast=!0,e._zr.on(a,i,e)}),R($f,function(a,i){e._messageCenter.on(i,function(n){this.trigger(i,n)},e)}),R(["selectchanged"],function(a){e._messageCenter.on(a,function(i){this.trigger(a,i)},e)}),xq(this._messageCenter,this,this._api)},t.prototype.isDisposed=function(){return this._disposed},t.prototype.clear=function(){if(this._disposed){this.id;return}this.setOption({series:[]},!0)},t.prototype.dispose=function(){if(this._disposed){this.id;return}this._disposed=!0;var e=this.getDom();e&&N4(this.getDom(),aT,"");var a=this,i=a._api,n=a._model;R(a._componentsViews,function(o){o.dispose(n,i)}),R(a._chartsViews,function(o){o.dispose(n,i)}),a._zr.dispose(),a._dom=a._model=a._chartsMap=a._componentsMap=a._chartsViews=a._componentsViews=a._scheduler=a._api=a._zr=a._throttledZrFlush=a._theme=a._coordSysMgr=a._messageCenter=null,delete Yf[a.id]},t.prototype.resize=function(e){if(!this[wi]){if(this._disposed){this.id;return}this._zr.resize(e);var a=this._model;if(this._loadingFX&&this._loadingFX.resize(),!!a){var i=a.resetOption("media"),n=e&&e.silent;this[Zi]&&(n==null&&(n=this[Zi].silent),i=!0,this[Zi]=null),this[wi]=!0;try{i&&Qv(this),il.update.call(this,{type:"resize",animation:lt({duration:0},e&&e.animation)})}catch(o){throw this[wi]=!1,o}this[wi]=!1,Kd.call(this,n),Qd.call(this,n)}}},t.prototype.showLoading=function(e,a){if(this._disposed){this.id;return}if(pe(e)&&(a=e,e=""),e=e||"default",this.hideLoading(),!!Xw[e]){var i=Xw[e](this._api,a),n=this._zr;this._loadingFX=i,n.add(i)}},t.prototype.hideLoading=function(){if(this._disposed){this.id;return}this._loadingFX&&this._zr.remove(this._loadingFX),this._loadingFX=null},t.prototype.makeActionFromEvent=function(e){var a=lt({},e);return a.type=$f[e.type],a},t.prototype.dispatchAction=function(e,a){if(this._disposed){this.id;return}if(pe(a)||(a={silent:!!a}),!!dm[e.type]&&this._model){if(this[wi]){this._pendingActions.push(e);return}var i=a.silent;hx.call(this,e,i);var n=a.flush;n?this._zr.flush():n!==!1&&dr.browser.weChat&&this._throttledZrFlush(),Kd.call(this,i),Qd.call(this,i)}},t.prototype.updateLabelLayout=function(){vo.trigger("series:layoutlabels",this._model,this._api,{updatedSeries:[]})},t.prototype.appendData=function(e){if(this._disposed){this.id;return}var a=e.seriesIndex,i=this.getModel(),n=i.getSeriesByIndex(a);n.appendData(e),this._scheduler.unfinished=!0,this.getZr().wakeUp()},t.internalField=function(){Qv=function(c){var d=c._scheduler;d.restorePipelines(c._model),d.prepareStageTasks(),cx(c,!0),cx(c,!1),d.plan()},cx=function(c,d){for(var f=c._model,h=c._scheduler,g=d?c._componentsViews:c._chartsViews,m=d?c._componentsMap:c._chartsMap,x=c._zr,b=c._api,w=0;wd.get("hoverLayerThreshold")&&!dr.node&&!dr.worker&&d.eachSeries(function(m){if(!m.preventUsingHoverLayer){var x=c._chartsMap[m.__viewId];x.__alive&&x.eachRendered(function(b){b.states.emphasis&&(b.states.emphasis.hoverLayer=!0)})}})}function o(c,d){var f=c.get("blendMode")||null;d.eachRendered(function(h){h.isGroup||(h.style.blend=f)})}function s(c,d){if(!c.preventAutoZ){var f=c.get("z")||0,h=c.get("zlevel")||0;d.eachRendered(function(g){return l(g,f,h,-1/0),!0})}}function l(c,d,f,h){var g=c.getTextContent(),m=c.getTextGuideLine(),x=c.isGroup;if(x)for(var b=c.childrenRef(),w=0;w0?{duration:g,delay:f.get("delay"),easing:f.get("easing")}:null;d.eachRendered(function(x){if(x.states&&x.states.emphasis){if(wc(x))return;if(x instanceof ur&&OH(x),x.__dirty){var b=x.prevStates;b&&x.useStates(b)}if(h){x.stateTransition=m;var w=x.getTextContent(),S=x.getTextGuideLine();w&&(w.stateTransition=m),S&&(S.stateTransition=m)}x.__dirty&&i(x)}})}UD=function(c){return new(function(d){tt(f,d);function f(){return d!==null&&d.apply(this,arguments)||this}return f.prototype.getCoordinateSystems=function(){return c._coordSysMgr.getCoordinateSystems()},f.prototype.getComponentByElement=function(h){for(;h;){var g=h.__ecComponentInfo;if(g!=null)return c._model.getComponent(g.mainType,g.index);h=h.parent}},f.prototype.enterEmphasis=function(h,g){Bs(h,g),Sn(c)},f.prototype.leaveEmphasis=function(h,g){Vs(h,g),Sn(c)},f.prototype.enterBlur=function(h){Q4(h),Sn(c)},f.prototype.leaveBlur=function(h){IS(h),Sn(c)},f.prototype.enterSelect=function(h){J4(h),Sn(c)},f.prototype.leaveSelect=function(h){t5(h),Sn(c)},f.prototype.getModel=function(){return c.getModel()},f.prototype.getViewOfComponentModel=function(h){return c.getViewOfComponentModel(h)},f.prototype.getViewOfSeriesModel=function(h){return c.getViewOfSeriesModel(h)},f}($5))(c)},G3=function(c){function d(f,h){for(var g=0;g=0)){YD.push(e);var n=_3.wrapStageHandler(e,i);n.__prio=t,n.__raw=e,r.push(n)}}function Y3(r,t){Xw[r]=t}function m$(r,t,e){var a=Xq("registerMap");a&&a(r,t,e)}var _$=IU;Lv(eT,aq);Lv(C_,iq);Lv(C_,nq);Lv(eT,mq);Lv(C_,_q);Lv(N3,Yq);q3(Z5);$3(Qq,cU);Y3("default",oq);ds({type:iv,event:iv,update:iv},Fa);ds({type:Ly,event:Ly,update:Ly},Fa);ds({type:Gf,event:Gf,update:Gf},Fa);ds({type:Iy,event:Iy,update:Iy},Fa);ds({type:Hf,event:Hf,update:Hf},Fa);U3("light",gq);U3("dark",S3);var ZD=[],x$={registerPreprocessor:q3,registerProcessor:$3,registerPostInit:h$,registerPostUpdate:p$,registerUpdateLifecycle:iT,registerAction:ds,registerCoordinateSystem:g$,registerLayout:y$,registerVisual:Lv,registerTransform:_$,registerLoading:Y3,registerMap:m$,registerImpl:Zq,PRIORITY:s$,ComponentModel:_r,ComponentView:Ma,SeriesModel:ma,ChartView:ca,registerComponentModel:function(r){_r.registerClass(r)},registerComponentView:function(r){Ma.registerClass(r)},registerSeriesModel:function(r){ma.registerClass(r)},registerChartView:function(r){ca.registerClass(r)},registerSubTypeDefaulter:function(r,t){_r.registerSubTypeDefaulter(r,t)},registerPainter:function(r,t){r9(r,t)}};function sr(r){if(ht(r)){R(r,function(t){sr(t)});return}ir(ZD,r)>=0||(ZD.push(r),le(r)&&(r={install:r}),r.install(x$))}function Jd(r){return r==null?0:r.length||1}function XD(r){return r}var Fs=function(){function r(t,e,a,i,n,o){this._old=t,this._new=e,this._oldKeyGetter=a||XD,this._newKeyGetter=i||XD,this.context=n,this._diffModeMultiple=o==="multiple"}return r.prototype.add=function(t){return this._add=t,this},r.prototype.update=function(t){return this._update=t,this},r.prototype.updateManyToOne=function(t){return this._updateManyToOne=t,this},r.prototype.updateOneToMany=function(t){return this._updateOneToMany=t,this},r.prototype.updateManyToMany=function(t){return this._updateManyToMany=t,this},r.prototype.remove=function(t){return this._remove=t,this},r.prototype.execute=function(){this[this._diffModeMultiple?"_executeMultiple":"_executeOneToOne"]()},r.prototype._executeOneToOne=function(){var t=this._old,e=this._new,a={},i=new Array(t.length),n=new Array(e.length);this._initIndexMap(t,null,i,"_oldKeyGetter"),this._initIndexMap(e,a,n,"_newKeyGetter");for(var o=0;o1){var v=l.shift();l.length===1&&(a[s]=l[0]),this._update&&this._update(v,o)}else u===1?(a[s]=null,this._update&&this._update(l,o)):this._remove&&this._remove(o)}this._performRestAdd(n,a)},r.prototype._executeMultiple=function(){var t=this._old,e=this._new,a={},i={},n=[],o=[];this._initIndexMap(t,a,n,"_oldKeyGetter"),this._initIndexMap(e,i,o,"_newKeyGetter");for(var s=0;s1&&d===1)this._updateManyToOne&&this._updateManyToOne(v,u),i[l]=null;else if(c===1&&d>1)this._updateOneToMany&&this._updateOneToMany(v,u),i[l]=null;else if(c===1&&d===1)this._update&&this._update(v,u),i[l]=null;else if(c>1&&d>1)this._updateManyToMany&&this._updateManyToMany(v,u),i[l]=null;else if(c>1)for(var f=0;f1)for(var s=0;s30}var tf=pe,nl=pt,C$=typeof Int32Array>"u"?Array:Int32Array,k$="e\0\0",jD=-1,D$=["hasItemOption","_nameList","_idList","_invertedIndicesMap","_dimSummary","userOutput","_rawData","_dimValueGetter","_nameDimIdx","_idDimIdx","_nameRepeatCount"],M$=["_approximateExtent"],KD,Gg,ef,rf,yx,af,mx,Oi=function(){function r(t,e){this.type="list",this._dimOmitted=!1,this._nameList=[],this._idList=[],this._visual={},this._layout={},this._itemVisuals=[],this._itemLayouts=[],this._graphicEls=[],this._approximateExtent={},this._calculationInfo={},this.hasItemOption=!1,this.TRANSFERABLE_METHODS=["cloneShallow","downSample","minmaxDownSample","lttbDownSample","map"],this.CHANGABLE_METHODS=["filterSelf","selectRange"],this.DOWNSAMPLE_METHODS=["downSample","minmaxDownSample","lttbDownSample"];var a,i=!1;X3(t)?(a=t.dimensions,this._dimOmitted=t.isDimensionOmitted(),this._schema=t):(i=!0,a=t),a=a||["x","y"];for(var n={},o=[],s={},l=!1,u={},v=0;v=e)){var a=this._store,i=a.getProvider();this._updateOrdinalMeta();var n=this._nameList,o=this._idList,s=i.getSource().sourceFormat,l=s===Qn;if(l&&!i.pure)for(var u=[],v=t;v0},r.prototype.ensureUniqueItemVisual=function(t,e){var a=this._itemVisuals,i=a[t];i||(i=a[t]={});var n=i[e];return n==null&&(n=this.getVisual(e),ht(n)?n=n.slice():tf(n)&&(n=lt({},n)),i[e]=n),n},r.prototype.setItemVisual=function(t,e,a){var i=this._itemVisuals[t]||{};this._itemVisuals[t]=i,tf(e)?lt(i,e):i[e]=a},r.prototype.clearAllVisual=function(){this._visual={},this._itemVisuals=[]},r.prototype.setLayout=function(t,e){tf(t)?lt(this._layout,t):this._layout[t]=e},r.prototype.getLayout=function(t){return this._layout[t]},r.prototype.getItemLayout=function(t){return this._itemLayouts[t]},r.prototype.setItemLayout=function(t,e,a){this._itemLayouts[t]=a?lt(this._itemLayouts[t]||{},e):e},r.prototype.clearItemLayouts=function(){this._itemLayouts.length=0},r.prototype.setItemGraphicEl=function(t,e){var a=this.hostModel&&this.hostModel.seriesIndex;kw(a,this.dataType,t,e),this._graphicEls[t]=e},r.prototype.getItemGraphicEl=function(t){return this._graphicEls[t]},r.prototype.eachItemGraphicEl=function(t,e){R(this._graphicEls,function(a,i){a&&t&&t.call(e,a,i)})},r.prototype.cloneShallow=function(t){return t||(t=new r(this._schema?this._schema:nl(this.dimensions,this._getDimInfo,this),this.hostModel)),yx(t,this),t._store=this._store,t},r.prototype.wrapMethod=function(t,e){var a=this[t];le(a)&&(this.__wrappedMethods=this.__wrappedMethods||[],this.__wrappedMethods.push(t),this[t]=function(){var i=a.apply(this,arguments);return e.apply(this,[i].concat(hS(arguments)))})},r.internalField=function(){KD=function(t){var e=t._invertedIndicesMap;R(e,function(a,i){var n=t._dimInfos[i],o=n.ordinalMeta,s=t._store;if(o){a=e[i]=new C$(o.categories.length);for(var l=0;l1&&(l+="__ec__"+v),i[e]=l}}}(),r}();function qh(r,t){ZS(r)||(r=XS(r)),t=t||{};var e=t.coordDimensions||[],a=t.dimensionsDefine||r.dimensionsDefine||[],i=Wt(),n=[],o=I$(r,e,a,t.dimensionsCount),s=t.canOmitUnusedDimensions&&Q3(o),l=a===r.dimensionsDefine,u=l?K3(r):j3(a),v=t.encodeDefine;!v&&t.encodeDefaulter&&(v=t.encodeDefaulter(r,o));for(var c=Wt(v),d=new n3(o),f=0;f0&&(a.name=i+(n-1)),n++,t.set(i,n)}}function I$(r,t,e,a){var i=Math.max(r.dimensionsDetectedCount||1,t.length,e.length,a||0);return R(t,function(n){var o;pe(n)&&(o=n.dimsDef)&&(i=Math.max(i,o.length))}),i}function R$(r,t,e){if(e||t.hasKey(r)){for(var a=0;t.hasKey(r+a);)a++;r+=a}return t.set(r,!0),r}var P$=function(){function r(t){this.coordSysDims=[],this.axisMap=Wt(),this.categoryAxisMap=Wt(),this.coordSysName=t}return r}();function E$(r){var t=r.get("coordinateSystem"),e=new P$(t),a=N$[t];if(a)return a(r,e,e.axisMap,e.categoryAxisMap),e}var N$={cartesian2d:function(r,t,e,a){var i=r.getReferringComponents("xAxis",Ua).models[0],n=r.getReferringComponents("yAxis",Ua).models[0];t.coordSysDims=["x","y"],e.set("x",i),e.set("y",n),Jv(i)&&(a.set("x",i),t.firstCategoryDimIndex=0),Jv(n)&&(a.set("y",n),t.firstCategoryDimIndex==null&&(t.firstCategoryDimIndex=1))},singleAxis:function(r,t,e,a){var i=r.getReferringComponents("singleAxis",Ua).models[0];t.coordSysDims=["single"],e.set("single",i),Jv(i)&&(a.set("single",i),t.firstCategoryDimIndex=0)},polar:function(r,t,e,a){var i=r.getReferringComponents("polar",Ua).models[0],n=i.findAxisModel("radiusAxis"),o=i.findAxisModel("angleAxis");t.coordSysDims=["radius","angle"],e.set("radius",n),e.set("angle",o),Jv(n)&&(a.set("radius",n),t.firstCategoryDimIndex=0),Jv(o)&&(a.set("angle",o),t.firstCategoryDimIndex==null&&(t.firstCategoryDimIndex=1))},geo:function(r,t,e,a){t.coordSysDims=["lng","lat"]},parallel:function(r,t,e,a){var i=r.ecModel,n=i.getComponent("parallel",r.get("parallelIndex")),o=t.coordSysDims=n.dimensions.slice();R(n.parallelAxisIndex,function(s,l){var u=i.getComponent("parallelAxis",s),v=o[l];e.set(v,u),Jv(u)&&(a.set(v,u),t.firstCategoryDimIndex==null&&(t.firstCategoryDimIndex=l))})}};function Jv(r){return r.get("type")==="category"}function O$(r,t,e){e=e||{};var a=e.byIndex,i=e.stackedCoordDimension,n,o,s;z$(t)?n=t:(o=t.schema,n=o.dimensions,s=t.store);var l=!!(r&&r.get("stack")),u,v,c,d;if(R(n,function(b,w){Pt(b)&&(n[w]=b={name:b}),l&&!b.isExtraCoord&&(!a&&!u&&b.ordinalMeta&&(u=b),!v&&b.type!=="ordinal"&&b.type!=="time"&&(!i||i===b.coordDim)&&(v=b))}),v&&!a&&!u&&(a=!0),v){c="__\0ecstackresult_"+r.id,d="__\0ecstackedover_"+r.id,u&&(u.createInvertedIndices=!0);var f=v.coordDim,h=v.type,g=0;R(n,function(b){b.coordDim===f&&g++});var m={name:c,coordDim:f,coordDimIndex:g,type:h,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:n.length},x={name:d,coordDim:d,coordDimIndex:g+1,type:h,isExtraCoord:!0,isCalculationCoord:!0,storeDimIndex:n.length+1};o?(s&&(m.storeDimIndex=s.ensureCalculationDimension(d,h),x.storeDimIndex=s.ensureCalculationDimension(c,h)),o.appendCalculationDimension(m),o.appendCalculationDimension(x)):(n.push(m),n.push(x))}return{stackedDimension:v&&v.name,stackedByDimension:u&&u.name,isStackedByIndex:a,stackedOverDimension:d,stackResultDimension:c}}function z$(r){return!X3(r.schema)}function Bl(r,t){return!!t&&t===r.getCalculationInfo("stackedDimension")}function J3(r,t){return Bl(r,t)?r.getCalculationInfo("stackResultDimension"):t}function B$(r,t){var e=r.get("coordinateSystem"),a=Wh.get(e),i;return t&&t.coordSysDims&&(i=pt(t.coordSysDims,function(n){var o={name:n},s=t.axisMap.get(n);if(s){var l=s.get("type");o.type=hm(l)}return o})),i||(i=a&&(a.getDimensionsInfo?a.getDimensionsInfo():a.dimensions.slice())||["x","y"]),i}function V$(r,t,e){var a,i;return e&&R(r,function(n,o){var s=n.coordDim,l=e.categoryAxisMap.get(s);l&&(a==null&&(a=o),n.ordinalMeta=l.getOrdinalMeta(),t&&(n.createInvertedIndices=!0)),n.otherDims.itemName!=null&&(i=!0)}),!i&&a!=null&&(r[a].otherDims.itemName=0),a}function Ys(r,t,e){e=e||{};var a=t.getSourceManager(),i,n=!1;r?(n=!0,i=XS(r)):(i=a.getSource(),n=i.sourceFormat===Qn);var o=E$(t),s=B$(t,o),l=e.useEncodeDefaulter,u=le(l)?l:l?We(H5,s,t):null,v={coordDimensions:s,generateCoord:e.generateCoord,encodeDefine:t.getEncode(),encodeDefaulter:u,canOmitUnusedDimensions:!n},c=qh(i,v),d=V$(c.dimensions,e.createInvertedIndices,o),f=n?null:a.getSharedDataStore(c),h=O$(t,{schema:c,store:f}),g=new Oi(c,t);g.setCalculationInfo(h);var m=d!=null&&F$(i)?function(x,b,w,S){return S===d?w:this.defaultDimValueGetter(x,b,w,S)}:null;return g.hasItemOption=!1,g.initData(n?i:f,null,m),g}function F$(r){if(r.sourceFormat===Qn){var t=G$(r.data||[]);return!ht(nd(t))}}function G$(r){for(var t=0;te[1]&&(e[1]=t[1])},r.prototype.unionExtentFromData=function(t,e){this.unionExtent(t.getApproximateExtent(e))},r.prototype.getExtent=function(){return this._extent.slice()},r.prototype.setExtent=function(t,e){var a=this._extent;isNaN(t)||(a[0]=t),isNaN(e)||(a[1]=e)},r.prototype.isInExtentRange=function(t){return this._extent[0]<=t&&this._extent[1]>=t},r.prototype.isBlank=function(){return this._isBlank},r.prototype.setBlank=function(t){this._isBlank=t},r}();o_(fs);var H$=0,jw=function(){function r(t){this.categories=t.categories||[],this._needCollect=t.needCollect,this._deduplication=t.deduplication,this.uid=++H$}return r.createByAxisModel=function(t){var e=t.option,a=e.data,i=a&&pt(a,W$);return new r({categories:i,needCollect:!i,deduplication:e.dedplication!==!1})},r.prototype.getOrdinal=function(t){return this._getOrCreateMap().get(t)},r.prototype.parseAndCollect=function(t){var e,a=this._needCollect;if(!Pt(t)&&!a)return t;if(a&&!this._deduplication)return e=this.categories.length,this.categories[e]=t,e;var i=this._getOrCreateMap();return e=i.get(t),e==null&&(a?(e=this.categories.length,this.categories[e]=t,i.set(t,e)):e=NaN),e},r.prototype._getOrCreateMap=function(){return this._map||(this._map=Wt(this.categories))},r}();function W$(r){return pe(r)&&r.value!=null?r.value:r+""}function Kw(r){return r.type==="interval"||r.type==="log"}function U$(r,t,e,a){var i={},n=r[1]-r[0],o=i.interval=k4(n/t);e!=null&&oa&&(o=i.interval=a);var s=i.intervalPrecision=tN(o),l=i.niceTickExtent=[Ea(Math.ceil(r[0]/o)*o,s),Ea(Math.floor(r[1]/o)*o,s)];return q$(l,r),i}function _x(r){var t=Math.pow(10,wS(r)),e=r/t;return e?e===2?e=3:e===3?e=5:e*=2:e=1,Ea(e*t)}function tN(r){return Qo(r)+2}function QD(r,t,e){r[t]=Math.max(Math.min(r[t],e[1]),e[0])}function q$(r,t){!isFinite(r[0])&&(r[0]=t[0]),!isFinite(r[1])&&(r[1]=t[1]),QD(r,0,t),QD(r,1,t),r[0]>r[1]&&(r[0]=r[1])}function k_(r,t){return r>=t[0]&&r<=t[1]}function D_(r,t){return t[1]===t[0]?.5:(r-t[0])/(t[1]-t[0])}function M_(r,t){return r*(t[1]-t[0])+t[0]}var L_=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;a.type="ordinal";var i=a.getSetting("ordinalMeta");return i||(i=new jw({})),ht(i)&&(i=new jw({categories:pt(i,function(n){return pe(n)?n.value:n})})),a._ordinalMeta=i,a._extent=a.getSetting("extent")||[0,i.categories.length-1],a}return t.prototype.parse=function(e){return e==null?NaN:Pt(e)?this._ordinalMeta.getOrdinal(e):Math.round(e)},t.prototype.contain=function(e){return e=this.parse(e),k_(e,this._extent)&&this._ordinalMeta.categories[e]!=null},t.prototype.normalize=function(e){return e=this._getTickNumber(this.parse(e)),D_(e,this._extent)},t.prototype.scale=function(e){return e=Math.round(M_(e,this._extent)),this.getRawOrdinalNumber(e)},t.prototype.getTicks=function(){for(var e=[],a=this._extent,i=a[0];i<=a[1];)e.push({value:i}),i++;return e},t.prototype.getMinorTicks=function(e){},t.prototype.setSortInfo=function(e){if(e==null){this._ordinalNumbersByTick=this._ticksByOrdinalNumber=null;return}for(var a=e.ordinalNumbers,i=this._ordinalNumbersByTick=[],n=this._ticksByOrdinalNumber=[],o=0,s=this._ordinalMeta.categories.length,l=Math.min(s,a.length);o=0&&e=0&&e=e},t.prototype.getOrdinalMeta=function(){return this._ordinalMeta},t.prototype.calcNiceTicks=function(){},t.prototype.calcNiceExtent=function(){},t.type="ordinal",t}(fs);fs.registerClass(L_);var _u=Ea,Gs=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type="interval",e._interval=0,e._intervalPrecision=2,e}return t.prototype.parse=function(e){return e},t.prototype.contain=function(e){return k_(e,this._extent)},t.prototype.normalize=function(e){return D_(e,this._extent)},t.prototype.scale=function(e){return M_(e,this._extent)},t.prototype.setExtent=function(e,a){var i=this._extent;isNaN(e)||(i[0]=parseFloat(e)),isNaN(a)||(i[1]=parseFloat(a))},t.prototype.unionExtent=function(e){var a=this._extent;e[0]a[1]&&(a[1]=e[1]),this.setExtent(a[0],a[1])},t.prototype.getInterval=function(){return this._interval},t.prototype.setInterval=function(e){this._interval=e,this._niceExtent=this._extent.slice(),this._intervalPrecision=tN(e)},t.prototype.getTicks=function(e){var a=this._interval,i=this._extent,n=this._niceExtent,o=this._intervalPrecision,s=[];if(!a)return s;var l=1e4;i[0]l)return[];var v=s.length?s[s.length-1].value:n[1];return i[1]>v&&(e?s.push({value:_u(v+a,o)}):s.push({value:i[1]})),s},t.prototype.getMinorTicks=function(e){for(var a=this.getTicks(!0),i=[],n=this.getExtent(),o=1;on[0]&&f0&&(n=n===null?s:Math.min(n,s))}e[a]=n}}return e}function iN(r){var t=Z$(r),e=[];return R(r,function(a){var i=a.coordinateSystem,n=i.getBaseAxis(),o=n.getExtent(),s;if(n.type==="category")s=n.getBandWidth();else if(n.type==="value"||n.type==="time"){var l=n.dim+"_"+n.index,u=t[l],v=Math.abs(o[1]-o[0]),c=n.scale.getExtent(),d=Math.abs(c[1]-c[0]);s=u?v/d*u:v}else{var f=a.getData();s=Math.abs(o[1]-o[0])/f.count()}var h=Lt(a.get("barWidth"),s),g=Lt(a.get("barMaxWidth"),s),m=Lt(a.get("barMinWidth")||(uN(a)?.5:1),s),x=a.get("barGap"),b=a.get("barCategoryGap");e.push({bandWidth:s,barWidth:h,barMaxWidth:g,barMinWidth:m,barGap:x,barCategoryGap:b,axisKey:oT(n),stackId:rN(a)})}),nN(e)}function nN(r){var t={};R(r,function(a,i){var n=a.axisKey,o=a.bandWidth,s=t[n]||{bandWidth:o,remainedWidth:o,autoWidthCount:0,categoryGap:null,gap:"20%",stacks:{}},l=s.stacks;t[n]=s;var u=a.stackId;l[u]||s.autoWidthCount++,l[u]=l[u]||{width:0,maxWidth:0};var v=a.barWidth;v&&!l[u].width&&(l[u].width=v,v=Math.min(s.remainedWidth,v),s.remainedWidth-=v);var c=a.barMaxWidth;c&&(l[u].maxWidth=c);var d=a.barMinWidth;d&&(l[u].minWidth=d);var f=a.barGap;f!=null&&(s.gap=f);var h=a.barCategoryGap;h!=null&&(s.categoryGap=h)});var e={};return R(t,function(a,i){e[i]={};var n=a.stacks,o=a.bandWidth,s=a.categoryGap;if(s==null){var l=xr(n).length;s=Math.max(35-l*4,15)+"%"}var u=Lt(s,o),v=Lt(a.gap,1),c=a.remainedWidth,d=a.autoWidthCount,f=(c-u)/(d+(d-1)*v);f=Math.max(f,0),R(n,function(x){var b=x.maxWidth,w=x.minWidth;if(x.width){var S=x.width;b&&(S=Math.min(S,b)),w&&(S=Math.max(S,w)),x.width=S,c-=S+v*S,d--}else{var S=f;b&&bS&&(S=w),S!==f&&(x.width=S,c-=S+v*S,d--)}}),f=(c-u)/(d+(d-1)*v),f=Math.max(f,0);var h=0,g;R(n,function(x,b){x.width||(x.width=f),g=x,h+=x.width*(1+v)}),g&&(h-=g.width*v);var m=-h/2;R(n,function(x,b){e[i][b]=e[i][b]||{bandWidth:o,offset:m,width:x.width},m+=x.width*(1+v)})}),e}function X$(r,t,e){if(r&&t){var a=r[oT(t)];return a}}function oN(r,t){var e=aN(r,t),a=iN(e);R(e,function(i){var n=i.getData(),o=i.coordinateSystem,s=o.getBaseAxis(),l=rN(i),u=a[oT(s)][l],v=u.offset,c=u.width;n.setLayout({bandWidth:u.bandWidth,offset:v,size:c})})}function sN(r){return{seriesType:r,plan:fd(),reset:function(t){if(lN(t)){var e=t.getData(),a=t.coordinateSystem,i=a.getBaseAxis(),n=a.getOtherAxis(i),o=e.getDimensionIndex(e.mapDimension(n.dim)),s=e.getDimensionIndex(e.mapDimension(i.dim)),l=t.get("showBackground",!0),u=e.mapDimension(n.dim),v=e.getCalculationInfo("stackResultDimension"),c=Bl(e,u)&&!!e.getCalculationInfo("stackedOnSeries"),d=n.isHorizontal(),f=j$(i,n),h=uN(t),g=t.get("barMinHeight")||0,m=v&&e.getDimensionIndex(v),x=e.getLayout("size"),b=e.getLayout("offset");return{progress:function(w,S){for(var C=w.count,T=h&&ts(C*3),k=h&&l&&ts(C*3),D=h&&ts(C),M=a.master.getRect(),L=d?M.width:M.height,I,P=S.getStore(),E=0;(I=w.next())!=null;){var N=P.get(c?m:o,I),O=P.get(s,I),V=f,B=void 0;c&&(B=+N-P.get(o,I));var z=void 0,H=void 0,Y=void 0,$=void 0;if(d){var Z=a.dataToPoint([N,O]);if(c){var Q=a.dataToPoint([B,O]);V=Q[0]}z=V,H=Z[1]+b,Y=Z[0]-V,$=x,Math.abs(Y)0?e:1:e))}var K$=function(r,t,e,a){for(;e>>1;r[i][1]i&&(this._approxInterval=i);var s=Hg.length,l=Math.min(K$(Hg,this._approxInterval,0,s),s-1);this._interval=Hg[l][1],this._minLevelUnit=Hg[Math.max(l-1,0)][0]},t.prototype.parse=function(e){return Or(e)?e:+us(e)},t.prototype.contain=function(e){return k_(this.parse(e),this._extent)},t.prototype.normalize=function(e){return D_(this.parse(e),this._extent)},t.prototype.scale=function(e){return M_(e,this._extent)},t.type="time",t}(Gs),Hg=[["second",FS],["minute",GS],["hour",Uf],["quarter-day",Uf*6],["half-day",Uf*12],["day",Fn*1.2],["half-week",Fn*3.5],["week",Fn*7],["month",Fn*31],["quarter",Fn*95],["half-year",Gk/2],["year",Gk]];function Q$(r,t,e,a){var i=us(t),n=us(e),o=function(h){return Wk(i,h,a)===Wk(n,h,a)},s=function(){return o("year")},l=function(){return s()&&o("month")},u=function(){return l()&&o("day")},v=function(){return u()&&o("hour")},c=function(){return v()&&o("minute")},d=function(){return c()&&o("second")},f=function(){return d()&&o("millisecond")};switch(r){case"year":return s();case"month":return l();case"day":return u();case"hour":return v();case"minute":return c();case"second":return d();case"millisecond":return f()}}function J$(r,t){return r/=Fn,r>16?16:r>7.5?7:r>3.5?4:r>1.5?2:1}function tY(r){var t=30*Fn;return r/=t,r>6?6:r>3?3:r>2?2:1}function eY(r){return r/=Uf,r>12?12:r>6?6:r>3.5?4:r>2?2:1}function JD(r,t){return r/=t?GS:FS,r>30?30:r>20?20:r>15?15:r>10?10:r>5?5:r>2?2:1}function rY(r){return k4(r)}function aY(r,t,e){var a=new Date(r);switch(Tc(t)){case"year":case"month":a[k5(e)](0);case"day":a[D5(e)](1);case"hour":a[M5(e)](0);case"minute":a[L5(e)](0);case"second":a[I5(e)](0),a[R5(e)](0)}return a.getTime()}function iY(r,t,e,a){var i=1e4,n=A5,o=0;function s(L,I,P,E,N,O,V){for(var B=new Date(I),z=I,H=B[E]();z1&&O===0&&P.unshift({value:P[0].value-z})}}for(var O=0;O=a[0]&&b<=a[1]&&c++)}var w=(a[1]-a[0])/t;if(c>w*1.5&&d>w/1.5||(u.push(m),c>w||r===n[f]))break}v=[]}}}for(var S=ta(pt(u,function(L){return ta(L,function(I){return I.value>=a[0]&&I.value<=a[1]&&!I.notAdd})}),function(L){return L.length>0}),C=[],T=S.length-1,f=0;f0;)n*=10;var s=[Ea(sY(a[0]/n)*n),Ea(oY(a[1]/n)*n)];this._interval=n,this._niceExtent=s}},t.prototype.calcNiceExtent=function(e){Zf.calcNiceExtent.call(this,e),this._fixMin=e.fixMin,this._fixMax=e.fixMax},t.prototype.parse=function(e){return e},t.prototype.contain=function(e){return e=oo(e)/oo(this.base),k_(e,this._extent)},t.prototype.normalize=function(e){return e=oo(e)/oo(this.base),D_(e,this._extent)},t.prototype.scale=function(e){return e=M_(e,this._extent),Wg(this.base,e)},t.type="log",t}(fs),vN=lT.prototype;vN.getMinorTicks=Zf.getMinorTicks;vN.getLabel=Zf.getLabel;function Ug(r,t){return nY(r,Qo(t))}fs.registerClass(lT);var lY=function(){function r(t,e,a){this._prepareParams(t,e,a)}return r.prototype._prepareParams=function(t,e,a){a[1]0&&l>0&&!u&&(s=0),s<0&&l<0&&!v&&(l=0));var d=this._determinedMin,f=this._determinedMax;return d!=null&&(s=d,u=!0),f!=null&&(l=f,v=!0),{min:s,max:l,minFixed:u,maxFixed:v,isBlank:c}},r.prototype.modifyDataMinMax=function(t,e){this[vY[t]]=e},r.prototype.setDeterminedMinMax=function(t,e){var a=uY[t];this[a]=e},r.prototype.freeze=function(){this.frozen=!0},r}(),uY={min:"_determinedMin",max:"_determinedMax"},vY={min:"_dataMin",max:"_dataMax"};function cN(r,t,e){var a=r.rawExtentInfo;return a||(a=new lY(r,t,e),r.rawExtentInfo=a,a)}function qg(r,t){return t==null?null:th(t)?NaN:r.parse(t)}function dN(r,t){var e=r.type,a=cN(r,t,r.getExtent()).calculate();r.setBlank(a.isBlank);var i=a.min,n=a.max,o=t.ecModel;if(o&&e==="time"){var s=aN("bar",o),l=!1;if(R(s,function(c){l=l||c.getBaseAxis()===t.axis}),l){var u=iN(s),v=cY(i,n,t,u);i=v.min,n=v.max}}return{extent:[i,n],fixMin:a.minFixed,fixMax:a.maxFixed}}function cY(r,t,e,a){var i=e.axis.getExtent(),n=Math.abs(i[1]-i[0]),o=X$(a,e.axis);if(o===void 0)return{min:r,max:t};var s=1/0;R(o,function(f){s=Math.min(f.offset,s)});var l=-1/0;R(o,function(f){l=Math.max(f.offset+f.width,l)}),s=Math.abs(s),l=Math.abs(l);var u=s+l,v=t-r,c=1-(s+l)/n,d=v/c-v;return t+=d*(l/u),r-=d*(s/u),{min:r,max:t}}function Xc(r,t){var e=t,a=dN(r,e),i=a.extent,n=e.get("splitNumber");r instanceof lT&&(r.base=e.get("logBase"));var o=r.type,s=e.get("interval"),l=o==="interval"||o==="time";r.setExtent(i[0],i[1]),r.calcNiceExtent({splitNumber:n,fixMin:a.fixMin,fixMax:a.fixMax,minInterval:l?e.get("minInterval"):null,maxInterval:l?e.get("maxInterval"):null}),s!=null&&r.setInterval&&r.setInterval(s)}function I_(r,t){if(t=t||r.get("type"),t)switch(t){case"category":return new L_({ordinalMeta:r.getOrdinalMeta?r.getOrdinalMeta():r.getCategories(),extent:[1/0,-1/0]});case"time":return new sT({locale:r.ecModel.getLocaleModel(),useUTC:r.ecModel.get("useUTC")});default:return new(fs.getClass(t)||Gs)}}function dY(r){var t=r.scale.getExtent(),e=t[0],a=t[1];return!(e>0&&a>0||e<0&&a<0)}function gd(r){var t=r.getLabelModel().get("formatter"),e=r.type==="category"?r.scale.getExtent()[0]:null;return r.scale.type==="time"?function(a){return function(i,n){return r.scale.getFormattedLabel(i,n,a)}}(t):Pt(t)?function(a){return function(i){var n=r.scale.getLabel(i),o=a.replace("{value}",n??"");return o}}(t):le(t)?function(a){return function(i,n){return e!=null&&(n=i.value-e),a(uT(r,i),n,i.level!=null?{level:i.level}:null)}}(t):function(a){return r.scale.getLabel(a)}}function uT(r,t){return r.type==="category"?r.scale.getLabel(t):t.value}function fY(r){var t=r.model,e=r.scale;if(!(!t.get(["axisLabel","show"])||e.isBlank())){var a,i,n=e.getExtent();e instanceof L_?i=e.count():(a=e.getTicks(),i=a.length);var o=r.getLabelModel(),s=gd(r),l,u=1;i>40&&(u=Math.ceil(i/40));for(var v=0;vr[1]&&(r[1]=i[1])})}var $h=function(){function r(){}return r.prototype.getNeedCrossZero=function(){var t=this.option;return!t.scale},r.prototype.getCoordSysModel=function(){},r}(),gY=1e-8;function eM(r,t){return Math.abs(r-t)i&&(a=o,i=l)}if(a)return mY(a.exterior);var u=this.getBoundingRect();return[u.x+u.width/2,u.y+u.height/2]},t.prototype.getBoundingRect=function(e){var a=this._rect;if(a&&!e)return a;var i=[1/0,1/0],n=[-1/0,-1/0],o=this.geometries;return R(o,function(s){s.type==="polygon"?rM(s.exterior,i,n,e):R(s.points,function(l){rM(l,i,n,e)})}),isFinite(i[0])&&isFinite(i[1])&&isFinite(n[0])&&isFinite(n[1])||(i[0]=i[1]=n[0]=n[1]=0),a=new er(i[0],i[1],n[0]-i[0],n[1]-i[1]),e||(this._rect=a),a},t.prototype.contain=function(e){var a=this.getBoundingRect(),i=this.geometries;if(!a.contain(e[0],e[1]))return!1;t:for(var n=0,o=i.length;n>1^-(s&1),l=l>>1^-(l&1),s+=i,l+=n,i=s,n=l,a.push([s/e,l/e])}return a}function bY(r,t){return r=xY(r),pt(ta(r.features,function(e){return e.geometry&&e.properties&&e.geometry.coordinates.length>0}),function(e){var a=e.properties,i=e.geometry,n=[];switch(i.type){case"Polygon":var o=i.coordinates;n.push(new aM(o[0],o.slice(1)));break;case"MultiPolygon":R(i.coordinates,function(l){l[0]&&n.push(new aM(l[0],l.slice(1)))});break;case"LineString":n.push(new iM([i.coordinates]));break;case"MultiLineString":n.push(new iM(i.coordinates))}var s=new pN(a[t||"name"],n,a.cp);return s.properties=a,s})}var gh=Lr();function yN(r,t){var e=pt(t,function(a){return r.scale.parse(a)});return r.type==="time"&&e.length>0&&(e.sort(),e.unshift(e[0]),e.push(e[e.length-1])),e}function wY(r){var t=r.getLabelModel().get("customValues");if(t){var e=gd(r),a=r.scale.getExtent(),i=yN(r,t),n=ta(i,function(o){return o>=a[0]&&o<=a[1]});return{labels:pt(n,function(o){var s={value:o};return{formattedLabel:e(s),rawLabel:r.scale.getLabel(s),tickValue:o}})}}return r.type==="category"?TY(r):CY(r)}function SY(r,t){var e=r.getTickModel().get("customValues");if(e){var a=r.scale.getExtent(),i=yN(r,e);return{ticks:ta(i,function(n){return n>=a[0]&&n<=a[1]})}}return r.type==="category"?AY(r,t):{ticks:pt(r.scale.getTicks(),function(n){return n.value})}}function TY(r){var t=r.getLabelModel(),e=mN(r,t);return!t.get("show")||r.scale.isBlank()?{labels:[],labelCategoryInterval:e.labelCategoryInterval}:e}function mN(r,t){var e=_N(r,"labels"),a=vT(t),i=xN(e,a);if(i)return i;var n,o;return le(a)?n=SN(r,a):(o=a==="auto"?kY(r):a,n=wN(r,o)),bN(e,a,{labels:n,labelCategoryInterval:o})}function AY(r,t){var e=_N(r,"ticks"),a=vT(t),i=xN(e,a);if(i)return i;var n,o;if((!t.get("show")||r.scale.isBlank())&&(n=[]),le(a))n=SN(r,a,!0);else if(a==="auto"){var s=mN(r,r.getLabelModel());o=s.labelCategoryInterval,n=pt(s.labels,function(l){return l.tickValue})}else o=a,n=wN(r,o,!0);return bN(e,a,{ticks:n,tickCategoryInterval:o})}function CY(r){var t=r.scale.getTicks(),e=gd(r);return{labels:pt(t,function(a,i){return{level:a.level,formattedLabel:e(a,i),rawLabel:r.scale.getLabel(a),tickValue:a.value}})}}function _N(r,t){return gh(r)[t]||(gh(r)[t]=[])}function xN(r,t){for(var e=0;e40&&(s=Math.max(1,Math.floor(o/40)));for(var l=n[0],u=r.dataToCoord(l+1)-r.dataToCoord(l),v=Math.abs(u*Math.cos(a)),c=Math.abs(u*Math.sin(a)),d=0,f=0;l<=n[1];l+=s){var h=0,g=0,m=Nh(e({value:l}),t.font,"center","top");h=m.width*1.3,g=m.height*1.3,d=Math.max(d,h,7),f=Math.max(f,g,7)}var x=d/v,b=f/c;isNaN(x)&&(x=1/0),isNaN(b)&&(b=1/0);var w=Math.max(0,Math.floor(Math.min(x,b))),S=gh(r.model),C=r.getExtent(),T=S.lastAutoInterval,k=S.lastTickCount;return T!=null&&k!=null&&Math.abs(T-w)<=1&&Math.abs(k-o)<=1&&T>w&&S.axisExtent0===C[0]&&S.axisExtent1===C[1]?w=T:(S.lastTickCount=o,S.lastAutoInterval=w,S.axisExtent0=C[0],S.axisExtent1=C[1]),w}function MY(r){var t=r.getLabelModel();return{axisRotate:r.getRotate?r.getRotate():r.isHorizontal&&!r.isHorizontal()?90:0,labelRotate:t.get("rotate")||0,font:t.getFont()}}function wN(r,t,e){var a=gd(r),i=r.scale,n=i.getExtent(),o=r.getLabelModel(),s=[],l=Math.max((t||0)+1,1),u=n[0],v=i.count();u!==0&&l>1&&v/l>2&&(u=Math.round(Math.ceil(u/l)*l));var c=fN(r),d=o.get("showMinLabel")||c,f=o.get("showMaxLabel")||c;d&&u!==n[0]&&g(n[0]);for(var h=u;h<=n[1];h+=l)g(h);f&&h-l!==n[1]&&g(n[1]);function g(m){var x={value:m};s.push(e?m:{formattedLabel:a(x),rawLabel:i.getLabel(x),tickValue:m})}return s}function SN(r,t,e){var a=r.scale,i=gd(r),n=[];return R(a.getTicks(),function(o){var s=a.getLabel(o),l=o.value;t(o.value,s)&&n.push(e?l:{formattedLabel:i(o),rawLabel:s,tickValue:l})}),n}var nM=[0,1],Do=function(){function r(t,e,a){this.onBand=!1,this.inverse=!1,this.dim=t,this.scale=e,this._extent=a||[0,0]}return r.prototype.contain=function(t){var e=this._extent,a=Math.min(e[0],e[1]),i=Math.max(e[0],e[1]);return t>=a&&t<=i},r.prototype.containData=function(t){return this.scale.contain(t)},r.prototype.getExtent=function(){return this._extent.slice()},r.prototype.getPixelPrecision=function(t){return A4(t||this.scale.getExtent(),this._extent)},r.prototype.setExtent=function(t,e){var a=this._extent;a[0]=t,a[1]=e},r.prototype.dataToCoord=function(t,e){var a=this._extent,i=this.scale;return t=i.normalize(t),this.onBand&&i.type==="ordinal"&&(a=a.slice(),oM(a,i.count())),ra(t,nM,a,e)},r.prototype.coordToData=function(t,e){var a=this._extent,i=this.scale;this.onBand&&i.type==="ordinal"&&(a=a.slice(),oM(a,i.count()));var n=ra(t,a,nM,e);return this.scale.scale(n)},r.prototype.pointToData=function(t,e){},r.prototype.getTicksCoords=function(t){t=t||{};var e=t.tickModel||this.getTickModel(),a=SY(this,e),i=a.ticks,n=pt(i,function(s){return{coord:this.dataToCoord(this.scale.type==="ordinal"?this.scale.getRawOrdinalNumber(s):s),tickValue:s}},this),o=e.get("alignWithLabel");return LY(this,n,o,t.clamp),n},r.prototype.getMinorTicksCoords=function(){if(this.scale.type==="ordinal")return[];var t=this.model.getModel("minorTick"),e=t.get("splitNumber");e>0&&e<100||(e=5);var a=this.scale.getMinorTicks(e),i=pt(a,function(n){return pt(n,function(o){return{coord:this.dataToCoord(o),tickValue:o}},this)},this);return i},r.prototype.getViewLabels=function(){return wY(this).labels},r.prototype.getLabelModel=function(){return this.model.getModel("axisLabel")},r.prototype.getTickModel=function(){return this.model.getModel("axisTick")},r.prototype.getBandWidth=function(){var t=this._extent,e=this.scale.getExtent(),a=e[1]-e[0]+(this.onBand?1:0);a===0&&(a=1);var i=Math.abs(t[1]-t[0]);return Math.abs(i)/a},r.prototype.calculateCategoryInterval=function(){return DY(this)},r}();function oM(r,t){var e=r[1]-r[0],a=t,i=e/a/2;r[0]+=i,r[1]-=i}function LY(r,t,e,a){var i=t.length;if(!r.onBand||e||!i)return;var n=r.getExtent(),o,s;if(i===1)t[0].coord=n[0],o=t[1]={coord:n[1],tickValue:t[0].tickValue};else{var l=t[i-1].tickValue-t[0].tickValue,u=(t[i-1].coord-t[0].coord)/l;R(t,function(f){f.coord-=u/2});var v=r.scale.getExtent();s=1+v[1]-t[i-1].tickValue,o={coord:t[i-1].coord+u*s,tickValue:v[1]+1},t.push(o)}var c=n[0]>n[1];d(t[0].coord,n[0])&&(a?t[0].coord=n[0]:t.shift()),a&&d(n[0],t[0].coord)&&t.unshift({coord:n[0]}),d(n[1],o.coord)&&(a?o.coord=n[1]:t.pop()),a&&d(o.coord,n[1])&&t.push({coord:n[1]});function d(f,h){return f=Ea(f),h=Ea(h),c?f>h:fi&&(i+=nf);var f=Math.atan2(s,o);if(f<0&&(f+=nf),f>=a&&f<=i||f+nf>=a&&f+nf<=i)return l[0]=v,l[1]=c,u-e;var h=e*Math.cos(a)+r,g=e*Math.sin(a)+t,m=e*Math.cos(i)+r,x=e*Math.sin(i)+t,b=(h-o)*(h-o)+(g-s)*(g-s),w=(m-o)*(m-o)+(x-s)*(x-s);return b0){t=t/180*Math.PI,xo.fromArray(r[0]),ga.fromArray(r[1]),Ba.fromArray(r[2]),Je.sub(es,xo,ga),Je.sub(Yo,Ba,ga);var e=es.len(),a=Yo.len();if(!(e<.001||a<.001)){es.scale(1/e),Yo.scale(1/a);var i=es.dot(Yo),n=Math.cos(t);if(n1&&Je.copy(Ei,Ba),Ei.toArray(r[1])}}}}function OY(r,t,e){if(e<=180&&e>0){e=e/180*Math.PI,xo.fromArray(r[0]),ga.fromArray(r[1]),Ba.fromArray(r[2]),Je.sub(es,ga,xo),Je.sub(Yo,Ba,ga);var a=es.len(),i=Yo.len();if(!(a<.001||i<.001)){es.scale(1/a),Yo.scale(1/i);var n=es.dot(t),o=Math.cos(e);if(n=l)Je.copy(Ei,Ba);else{Ei.scaleAndAdd(Yo,s/Math.tan(Math.PI/2-v));var c=Ba.x!==ga.x?(Ei.x-ga.x)/(Ba.x-ga.x):(Ei.y-ga.y)/(Ba.y-ga.y);if(isNaN(c))return;c<0?Je.copy(Ei,ga):c>1&&Je.copy(Ei,Ba)}Ei.toArray(r[1])}}}}function Sx(r,t,e,a){var i=e==="normal",n=i?r:r.ensureState(e);n.ignore=t;var o=a.get("smooth");o&&o===!0&&(o=.3),n.shape=n.shape||{},o>0&&(n.shape.smooth=o);var s=a.getModel("lineStyle").getLineStyle();i?r.useStyle(s):n.style=s}function zY(r,t){var e=t.smooth,a=t.points;if(a)if(r.moveTo(a[0][0],a[0][1]),e>0&&a.length>=3){var i=yl(a[0],a[1]),n=yl(a[1],a[2]);if(!i||!n){r.lineTo(a[1][0],a[1][1]),r.lineTo(a[2][0],a[2][1]);return}var o=Math.min(i,n)*e,s=Ty([],a[1],a[0],o/i),l=Ty([],a[1],a[2],o/n),u=Ty([],s,l,.5);r.bezierCurveTo(s[0],s[1],s[0],s[1],u[0],u[1]),r.bezierCurveTo(l[0],l[1],l[0],l[1],a[2][0],a[2][1])}else for(var v=1;v0){w(M*D,0,o);var L=M+T;L<0&&S(-L*D,1)}else S(-T*D,1)}}function w(T,k,D){T!==0&&(u=!0);for(var M=k;M0)for(var L=0;L0;L--){var N=D[L-1]*E;w(-N,L,o)}}}function C(T){var k=T<0?-1:1;T=Math.abs(T);for(var D=Math.ceil(T/(o-1)),M=0;M0?w(D,0,M+1):w(-D,o-M-1,o),T-=D,T<=0)return}return u}function BY(r,t,e,a){return kN(r,"x","width",t,e)}function DN(r,t,e,a){return kN(r,"y","height",t,e)}function MN(r){var t=[];r.sort(function(g,m){return m.priority-g.priority});var e=new er(0,0,0,0);function a(g){if(!g.ignore){var m=g.ensureState("emphasis");m.ignore==null&&(m.ignore=!1)}g.ignore=!0}for(var i=0;i=0&&a.attr(n.oldLayoutSelect),ir(d,"emphasis")>=0&&a.attr(n.oldLayoutEmphasis)),Gr(a,u,e,l)}else if(a.attr(u),!ld(a).valueAnimation){var c=Be(a.style.opacity,1);a.style.opacity=0,Ta(a,{style:{opacity:c}},e,l)}if(n.oldLayout=u,a.states.select){var f=n.oldLayoutSelect={};$g(f,u,Yg),$g(f,a.states.select,Yg)}if(a.states.emphasis){var h=n.oldLayoutEmphasis={};$g(h,u,Yg),$g(h,a.states.emphasis,Yg)}x5(a,l,v,e,e)}if(i&&!i.ignore&&!i.invisible){var n=GY(i),o=n.oldLayout,g={points:i.shape.points};o?(i.attr({shape:o}),Gr(i,{shape:g},e)):(i.setShape(g),i.style.strokePercent=0,Ta(i,{style:{strokePercent:1}},e)),n.oldLayout=g}},r}(),Ax=Lr();function WY(r){r.registerUpdateLifecycle("series:beforeupdate",function(t,e,a){var i=Ax(e).labelManager;i||(i=Ax(e).labelManager=new HY),i.clearLabels()}),r.registerUpdateLifecycle("series:layoutlabels",function(t,e,a){var i=Ax(e).labelManager;a.updatedSeries.forEach(function(n){i.addLabelsOfSeries(e.getViewOfSeriesModel(n))}),i.updateLayoutConfig(e),i.layout(e),i.processLabelsOverall()})}var Cx=Math.sin,kx=Math.cos,LN=Math.PI,bu=Math.PI*2,UY=180/LN,IN=function(){function r(){}return r.prototype.reset=function(t){this._start=!0,this._d=[],this._str="",this._p=Math.pow(10,t||4)},r.prototype.moveTo=function(t,e){this._add("M",t,e)},r.prototype.lineTo=function(t,e){this._add("L",t,e)},r.prototype.bezierCurveTo=function(t,e,a,i,n,o){this._add("C",t,e,a,i,n,o)},r.prototype.quadraticCurveTo=function(t,e,a,i){this._add("Q",t,e,a,i)},r.prototype.arc=function(t,e,a,i,n,o){this.ellipse(t,e,a,a,0,i,n,o)},r.prototype.ellipse=function(t,e,a,i,n,o,s,l){var u=s-o,v=!l,c=Math.abs(u),d=wl(c-bu)||(v?u>=bu:-u>=bu),f=u>0?u%bu:u%bu+bu,h=!1;d?h=!0:wl(c)?h=!1:h=f>=LN==!!v;var g=t+a*kx(o),m=e+i*Cx(o);this._start&&this._add("M",g,m);var x=Math.round(n*UY);if(d){var b=1/this._p,w=(v?1:-1)*(bu-b);this._add("A",a,i,x,1,+v,t+a*kx(o+w),e+i*Cx(o+w)),b>.01&&this._add("A",a,i,x,0,+v,g,m)}else{var S=t+a*kx(s),C=e+i*Cx(s);this._add("A",a,i,x,+h,+v,S,C)}},r.prototype.rect=function(t,e,a,i){this._add("M",t,e),this._add("l",a,0),this._add("l",0,i),this._add("l",-a,0),this._add("Z")},r.prototype.closePath=function(){this._d.length>0&&this._add("Z")},r.prototype._add=function(t,e,a,i,n,o,s,l,u){for(var v=[],c=this._p,d=1;d"}function JY(r){return""}function hT(r,t){t=t||{};var e=t.newline?` +`:"";function a(i){var n=i.children,o=i.tag,s=i.attrs,l=i.text;return QY(o,s)+(o!=="style"?Ni(l):l||"")+(n?""+e+pt(n,function(u){return a(u)}).join(e)+e:"")+JY(o)}return a(r)}function tZ(r,t,e){e=e||{};var a=e.newline?` +`:"",i=" {"+a,n=a+"}",o=pt(xr(r),function(l){return l+i+pt(xr(r[l]),function(u){return u+":"+r[l][u]+";"}).join(a)+n}).join(a),s=pt(xr(t),function(l){return"@keyframes "+l+i+pt(xr(t[l]),function(u){return u+i+pt(xr(t[l][u]),function(v){var c=t[l][u][v];return v==="d"&&(c='path("'+c+'")'),v+":"+c+";"}).join(a)+n}).join(a)+n}).join(a);return!o&&!s?"":[""].join(a)}function t2(r){return{zrId:r,shadowCache:{},patternCache:{},gradientCache:{},clipPathCache:{},defs:{},cssNodes:{},cssAnims:{},cssStyleCache:{},cssAnimIdx:0,shadowIdx:0,gradientIdx:0,patternIdx:0,clipPathIdx:0}}function vM(r,t,e,a){return ti("svg","root",{width:r,height:t,xmlns:RN,"xmlns:xlink":PN,version:"1.1",baseProfile:"full",viewBox:a?"0 0 "+r+" "+t:!1},e)}var eZ=0;function NN(){return eZ++}var cM={cubicIn:"0.32,0,0.67,0",cubicOut:"0.33,1,0.68,1",cubicInOut:"0.65,0,0.35,1",quadraticIn:"0.11,0,0.5,0",quadraticOut:"0.5,1,0.89,1",quadraticInOut:"0.45,0,0.55,1",quarticIn:"0.5,0,0.75,0",quarticOut:"0.25,1,0.5,1",quarticInOut:"0.76,0,0.24,1",quinticIn:"0.64,0,0.78,0",quinticOut:"0.22,1,0.36,1",quinticInOut:"0.83,0,0.17,1",sinusoidalIn:"0.12,0,0.39,0",sinusoidalOut:"0.61,1,0.88,1",sinusoidalInOut:"0.37,0,0.63,1",exponentialIn:"0.7,0,0.84,0",exponentialOut:"0.16,1,0.3,1",exponentialInOut:"0.87,0,0.13,1",circularIn:"0.55,0,1,0.45",circularOut:"0,0.55,0.45,1",circularInOut:"0.85,0,0.15,1"},Tu="transform-origin";function rZ(r,t,e){var a=lt({},r.shape);lt(a,t),r.buildPath(e,a);var i=new IN;return i.reset(g4(r)),e.rebuildPath(i,1),i.generateStr(),i.getStr()}function aZ(r,t){var e=t.originX,a=t.originY;(e||a)&&(r[Tu]=e+"px "+a+"px")}var iZ={fill:"fill",opacity:"opacity",lineWidth:"stroke-width",lineDashOffset:"stroke-dashoffset"};function ON(r,t){var e=t.zrId+"-ani-"+t.cssAnimIdx++;return t.cssAnims[e]=r,e}function nZ(r,t,e){var a=r.shape.paths,i={},n,o;if(R(a,function(l){var u=t2(e.zrId);u.animation=!0,R_(l,{},u,!0);var v=u.cssAnims,c=u.cssNodes,d=xr(v),f=d.length;if(f){o=d[f-1];var h=v[o];for(var g in h){var m=h[g];i[g]=i[g]||{d:""},i[g].d+=m.d||""}for(var x in c){var b=c[x].animation;b.indexOf(o)>=0&&(n=b)}}}),!!n){t.d=!1;var s=ON(i,e);return n.replace(o,s)}}function dM(r){return Pt(r)?cM[r]?"cubic-bezier("+cM[r]+")":_S(r)?r:"":""}function R_(r,t,e,a){var i=r.animators,n=i.length,o=[];if(r instanceof ES){var s=nZ(r,t,e);if(s)o.push(s);else if(!n)return}else if(!n)return;for(var l={},u=0;u0}).length){var Ot=ON(k,e);return Ot+" "+b[0]+" both"}}for(var m in l){var s=g(l[m]);s&&o.push(s)}if(o.length){var x=e.zrId+"-cls-"+NN();e.cssNodes["."+x]={animation:o.join(",")},t.class=x}}function oZ(r,t,e){if(!r.ignore)if(r.isSilent()){var a={"pointer-events":"none"};fM(a,t,e)}else{var i=r.states.emphasis&&r.states.emphasis.style?r.states.emphasis.style:{},n=i.fill;if(!n){var o=r.style&&r.style.fill,s=r.states.select&&r.states.select.style&&r.states.select.style.fill,l=r.currentStates.indexOf("select")>=0&&s||o;l&&(n=dw(l))}var u=i.lineWidth;if(u){var v=!i.strokeNoScale&&r.transform?r.transform[0]:1;u=u/v}var a={cursor:"pointer"};n&&(a.fill=n),i.stroke&&(a.stroke=i.stroke),u&&(a["stroke-width"]=u),fM(a,t,e)}}function fM(r,t,e,a){var i=JSON.stringify(r),n=e.cssStyleCache[i];n||(n=e.zrId+"-cls-"+NN(),e.cssStyleCache[i]=n,e.cssNodes["."+n+":hover"]=r),t.class=t.class?t.class+" "+n:n}var yh=Math.round;function zN(r){return r&&Pt(r.src)}function BN(r){return r&&le(r.toDataURL)}function pT(r,t,e,a){XY(function(i,n){var o=i==="fill"||i==="stroke";o&&p4(n)?FN(t,r,i,a):o&&xS(n)?GN(e,r,i,a):r[i]=n,o&&a.ssr&&n==="none"&&(r["pointer-events"]="visible")},t,e,!1),fZ(e,r,a)}function gT(r,t){var e=a9(t);e&&(e.each(function(a,i){a!=null&&(r[(uM+i).toLowerCase()]=a+"")}),t.isSilent()&&(r[uM+"silent"]="true"))}function hM(r){return wl(r[0]-1)&&wl(r[1])&&wl(r[2])&&wl(r[3]-1)}function sZ(r){return wl(r[4])&&wl(r[5])}function yT(r,t,e){if(t&&!(sZ(t)&&hM(t))){var a=1e4;r.transform=hM(t)?"translate("+yh(t[4]*a)/a+" "+yh(t[5]*a)/a+")":C8(t)}}function pM(r,t,e){for(var a=r.points,i=[],n=0;n"u"){var m="Image width/height must been given explictly in svg-ssr renderer.";Vi(d,m),Vi(f,m)}else if(d==null||f==null){var x=function(L,I){if(L){var P=L.elm,E=d||I.width,N=f||I.height;L.tag==="pattern"&&(u?(N=1,E/=n.width):v&&(E=1,N/=n.height)),L.attrs.width=E,L.attrs.height=N,P&&(P.setAttribute("width",E),P.setAttribute("height",N))}},b=CS(h,null,r,function(L){l||x(T,L),x(c,L)});b&&b.width&&b.height&&(d=d||b.width,f=f||b.height)}c=ti("image","img",{href:h,width:d,height:f}),o.width=d,o.height=f}else i.svgElement&&(c=be(i.svgElement),o.width=i.svgWidth,o.height=i.svgHeight);if(c){var w,S;l?w=S=1:u?(S=1,w=o.width/n.width):v?(w=1,S=o.height/n.height):o.patternUnits="userSpaceOnUse",w!=null&&!isNaN(w)&&(o.width=w),S!=null&&!isNaN(S)&&(o.height=S);var C=y4(i);C&&(o.patternTransform=C);var T=ti("pattern","",o,[c]),k=hT(T),D=a.patternCache,M=D[k];M||(M=a.zrId+"-p"+a.patternIdx++,D[k]=M,o.id=M,T=a.defs[M]=ti("pattern",M,o,[c])),t[e]=a_(M)}}function hZ(r,t,e){var a=e.clipPathCache,i=e.defs,n=a[r.id];if(!n){n=e.zrId+"-c"+e.clipPathIdx++;var o={id:n};a[r.id]=n,i[n]=ti("clipPath",n,o,[VN(r,e)])}t["clip-path"]=a_(n)}function mM(r){return document.createTextNode(r)}function Iu(r,t,e){r.insertBefore(t,e)}function _M(r,t){r.removeChild(t)}function xM(r,t){r.appendChild(t)}function HN(r){return r.parentNode}function WN(r){return r.nextSibling}function Dx(r,t){r.textContent=t}var bM=58,pZ=120,gZ=ti("","");function e2(r){return r===void 0}function Ho(r){return r!==void 0}function yZ(r,t,e){for(var a={},i=t;i<=e;++i){var n=r[i].key;n!==void 0&&(a[n]=i)}return a}function Af(r,t){var e=r.key===t.key,a=r.tag===t.tag;return a&&e}function mh(r){var t,e=r.children,a=r.tag;if(Ho(a)){var i=r.elm=EN(a);if(mT(gZ,r),ht(e))for(t=0;tn?(h=e[l+1]==null?null:e[l+1].elm,UN(r,h,e,i,l)):mm(r,t,a,n))}function cc(r,t){var e=t.elm=r.elm,a=r.children,i=t.children;r!==t&&(mT(r,t),e2(t.text)?Ho(a)&&Ho(i)?a!==i&&mZ(e,a,i):Ho(i)?(Ho(r.text)&&Dx(e,""),UN(e,null,i,0,i.length-1)):Ho(a)?mm(e,a,0,a.length-1):Ho(r.text)&&Dx(e,""):r.text!==t.text&&(Ho(a)&&mm(e,a,0,a.length-1),Dx(e,t.text)))}function _Z(r,t){if(Af(r,t))cc(r,t);else{var e=r.elm,a=HN(e);mh(t),a!==null&&(Iu(a,t.elm,WN(e)),mm(a,[r],0,0))}return t}var xZ=0,bZ=function(){function r(t,e,a){if(this.type="svg",this.refreshHover=wM(),this.configLayer=wM(),this.storage=e,this._opts=a=lt({},a),this.root=t,this._id="zr"+xZ++,this._oldVNode=vM(a.width,a.height),t&&!a.ssr){var i=this._viewport=document.createElement("div");i.style.cssText="position:relative;overflow:hidden";var n=this._svgDom=this._oldVNode.elm=EN("svg");mT(null,this._oldVNode),i.appendChild(n),t.appendChild(i)}this.resize(a.width,a.height)}return r.prototype.getType=function(){return this.type},r.prototype.getViewportRoot=function(){return this._viewport},r.prototype.getViewportRootOffset=function(){var t=this.getViewportRoot();if(t)return{offsetLeft:t.offsetLeft||0,offsetTop:t.offsetTop||0}},r.prototype.getSvgDom=function(){return this._svgDom},r.prototype.refresh=function(){if(this.root){var t=this.renderToVNode({willUpdate:!0});t.attrs.style="position:absolute;left:0;top:0;user-select:none",_Z(this._oldVNode,t),this._oldVNode=t}},r.prototype.renderOneToVNode=function(t){return yM(t,t2(this._id))},r.prototype.renderToVNode=function(t){t=t||{};var e=this.storage.getDisplayList(!0),a=this._width,i=this._height,n=t2(this._id);n.animation=t.animation,n.willUpdate=t.willUpdate,n.compress=t.compress,n.emphasis=t.emphasis,n.ssr=this._opts.ssr;var o=[],s=this._bgVNode=wZ(a,i,this._backgroundColor,n);s&&o.push(s);var l=t.compress?null:this._mainVNode=ti("g","main",{},[]);this._paintList(e,n,l?l.children:o),l&&o.push(l);var u=pt(xr(n.defs),function(d){return n.defs[d]});if(u.length&&o.push(ti("defs","defs",{},u)),t.animation){var v=tZ(n.cssNodes,n.cssAnims,{newline:!0});if(v){var c=ti("style","stl",{},[],v);o.push(c)}}return vM(a,i,o,t.useViewBox)},r.prototype.renderToString=function(t){return t=t||{},hT(this.renderToVNode({animation:Be(t.cssAnimation,!0),emphasis:Be(t.cssEmphasis,!0),willUpdate:!1,compress:!0,useViewBox:Be(t.useViewBox,!0)}),{newline:!0})},r.prototype.setBackgroundColor=function(t){this._backgroundColor=t},r.prototype.getSvgRoot=function(){return this._mainVNode&&this._mainVNode.elm},r.prototype._paintList=function(t,e,a){for(var i=t.length,n=[],o=0,s,l,u=0,v=0;v=0&&!(d&&l&&d[g]===l[g]);g--);for(var m=h-1;m>g;m--)o--,s=n[o-1];for(var x=g+1;x=s)}}for(var c=this.__startIndex;c15)break}}N.prevElClipPaths&&x.restore()};if(b)if(b.length===0)D=m.__endIndex;else for(var L=f.dpr,I=0;I0&&t>i[0]){for(l=0;lt);l++);s=a[i[l]]}if(i.splice(l+1,0,t),a[t]=e,!e.virtual)if(s){var u=s.dom;u.nextSibling?o.insertBefore(e.dom,u.nextSibling):o.appendChild(e.dom)}else o.firstChild?o.insertBefore(e.dom,o.firstChild):o.appendChild(e.dom);e.painter||(e.painter=this)}},r.prototype.eachLayer=function(t,e){for(var a=this._zlevelList,i=0;i0?Zg:0),this._needsManuallyCompositing),v.__builtin__||dS("ZLevel "+u+" has been used by unkown layer "+v.id),v!==n&&(v.__used=!0,v.__startIndex!==l&&(v.__dirty=!0),v.__startIndex=l,v.incremental?v.__drawIndex=-1:v.__drawIndex=l,e(l),n=v),i.__dirty&dn&&!i.__inHover&&(v.__dirty=!0,v.incremental&&v.__drawIndex<0&&(v.__drawIndex=l))}e(l),this.eachBuiltinLayer(function(c,d){!c.__used&&c.getElementCount()>0&&(c.__dirty=!0,c.__startIndex=c.__endIndex=c.__drawIndex=0),c.__dirty&&c.__drawIndex<0&&(c.__drawIndex=c.__startIndex)})},r.prototype.clear=function(){return this.eachBuiltinLayer(this._clearLayer),this},r.prototype._clearLayer=function(t){t.clear()},r.prototype.setBackgroundColor=function(t){this._backgroundColor=t,R(this._layers,function(e){e.setUnpainted()})},r.prototype.configLayer=function(t,e){if(e){var a=this._layerConfig;a[t]?Ze(a[t],e,!0):a[t]=e;for(var i=0;i-1&&(u.style.stroke=u.style.fill,u.style.fill="#fff",u.style.lineWidth=2),a},t.type="series.line",t.dependencies=["grid","polar"],t.defaultOption={z:3,coordinateSystem:"cartesian2d",legendHoverLink:!0,clip:!0,label:{position:"top"},endLabel:{show:!1,valueAnimation:!0,distance:8},lineStyle:{width:2,type:"solid"},emphasis:{scale:!0},step:!1,smooth:!1,smoothMonotone:null,symbol:"emptyCircle",symbolSize:4,symbolRotate:null,showSymbol:!0,showAllSymbol:"auto",connectNulls:!1,sampling:"none",animationEasing:"linear",progressive:0,hoverLayerThreshold:1/0,universalTransition:{divideShape:"clone"},triggerLineEvent:!1},t}(ma);function jc(r,t){var e=r.mapDimensionsAll("defaultedLabel"),a=e.length;if(a===1){var i=Yc(r,t,e[0]);return i!=null?i+"":null}else if(a){for(var n=[],o=0;o=0&&a.push(t[n])}return a.join(" ")}var Yh=function(r){tt(t,r);function t(e,a,i,n){var o=r.call(this)||this;return o.updateData(e,a,i,n),o}return t.prototype._createSymbol=function(e,a,i,n,o){this.removeAll();var s=qa(e,-1,-1,2,2,null,o);s.attr({z2:100,culling:!0,scaleX:n[0]/2,scaleY:n[1]/2}),s.drift=LZ,this._symbolType=e,this.add(s)},t.prototype.stopSymbolAnimation=function(e){this.childAt(0).stopAnimation(null,e)},t.prototype.getSymbolType=function(){return this._symbolType},t.prototype.getSymbolPath=function(){return this.childAt(0)},t.prototype.highlight=function(){Bs(this.childAt(0))},t.prototype.downplay=function(){Vs(this.childAt(0))},t.prototype.setZ=function(e,a){var i=this.childAt(0);i.zlevel=e,i.z=a},t.prototype.setDraggable=function(e,a){var i=this.childAt(0);i.draggable=e,i.cursor=!a&&e?"move":i.cursor},t.prototype.updateData=function(e,a,i,n){this.silent=!1;var o=e.getItemVisual(a,"symbol")||"circle",s=e.hostModel,l=t.getSymbolSize(e,a),u=o!==this._symbolType,v=n&&n.disableAnimation;if(u){var c=e.getItemVisual(a,"symbolKeepAspect");this._createSymbol(o,e,a,l,c)}else{var d=this.childAt(0);d.silent=!1;var f={scaleX:l[0]/2,scaleY:l[1]/2};v?d.attr(f):Gr(d,f,s,a),Co(d)}if(this._updateCommon(e,a,l,i,n),u){var d=this.childAt(0);if(!v){var f={scaleX:this._sizeX,scaleY:this._sizeY,style:{opacity:d.style.opacity}};d.scaleX=d.scaleY=0,d.style.opacity=0,Ta(d,f,s,a)}}v&&this.childAt(0).stopAnimation("leave")},t.prototype._updateCommon=function(e,a,i,n,o){var s=this.childAt(0),l=e.hostModel,u,v,c,d,f,h,g,m,x;if(n&&(u=n.emphasisItemStyle,v=n.blurItemStyle,c=n.selectItemStyle,d=n.focus,f=n.blurScope,g=n.labelStatesModels,m=n.hoverScale,x=n.cursorStyle,h=n.emphasisDisabled),!n||e.hasItemOption){var b=n&&n.itemModel?n.itemModel:e.getItemModel(a),w=b.getModel("emphasis");u=w.getModel("itemStyle").getItemStyle(),c=b.getModel(["select","itemStyle"]).getItemStyle(),v=b.getModel(["blur","itemStyle"]).getItemStyle(),d=w.get("focus"),f=w.get("blurScope"),h=w.get("disabled"),g=ai(b),m=w.getShallow("scale"),x=b.getShallow("cursor")}var S=e.getItemVisual(a,"symbolRotate");s.attr("rotation",(S||0)*Math.PI/180||0);var C=Mv(e.getItemVisual(a,"symbolOffset"),i);C&&(s.x=C[0],s.y=C[1]),x&&s.attr("cursor",x);var T=e.getItemVisual(a,"style"),k=T.fill;if(s instanceof ui){var D=s.style;s.useStyle(lt({image:D.image,x:D.x,y:D.y,width:D.width,height:D.height},T))}else s.__isEmptyBrush?s.useStyle(lt({},T)):s.useStyle(T),s.style.decal=null,s.setColor(k,o&&o.symbolInnerColor),s.style.strokeNoScale=!0;var M=e.getItemVisual(a,"liftZ"),L=this._z2;M!=null?L==null&&(this._z2=s.z2,s.z2+=M):L!=null&&(s.z2=L,this._z2=null);var I=o&&o.useNameLabel;mi(s,g,{labelFetcher:l,labelDataIndex:a,defaultText:P,inheritColor:k,defaultOpacity:T.opacity});function P(O){return I?e.getName(O):jc(e,O)}this._sizeX=i[0]/2,this._sizeY=i[1]/2;var E=s.ensureState("emphasis");E.style=u,s.ensureState("select").style=c,s.ensureState("blur").style=v;var N=m==null||m===!0?Math.max(1.1,3/this._sizeY):isFinite(m)&&m>0?+m:1;E.scaleX=this._sizeX*N,E.scaleY=this._sizeY*N,this.setSymbolScale(1),Ia(this,d,f,h)},t.prototype.setSymbolScale=function(e){this.scaleX=this.scaleY=e},t.prototype.fadeOut=function(e,a,i){var n=this.childAt(0),o=Ie(this).dataIndex,s=i&&i.animation;if(this.silent=n.silent=!0,i&&i.fadeLabel){var l=n.getTextContent();l&&Ol(l,{style:{opacity:0}},a,{dataIndex:o,removeOpt:s,cb:function(){n.removeTextContent()}})}else n.removeTextContent();Ol(n,{style:{opacity:0},scaleX:0,scaleY:0},a,{dataIndex:o,cb:e,removeOpt:s})},t.getSymbolSize=function(e,a){return pd(e.getItemVisual(a,"symbolSize"))},t}(Ae);function LZ(r,t){this.parent.drift(r,t)}function Lx(r,t,e,a){return t&&!isNaN(t[0])&&!isNaN(t[1])&&!(a.isIgnore&&a.isIgnore(e))&&!(a.clipShape&&!a.clipShape.contain(t[0],t[1]))&&r.getItemVisual(e,"symbol")!=="none"}function AM(r){return r!=null&&!pe(r)&&(r={isIgnore:r}),r||{}}function CM(r){var t=r.hostModel,e=t.getModel("emphasis");return{emphasisItemStyle:e.getModel("itemStyle").getItemStyle(),blurItemStyle:t.getModel(["blur","itemStyle"]).getItemStyle(),selectItemStyle:t.getModel(["select","itemStyle"]).getItemStyle(),focus:e.get("focus"),blurScope:e.get("blurScope"),emphasisDisabled:e.get("disabled"),hoverScale:e.get("scale"),labelStatesModels:ai(t),cursorStyle:t.get("cursor")}}var Zh=function(){function r(t){this.group=new Ae,this._SymbolCtor=t||Yh}return r.prototype.updateData=function(t,e){this._progressiveEls=null,e=AM(e);var a=this.group,i=t.hostModel,n=this._data,o=this._SymbolCtor,s=e.disableAnimation,l=CM(t),u={disableAnimation:s},v=e.getSymbolPoint||function(c){return t.getItemLayout(c)};n||a.removeAll(),t.diff(n).add(function(c){var d=v(c);if(Lx(t,d,c,e)){var f=new o(t,c,l,u);f.setPosition(d),t.setItemGraphicEl(c,f),a.add(f)}}).update(function(c,d){var f=n.getItemGraphicEl(d),h=v(c);if(!Lx(t,h,c,e)){a.remove(f);return}var g=t.getItemVisual(c,"symbol")||"circle",m=f&&f.getSymbolType&&f.getSymbolType();if(!f||m&&m!==g)a.remove(f),f=new o(t,c,l,u),f.setPosition(h);else{f.updateData(t,c,l,u);var x={x:h[0],y:h[1]};s?f.attr(x):Gr(f,x,i)}a.add(f),t.setItemGraphicEl(c,f)}).remove(function(c){var d=n.getItemGraphicEl(c);d&&d.fadeOut(function(){a.remove(d)},i)}).execute(),this._getSymbolPoint=v,this._data=t},r.prototype.updateLayout=function(){var t=this,e=this._data;e&&e.eachItemGraphicEl(function(a,i){var n=t._getSymbolPoint(i);a.setPosition(n),a.markRedraw()})},r.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=CM(t),this._data=null,this.group.removeAll()},r.prototype.incrementalUpdate=function(t,e,a){this._progressiveEls=[],a=AM(a);function i(l){l.isGroup||(l.incremental=!0,l.ensureState("emphasis").hoverLayer=!0)}for(var n=t.start;n0?e=a[0]:a[1]<0&&(e=a[1]),e}function YN(r,t,e,a){var i=NaN;r.stacked&&(i=e.get(e.getCalculationInfo("stackedOverDimension"),a)),isNaN(i)&&(i=r.valueStart);var n=r.baseDataOffset,o=[];return o[n]=e.get(r.baseDim,a),o[1-n]=i,t.dataToPoint(o)}function RZ(r,t){var e=[];return t.diff(r).add(function(a){e.push({cmd:"+",idx:a})}).update(function(a,i){e.push({cmd:"=",idx:i,idx1:a})}).remove(function(a){e.push({cmd:"-",idx:a})}).execute(),e}function PZ(r,t,e,a,i,n,o,s){for(var l=RZ(r,t),u=[],v=[],c=[],d=[],f=[],h=[],g=[],m=$N(i,t,o),x=r.getLayout("points")||[],b=t.getLayout("points")||[],w=0;w=i||g<0)break;if(lv(x,b)){if(l){g+=n;continue}break}if(g===e)r[n>0?"moveTo":"lineTo"](x,b),c=x,d=b;else{var w=x-u,S=b-v;if(w*w+S*S<.5){g+=n;continue}if(o>0){for(var C=g+n,T=t[C*2],k=t[C*2+1];T===x&&k===b&&m=a||lv(T,k))f=x,h=b;else{L=T-u,I=k-v;var N=x-u,O=T-x,V=b-v,B=k-b,z=void 0,H=void 0;if(s==="x"){z=Math.abs(N),H=Math.abs(O);var Y=L>0?1:-1;f=x-Y*z*o,h=b,P=x+Y*H*o,E=b}else if(s==="y"){z=Math.abs(V),H=Math.abs(B);var $=I>0?1:-1;f=x,h=b-$*z*o,P=x,E=b+$*H*o}else z=Math.sqrt(N*N+V*V),H=Math.sqrt(O*O+B*B),M=H/(H+z),f=x-L*o*(1-M),h=b-I*o*(1-M),P=x+L*o*M,E=b+I*o*M,P=ol(P,sl(T,x)),E=ol(E,sl(k,b)),P=sl(P,ol(T,x)),E=sl(E,ol(k,b)),L=P-x,I=E-b,f=x-L*z/H,h=b-I*z/H,f=ol(f,sl(u,x)),h=ol(h,sl(v,b)),f=sl(f,ol(u,x)),h=sl(h,ol(v,b)),L=x-f,I=b-h,P=x+L*H/z,E=b+I*H/z}r.bezierCurveTo(c,d,f,h,x,b),c=P,d=E}else r.lineTo(x,b)}u=x,v=b,g+=n}return m}var ZN=function(){function r(){this.smooth=0,this.smoothConstraint=!0}return r}(),EZ=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;return a.type="ec-polyline",a}return t.prototype.getDefaultStyle=function(){return{stroke:"#000",fill:null}},t.prototype.getDefaultShape=function(){return new ZN},t.prototype.buildPath=function(e,a){var i=a.points,n=0,o=i.length/2;if(a.connectNulls){for(;o>0&&lv(i[o*2-2],i[o*2-1]);o--);for(;n=0){var S=u?(h-l)*w+l:(f-s)*w+s;return u?[e,S]:[S,e]}s=f,l=h;break;case o.C:f=n[c++],h=n[c++],g=n[c++],m=n[c++],x=n[c++],b=n[c++];var C=u?Yy(s,f,g,x,e,v):Yy(l,h,m,b,e,v);if(C>0)for(var T=0;T=0){var S=u?Ka(l,h,m,b,k):Ka(s,f,g,x,k);return u?[e,S]:[S,e]}}s=x,l=b;break}}},t}(ur),NZ=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t}(ZN),XN=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;return a.type="ec-polygon",a}return t.prototype.getDefaultShape=function(){return new NZ},t.prototype.buildPath=function(e,a){var i=a.points,n=a.stackedOnPoints,o=0,s=i.length/2,l=a.smoothMonotone;if(a.connectNulls){for(;s>0&&lv(i[s*2-2],i[s*2-1]);s--);for(;ot){n?e.push(o(n,l,t)):i&&e.push(o(i,l,0),o(i,l,t));break}else i&&(e.push(o(i,l,0)),i=null),e.push(l),n=l}return e}function BZ(r,t,e){var a=r.getVisual("visualMeta");if(!(!a||!a.length||!r.count())&&t.type==="cartesian2d"){for(var i,n,o=a.length-1;o>=0;o--){var s=r.getDimensionInfo(a[o].dimension);if(i=s&&s.coordDim,i==="x"||i==="y"){n=a[o];break}}if(n){var l=t.getAxis(i),u=pt(n.stops,function(w){return{coord:l.toGlobalCoord(l.dataToCoord(w.value)),color:w.color}}),v=u.length,c=n.outerColors.slice();v&&u[0].coord>u[v-1].coord&&(u.reverse(),c.reverse());var d=zZ(u,i==="x"?e.getWidth():e.getHeight()),f=d.length;if(!f&&v)return u[0].coord<0?c[1]?c[1]:u[v-1].color:c[0]?c[0]:u[0].color;var h=10,g=d[0].coord-h,m=d[f-1].coord+h,x=m-g;if(x<.001)return"transparent";R(d,function(w){w.offset=(w.coord-g)/x}),d.push({offset:f?d[f-1].offset:.5,color:c[1]||"transparent"}),d.unshift({offset:f?d[0].offset:.5,color:c[0]||"transparent"});var b=new Fh(0,0,0,0,d,!0);return b[i]=g,b[i+"2"]=m,b}}}function VZ(r,t,e){var a=r.get("showAllSymbol"),i=a==="auto";if(!(a&&!i)){var n=e.getAxesByScale("ordinal")[0];if(n&&!(i&&FZ(n,t))){var o=t.mapDimension(n.dim),s={};return R(n.getViewLabels(),function(l){var u=n.scale.getRawOrdinalNumber(l.tickValue);s[u]=1}),function(l){return!s.hasOwnProperty(t.get(o,l))}}}}function FZ(r,t){var e=r.getExtent(),a=Math.abs(e[1]-e[0])/r.scale.count();isNaN(a)&&(a=0);for(var i=t.count(),n=Math.max(1,Math.round(i/5)),o=0;oa)return!1;return!0}function GZ(r,t){return isNaN(r)||isNaN(t)}function HZ(r){for(var t=r.length/2;t>0&&GZ(r[t*2-2],r[t*2-1]);t--);return t-1}function IM(r,t){return[r[t*2],r[t*2+1]]}function WZ(r,t,e){for(var a=r.length/2,i=e==="x"?0:1,n,o,s=0,l=-1,u=0;u=t||n>=t&&o<=t){l=u;break}s=u,n=o}return{range:[s,l],t:(t-n)/(o-n)}}function QN(r){if(r.get(["endLabel","show"]))return!0;for(var t=0;t0&&e.get(["emphasis","lineStyle","width"])==="bolder"){var H=h.getState("emphasis").style;H.lineWidth=+h.style.lineWidth+1}Ie(h).seriesIndex=e.seriesIndex,Ia(h,V,B,z);var Y=LM(e.get("smooth")),$=e.get("smoothMonotone");if(h.setShape({smooth:Y,smoothMonotone:$,connectNulls:k}),g){var Z=s.getCalculationInfo("stackedOnSeries"),Q=0;g.useStyle(ve(u.getAreaStyle(),{fill:P,opacity:.7,lineJoin:"bevel",decal:s.getVisual("style").decal})),Z&&(Q=LM(Z.get("smooth"))),g.setShape({smooth:Y,stackedOnSmooth:Q,smoothMonotone:$,connectNulls:k}),yi(g,e,"areaStyle"),Ie(g).seriesIndex=e.seriesIndex,Ia(g,V,B,z)}var at=this._changePolyState;s.eachItemGraphicEl(function(et){et&&(et.onHoverStateChange=at)}),this._polyline.onHoverStateChange=at,this._data=s,this._coordSys=n,this._stackedOnPoints=C,this._points=v,this._step=L,this._valueOrigin=w,e.get("triggerLineEvent")&&(this.packEventData(e,h),g&&this.packEventData(e,g))},t.prototype.packEventData=function(e,a){Ie(a).eventData={componentType:"series",componentSubType:"line",componentIndex:e.componentIndex,seriesIndex:e.seriesIndex,seriesName:e.name,seriesType:"line"}},t.prototype.highlight=function(e,a,i,n){var o=e.getData(),s=gv(o,n);if(this._changePolyState("emphasis"),!(s instanceof Array)&&s!=null&&s>=0){var l=o.getLayout("points"),u=o.getItemGraphicEl(s);if(!u){var v=l[s*2],c=l[s*2+1];if(isNaN(v)||isNaN(c)||this._clipShapeForSymbol&&!this._clipShapeForSymbol.contain(v,c))return;var d=e.get("zlevel")||0,f=e.get("z")||0;u=new Yh(o,s),u.x=v,u.y=c,u.setZ(d,f);var h=u.getSymbolPath().getTextContent();h&&(h.zlevel=d,h.z=f,h.z2=this._polyline.z2+1),u.__temp=!0,o.setItemGraphicEl(s,u),u.stopSymbolAnimation(!0),this.group.add(u)}u.highlight()}else ca.prototype.highlight.call(this,e,a,i,n)},t.prototype.downplay=function(e,a,i,n){var o=e.getData(),s=gv(o,n);if(this._changePolyState("normal"),s!=null&&s>=0){var l=o.getItemGraphicEl(s);l&&(l.__temp?(o.setItemGraphicEl(s,null),this.group.remove(l)):l.downplay())}else ca.prototype.downplay.call(this,e,a,i,n)},t.prototype._changePolyState=function(e){var a=this._polygon;tm(this._polyline,e),a&&tm(a,e)},t.prototype._newPolyline=function(e){var a=this._polyline;return a&&this._lineGroup.remove(a),a=new EZ({shape:{points:e},segmentIgnoreThreshold:2,z2:10}),this._lineGroup.add(a),this._polyline=a,a},t.prototype._newPolygon=function(e,a){var i=this._polygon;return i&&this._lineGroup.remove(i),i=new XN({shape:{points:e,stackedOnPoints:a},segmentIgnoreThreshold:2}),this._lineGroup.add(i),this._polygon=i,i},t.prototype._initSymbolLabelAnimation=function(e,a,i){var n,o,s=a.getBaseAxis(),l=s.inverse;a.type==="cartesian2d"?(n=s.isHorizontal(),o=!1):a.type==="polar"&&(n=s.dim==="angle",o=!0);var u=e.hostModel,v=u.get("animationDuration");le(v)&&(v=v(null));var c=u.get("animationDelay")||0,d=le(c)?c(null):c;e.eachItemGraphicEl(function(f,h){var g=f;if(g){var m=[f.x,f.y],x=void 0,b=void 0,w=void 0;if(i)if(o){var S=i,C=a.pointToCoord(m);n?(x=S.startAngle,b=S.endAngle,w=-C[1]/180*Math.PI):(x=S.r0,b=S.r,w=C[0])}else{var T=i;n?(x=T.x,b=T.x+T.width,w=f.x):(x=T.y+T.height,b=T.y,w=f.y)}var k=b===x?0:(w-x)/(b-x);l&&(k=1-k);var D=le(c)?c(h):v*k+d,M=g.getSymbolPath(),L=M.getTextContent();g.attr({scaleX:0,scaleY:0}),g.animateTo({scaleX:1,scaleY:1},{duration:200,setToFinal:!0,delay:D}),L&&L.animateFrom({style:{opacity:0}},{duration:300,delay:D}),M.disableLabelAnimation=!0}})},t.prototype._initOrUpdateEndLabel=function(e,a,i){var n=e.getModel("endLabel");if(QN(e)){var o=e.getData(),s=this._polyline,l=o.getLayout("points");if(!l){s.removeTextContent(),this._endLabel=null;return}var u=this._endLabel;u||(u=this._endLabel=new Rr({z2:200}),u.ignoreClip=!0,s.setTextContent(this._endLabel),s.disableLabelAnimation=!0);var v=HZ(l);v>=0&&(mi(s,ai(e,"endLabel"),{inheritColor:i,labelFetcher:e,labelDataIndex:v,defaultText:function(c,d,f){return f!=null?qN(o,f):jc(o,c)},enableTextSetter:!0},UZ(n,a)),s.textConfig.position=null)}else this._endLabel&&(this._polyline.removeTextContent(),this._endLabel=null)},t.prototype._endLabelOnDuring=function(e,a,i,n,o,s,l){var u=this._endLabel,v=this._polyline;if(u){e<1&&n.originalX==null&&(n.originalX=u.x,n.originalY=u.y);var c=i.getLayout("points"),d=i.hostModel,f=d.get("connectNulls"),h=s.get("precision"),g=s.get("distance")||0,m=l.getBaseAxis(),x=m.isHorizontal(),b=m.inverse,w=a.shape,S=b?x?w.x:w.y+w.height:x?w.x+w.width:w.y,C=(x?g:0)*(b?-1:1),T=(x?0:-g)*(b?-1:1),k=x?"x":"y",D=WZ(c,S,k),M=D.range,L=M[1]-M[0],I=void 0;if(L>=1){if(L>1&&!f){var P=IM(c,M[0]);u.attr({x:P[0]+C,y:P[1]+T}),o&&(I=d.getRawValue(M[0]))}else{var P=v.getPointOn(S,k);P&&u.attr({x:P[0]+C,y:P[1]+T});var E=d.getRawValue(M[0]),N=d.getRawValue(M[1]);o&&(I=O4(i,h,E,N,D.t))}n.lastFrameIndex=M[0]}else{var O=e===1||n.lastFrameIndex>0?M[0]:0,P=IM(c,O);o&&(I=d.getRawValue(O)),u.attr({x:P[0]+C,y:P[1]+T})}if(o){var V=ld(u);typeof V.setLabelText=="function"&&V.setLabelText(I)}}},t.prototype._doUpdateAnimation=function(e,a,i,n,o,s,l){var u=this._polyline,v=this._polygon,c=e.hostModel,d=PZ(this._data,e,this._stackedOnPoints,a,this._coordSys,i,this._valueOrigin),f=d.current,h=d.stackedOnCurrent,g=d.next,m=d.stackedOnNext;if(o&&(h=ll(d.stackedOnCurrent,d.current,i,o,l),f=ll(d.current,null,i,o,l),m=ll(d.stackedOnNext,d.next,i,o,l),g=ll(d.next,null,i,o,l)),MM(f,g)>3e3||v&&MM(h,m)>3e3){u.stopAnimation(),u.setShape({points:g}),v&&(v.stopAnimation(),v.setShape({points:g,stackedOnPoints:m}));return}u.shape.__points=d.current,u.shape.points=f;var x={shape:{points:g}};d.current!==f&&(x.shape.__points=d.next),u.stopAnimation(),Gr(u,x,c),v&&(v.setShape({points:f,stackedOnPoints:h}),v.stopAnimation(),Gr(v,{shape:{stackedOnPoints:m}},c),u.shape.points!==v.shape.points&&(v.shape.points=u.shape.points));for(var b=[],w=d.status,S=0;St&&(t=r[e]);return isFinite(t)?t:NaN},min:function(r){for(var t=1/0,e=0;e10&&o.type==="cartesian2d"&&n){var l=o.getBaseAxis(),u=o.getOtherAxis(l),v=l.getExtent(),c=a.getDevicePixelRatio(),d=Math.abs(v[1]-v[0])*(c||1),f=Math.round(s/d);if(isFinite(f)&&f>1){n==="lttb"?t.setData(i.lttbDownSample(i.mapDimension(u.dim),1/f)):n==="minmax"&&t.setData(i.minmaxDownSample(i.mapDimension(u.dim),1/f));var h=void 0;Pt(n)?h=$Z[n]:le(n)&&(h=n),h&&t.setData(i.downSample(i.mapDimension(u.dim),1/f,h,YZ))}}}}}function ZZ(r){r.registerChartView(qZ),r.registerSeriesModel(MZ),r.registerLayout(jh("line",!0)),r.registerVisual({seriesType:"line",reset:function(t){var e=t.getData(),a=t.getModel("lineStyle").getLineStyle();a&&!a.stroke&&(a.stroke=e.getVisual("style").fill),e.setVisual("legendLineStyle",a)}}),r.registerProcessor(r.PRIORITY.PROCESSOR.STATISTIC,JN("line"))}var _h=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.getInitialData=function(e,a){return Ys(null,this,{useEncodeDefaulter:!0})},t.prototype.getMarkerPosition=function(e,a,i){var n=this.coordinateSystem;if(n&&n.clampData){var o=n.clampData(e),s=n.dataToPoint(o);if(i)R(n.getAxes(),function(d,f){if(d.type==="category"&&a!=null){var h=d.getTicksCoords(),g=d.getTickModel().get("alignWithLabel"),m=o[f],x=a[f]==="x1"||a[f]==="y1";if(x&&!g&&(m+=1),h.length<2)return;if(h.length===2){s[f]=d.toGlobalCoord(d.getExtent()[x?1:0]);return}for(var b=void 0,w=void 0,S=1,C=0;Cm){w=(T+b)/2;break}C===1&&(S=k-h[0].tickValue)}w==null&&(b?b&&(w=h[h.length-1].coord):w=h[0].coord),s[f]=d.toGlobalCoord(w)}});else{var l=this.getData(),u=l.getLayout("offset"),v=l.getLayout("size"),c=n.getBaseAxis().isHorizontal()?0:1;s[c]+=u+v/2}return s}return[NaN,NaN]},t.type="series.__base_bar__",t.defaultOption={z:2,coordinateSystem:"cartesian2d",legendHoverLink:!0,barMinHeight:0,barMinAngle:0,large:!1,largeThreshold:400,progressive:3e3,progressiveChunkMode:"mod"},t}(ma);ma.registerClass(_h);var XZ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.getInitialData=function(){return Ys(null,this,{useEncodeDefaulter:!0,createInvertedIndices:!!this.get("realtimeSort",!0)||null})},t.prototype.getProgressive=function(){return this.get("large")?this.get("progressive"):!1},t.prototype.getProgressiveThreshold=function(){var e=this.get("progressiveThreshold"),a=this.get("largeThreshold");return a>e&&(e=a),e},t.prototype.brushSelector=function(e,a,i){return i.rect(a.getItemLayout(e))},t.type="series.bar",t.dependencies=["grid","polar"],t.defaultOption=Ul(_h.defaultOption,{clip:!0,roundCap:!1,showBackground:!1,backgroundStyle:{color:"rgba(180, 180, 180, 0.2)",borderColor:null,borderWidth:0,borderType:"solid",borderRadius:0,shadowBlur:0,shadowColor:null,shadowOffsetX:0,shadowOffsetY:0,opacity:1},select:{itemStyle:{borderColor:"#212121"}},realtimeSort:!1}),t}(_h),jZ=function(){function r(){this.cx=0,this.cy=0,this.r0=0,this.r=0,this.startAngle=0,this.endAngle=Math.PI*2,this.clockwise=!0}return r}(),_m=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;return a.type="sausage",a}return t.prototype.getDefaultShape=function(){return new jZ},t.prototype.buildPath=function(e,a){var i=a.cx,n=a.cy,o=Math.max(a.r0||0,0),s=Math.max(a.r,0),l=(s-o)*.5,u=o+l,v=a.startAngle,c=a.endAngle,d=a.clockwise,f=Math.PI*2,h=d?c-vMath.PI/2&&vs)return!0;s=c}return!1},t.prototype._isOrderDifferentInView=function(e,a){for(var i=a.scale,n=i.getExtent(),o=Math.max(0,n[0]),s=Math.min(n[1],i.getOrdinalMeta().categories.length-1);o<=s;++o)if(e.ordinalNumbers[o]!==i.getRawOrdinalNumber(o))return!0},t.prototype._updateSortWithinSameData=function(e,a,i,n){if(this._isOrderChangedWithinSameData(e,a,i)){var o=this._dataSort(e,i,a);this._isOrderDifferentInView(o,i)&&(this._removeOnRenderedListener(n),n.dispatchAction({type:"changeAxisOrder",componentType:i.dim+"Axis",axisId:i.index,sortInfo:o}))}},t.prototype._dispatchInitSort=function(e,a,i){var n=a.baseAxis,o=this._dataSort(e,n,function(s){return e.get(e.mapDimension(a.otherAxis.dim),s)});i.dispatchAction({type:"changeAxisOrder",componentType:n.dim+"Axis",isInitSort:!0,axisId:n.index,sortInfo:o})},t.prototype.remove=function(e,a){this._clear(this._model),this._removeOnRenderedListener(a)},t.prototype.dispose=function(e,a){this._removeOnRenderedListener(a)},t.prototype._removeOnRenderedListener=function(e){this._onRendered&&(e.getZr().off("rendered",this._onRendered),this._onRendered=null)},t.prototype._clear=function(e){var a=this.group,i=this._data;e&&e.isAnimationEnabled()&&i&&!this._isLargeDraw?(this._removeBackground(),this._backgroundEls=[],i.eachItemGraphicEl(function(n){uh(n,e,Ie(n).dataIndex)})):a.removeAll(),this._data=null,this._isFirstFrame=!0},t.prototype._removeBackground=function(){this.group.remove(this._backgroundGroup),this._backgroundGroup=null},t.type="bar",t}(ca),RM={cartesian2d:function(r,t){var e=t.width<0?-1:1,a=t.height<0?-1:1;e<0&&(t.x+=t.width,t.width=-t.width),a<0&&(t.y+=t.height,t.height=-t.height);var i=r.x+r.width,n=r.y+r.height,o=Rx(t.x,r.x),s=Px(t.x+t.width,i),l=Rx(t.y,r.y),u=Px(t.y+t.height,n),v=si?s:o,t.y=c&&l>n?u:l,t.width=v?0:s-o,t.height=c?0:u-l,e<0&&(t.x+=t.width,t.width=-t.width),a<0&&(t.y+=t.height,t.height=-t.height),v||c},polar:function(r,t){var e=t.r0<=t.r?1:-1;if(e<0){var a=t.r;t.r=t.r0,t.r0=a}var i=Px(t.r,r.r),n=Rx(t.r0,r.r0);t.r=i,t.r0=n;var o=i-n<0;if(e<0){var a=t.r;t.r=t.r0,t.r0=a}return o}},PM={cartesian2d:function(r,t,e,a,i,n,o,s,l){var u=new Mr({shape:lt({},a),z2:1});if(u.__dataIndex=e,u.name="item",n){var v=u.shape,c=i?"height":"width";v[c]=0}return u},polar:function(r,t,e,a,i,n,o,s,l){var u=!i&&l?_m:Gi,v=new u({shape:a,z2:1});v.name="item";var c=tO(i);if(v.calculateTextPosition=KZ(c,{isRoundCap:u===_m}),n){var d=v.shape,f=i?"r":"endAngle",h={};d[f]=i?a.r0:a.startAngle,h[f]=a[f],(s?Gr:Ta)(v,{shape:h},n)}return v}};function eX(r,t){var e=r.get("realtimeSort",!0),a=t.getBaseAxis();if(e&&a.type==="category"&&t.type==="cartesian2d")return{baseAxis:a,otherAxis:t.getOtherAxis(a)}}function EM(r,t,e,a,i,n,o,s){var l,u;n?(u={x:a.x,width:a.width},l={y:a.y,height:a.height}):(u={y:a.y,height:a.height},l={x:a.x,width:a.width}),s||(o?Gr:Ta)(e,{shape:l},t,i,null);var v=t?r.baseAxis.model:null;(o?Gr:Ta)(e,{shape:u},v,i)}function NM(r,t){for(var e=0;e0?1:-1,o=a.height>0?1:-1;return{x:a.x+n*i/2,y:a.y+o*i/2,width:a.width-n*i,height:a.height-o*i}},polar:function(r,t,e){var a=r.getItemLayout(t);return{cx:a.cx,cy:a.cy,r0:a.r0,r:a.r,startAngle:a.startAngle,endAngle:a.endAngle,clockwise:a.clockwise}}};function iX(r){return r.startAngle!=null&&r.endAngle!=null&&r.startAngle===r.endAngle}function tO(r){return function(t){var e=t?"Arc":"Angle";return function(a){switch(a){case"start":case"insideStart":case"end":case"insideEnd":return a+e;default:return a}}}(r)}function zM(r,t,e,a,i,n,o,s){var l=t.getItemVisual(e,"style");if(s){if(!n.get("roundCap")){var v=r.shape,c=qu(a.getModel("itemStyle"),v,!0);lt(v,c),r.setShape(v)}}else{var u=a.get(["itemStyle","borderRadius"])||0;r.setShape("r",u)}r.useStyle(l);var d=a.getShallow("cursor");d&&r.attr("cursor",d);var f=s?o?i.r>=i.r0?"endArc":"startArc":i.endAngle>=i.startAngle?"endAngle":"startAngle":o?i.height>=0?"bottom":"top":i.width>=0?"right":"left",h=ai(a);mi(r,h,{labelFetcher:n,labelDataIndex:e,defaultText:jc(n.getData(),e),inheritColor:l.fill,defaultOpacity:l.opacity,defaultOutsidePosition:f});var g=r.getTextContent();if(s&&g){var m=a.get(["label","position"]);r.textConfig.inside=m==="middle"?!0:null,QZ(r,m==="outside"?f:m,tO(o),a.get(["label","rotate"]))}_5(g,h,n.getRawValue(e),function(b){return qN(t,b)});var x=a.getModel(["emphasis"]);Ia(r,x.get("focus"),x.get("blurScope"),x.get("disabled")),yi(r,a),iX(i)&&(r.style.fill="none",r.style.stroke="none",R(r.states,function(b){b.style&&(b.style.fill=b.style.stroke="none")}))}function nX(r,t){var e=r.get(["itemStyle","borderColor"]);if(!e||e==="none")return 0;var a=r.get(["itemStyle","borderWidth"])||0,i=isNaN(t.width)?Number.MAX_VALUE:Math.abs(t.width),n=isNaN(t.height)?Number.MAX_VALUE:Math.abs(t.height);return Math.min(a,i,n)}var oX=function(){function r(){}return r}(),BM=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;return a.type="largeBar",a}return t.prototype.getDefaultShape=function(){return new oX},t.prototype.buildPath=function(e,a){for(var i=a.points,n=this.baseDimIdx,o=1-this.baseDimIdx,s=[],l=[],u=this.barWidth,v=0;v=0?e:null},30,!1);function sX(r,t,e){for(var a=r.baseDimIdx,i=1-a,n=r.shape.points,o=r.largeDataIndices,s=[],l=[],u=r.barWidth,v=0,c=n.length/3;v=s[0]&&t<=s[0]+l[0]&&e>=s[1]&&e<=s[1]+l[1])return o[v]}return-1}function eO(r,t,e){if(Iv(e,"cartesian2d")){var a=t,i=e.getArea();return{x:r?a.x:i.x,y:r?i.y:a.y,width:r?a.width:i.width,height:r?i.height:a.height}}else{var i=e.getArea(),n=t;return{cx:i.cx,cy:i.cy,r0:r?i.r0:n.r0,r:r?i.r:n.r,startAngle:r?n.startAngle:0,endAngle:r?n.endAngle:Math.PI*2}}}function lX(r,t,e){var a=r.type==="polar"?Gi:Mr;return new a({shape:eO(t,e,r),silent:!0,z2:0})}function uX(r){r.registerChartView(tX),r.registerSeriesModel(XZ),r.registerLayout(r.PRIORITY.VISUAL.LAYOUT,We(oN,"bar")),r.registerLayout(r.PRIORITY.VISUAL.PROGRESSIVE_LAYOUT,sN("bar")),r.registerProcessor(r.PRIORITY.PROCESSOR.STATISTIC,JN("bar")),r.registerAction({type:"changeAxisOrder",event:"changeAxisOrder",update:"update"},function(t,e){var a=t.componentType||"series";e.eachComponent({mainType:a,query:t},function(i){t.sortInfo&&i.axis.setCategorySortInfo(t.sortInfo)})})}var GM=Math.PI*2,Qg=Math.PI/180;function rO(r,t){return Xa(r.getBoxLayoutParams(),{width:t.getWidth(),height:t.getHeight()})}function aO(r,t){var e=rO(r,t),a=r.get("center"),i=r.get("radius");ht(i)||(i=[0,i]);var n=Lt(e.width,t.getWidth()),o=Lt(e.height,t.getHeight()),s=Math.min(n,o),l=Lt(i[0],s/2),u=Lt(i[1],s/2),v,c,d=r.coordinateSystem;if(d){var f=d.dataToPoint(a);v=f[0]||0,c=f[1]||0}else ht(a)||(a=[a,a]),v=Lt(a[0],n)+e.x,c=Lt(a[1],o)+e.y;return{cx:v,cy:c,r0:l,r:u}}function vX(r,t,e){t.eachSeriesByType(r,function(a){var i=a.getData(),n=i.mapDimension("value"),o=rO(a,e),s=aO(a,e),l=s.cx,u=s.cy,v=s.r,c=s.r0,d=-a.get("startAngle")*Qg,f=a.get("endAngle"),h=a.get("padAngle")*Qg;f=f==="auto"?d-GM:-f*Qg;var g=a.get("minAngle")*Qg,m=g+h,x=0;i.each(n,function(B){!isNaN(B)&&x++});var b=i.getSum(n),w=Math.PI/(b||x)*2,S=a.get("clockwise"),C=a.get("roseType"),T=a.get("stillShowZeroSum"),k=i.getDataExtent(n);k[0]=0;var D=S?1:-1,M=[d,f],L=D*h/2;kS(M,!S),d=M[0],f=M[1];var I=iO(a);I.startAngle=d,I.endAngle=f,I.clockwise=S;var P=Math.abs(f-d),E=P,N=0,O=d;if(i.setLayout({viewRect:o,r:v}),i.each(n,function(B,z){var H;if(isNaN(B)){i.setItemLayout(z,{angle:NaN,startAngle:NaN,endAngle:NaN,clockwise:S,cx:l,cy:u,r0:c,r:C?NaN:v});return}C!=="area"?H=b===0&&T?w:B*w:H=P/x,HH?($=O+D*H/2,Z=$):($=O+L,Z=Y-L),i.setItemLayout(z,{angle:H,startAngle:$,endAngle:Z,clockwise:S,cx:l,cy:u,r0:c,r:C?ra(B,k,[c,v]):v}),O=Y}),Ee?x:m,C=Math.abs(w.label.y-e);if(C>=S.maxY){var T=w.label.x-t-w.len2*i,k=a+w.len,D=Math.abs(T)r.unconstrainedWidth?null:f:null;a.setStyle("width",h)}var g=a.getBoundingRect();n.width=g.width;var m=(a.style.margin||0)+2.1;n.height=g.height+m,n.y-=(n.height-c)/2}}}function Ex(r){return r.position==="center"}function fX(r){var t=r.getData(),e=[],a,i,n=!1,o=(r.get("minShowLabelAngle")||0)*cX,s=t.getLayout("viewRect"),l=t.getLayout("r"),u=s.width,v=s.x,c=s.y,d=s.height;function f(T){T.ignore=!0}function h(T){if(!T.ignore)return!0;for(var k in T.states)if(T.states[k].ignore===!1)return!0;return!1}t.each(function(T){var k=t.getItemGraphicEl(T),D=k.shape,M=k.getTextContent(),L=k.getTextGuideLine(),I=t.getItemModel(T),P=I.getModel("label"),E=P.get("position")||I.get(["emphasis","label","position"]),N=P.get("distanceToLabelLine"),O=P.get("alignTo"),V=Lt(P.get("edgeDistance"),u),B=P.get("bleedMargin"),z=I.getModel("labelLine"),H=z.get("length");H=Lt(H,u);var Y=z.get("length2");if(Y=Lt(Y,u),Math.abs(D.endAngle-D.startAngle)0?"right":"left":Z>0?"left":"right"}var zt=Math.PI,Yt=0,we=P.get("rotate");if(Or(we))Yt=we*(zt/180);else if(E==="center")Yt=0;else if(we==="radial"||we===!0){var Kt=Z<0?-$+zt:-$;Yt=Kt}else if(we==="tangential"&&E!=="outside"&&E!=="outer"){var qe=Math.atan2(Z,Q);qe<0&&(qe=zt*2+qe);var fr=Q>0;fr&&(qe=zt+qe),Yt=qe-zt}if(n=!!Yt,M.x=at,M.y=et,M.rotation=Yt,M.setStyle({verticalAlign:"middle"}),xt){M.setStyle({align:Ot});var ye=M.states.select;ye&&(ye.x+=M.x,ye.y+=M.y)}else{var re=M.getBoundingRect().clone();re.applyTransform(M.getComputedTransform());var ge=(M.style.margin||0)+2.1;re.y-=ge/2,re.height+=ge,e.push({label:M,labelLine:L,position:E,len:H,len2:Y,minTurnAngle:z.get("minTurnAngle"),maxSurfaceAngle:z.get("maxSurfaceAngle"),surfaceNormal:new Je(Z,Q),linePoints:_t,textAlign:Ot,labelDistance:N,labelAlignTo:O,edgeDistance:V,bleedMargin:B,rect:re,unconstrainedWidth:re.width,labelStyleWidth:M.style.width})}k.setTextConfig({inside:xt})}}),!n&&r.get("avoidLabelOverlap")&&dX(e,a,i,l,u,d,v,c);for(var g=0;g0){for(var v=o.getItemLayout(0),c=1;isNaN(v&&v.startAngle)&&c=n.r0}},t.type="pie",t}(ca);function yd(r,t,e){t=ht(t)&&{coordDimensions:t}||lt({encodeDefine:r.getEncode()},t);var a=r.getSource(),i=qh(a,t).dimensions,n=new Oi(i,r);return n.initData(a,e),n}var Qh=function(){function r(t,e){this._getDataWithEncodedVisual=t,this._getRawData=e}return r.prototype.getAllNames=function(){var t=this._getRawData();return t.mapArray(t.getName)},r.prototype.containName=function(t){var e=this._getRawData();return e.indexOfName(t)>=0},r.prototype.indexOfName=function(t){var e=this._getDataWithEncodedVisual();return e.indexOfName(t)},r.prototype.getItemVisual=function(t,e){var a=this._getDataWithEncodedVisual();return a.getItemVisual(t,e)},r}(),gX=Lr(),yX=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.init=function(e){r.prototype.init.apply(this,arguments),this.legendVisualProvider=new Qh(Nt(this.getData,this),Nt(this.getRawData,this)),this._defaultLabelLine(e)},t.prototype.mergeOption=function(){r.prototype.mergeOption.apply(this,arguments)},t.prototype.getInitialData=function(){return yd(this,{coordDimensions:["value"],encodeDefaulter:We(US,this)})},t.prototype.getDataParams=function(e){var a=this.getData(),i=gX(a),n=i.seats;if(!n){var o=[];a.each(a.mapDimension("value"),function(l){o.push(l)}),n=i.seats=s9(o,a.hostModel.get("percentPrecision"))}var s=r.prototype.getDataParams.call(this,e);return s.percent=n[e]||0,s.$vars.push("percent"),s},t.prototype._defaultLabelLine=function(e){pv(e,"labelLine",["show"]);var a=e.labelLine,i=e.emphasis.labelLine;a.show=a.show&&e.label.show,i.show=i.show&&e.emphasis.label.show},t.type="series.pie",t.defaultOption={z:2,legendHoverLink:!0,colorBy:"data",center:["50%","50%"],radius:[0,"75%"],clockwise:!0,startAngle:90,endAngle:"auto",padAngle:0,minAngle:0,minShowLabelAngle:0,selectedOffset:10,percentPrecision:2,stillShowZeroSum:!0,left:0,top:0,right:0,bottom:0,width:null,height:null,label:{rotate:0,show:!0,overflow:"truncate",position:"outer",alignTo:"none",edgeDistance:"25%",bleedMargin:10,distanceToLabelLine:5},labelLine:{show:!0,length:15,length2:15,smooth:!1,minTurnAngle:90,maxSurfaceAngle:90,lineStyle:{width:1,type:"solid"}},itemStyle:{borderWidth:1,borderJoin:"round"},showEmptyCircle:!0,emptyCircleStyle:{color:"lightgray",opacity:1},labelLayout:{hideOverlap:!0},emphasis:{scale:!0,scaleSize:5},avoidLabelOverlap:!0,animationType:"expansion",animationDuration:1e3,animationTypeUpdate:"transition",animationEasingUpdate:"cubicInOut",animationDurationUpdate:500,animationEasing:"cubicInOut"},t}(ma);function mX(r){return{seriesType:r,reset:function(t,e){var a=t.getData();a.filterSelf(function(i){var n=a.mapDimension("value"),o=a.get(n,i);return!(Or(o)&&!isNaN(o)&&o<0)})}}}function _X(r){r.registerChartView(pX),r.registerSeriesModel(yX),A3("pie",r.registerAction),r.registerLayout(We(vX,"pie")),r.registerProcessor(Kh("pie")),r.registerProcessor(mX("pie"))}var xX=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.hasSymbolVisual=!0,e}return t.prototype.getInitialData=function(e,a){return Ys(null,this,{useEncodeDefaulter:!0})},t.prototype.getProgressive=function(){var e=this.option.progressive;return e??(this.option.large?5e3:this.get("progressive"))},t.prototype.getProgressiveThreshold=function(){var e=this.option.progressiveThreshold;return e??(this.option.large?1e4:this.get("progressiveThreshold"))},t.prototype.brushSelector=function(e,a,i){return i.point(a.getItemLayout(e))},t.prototype.getZLevelKey=function(){return this.getData().count()>this.getProgressiveThreshold()?this.id:""},t.type="series.scatter",t.dependencies=["grid","polar","geo","singleAxis","calendar"],t.defaultOption={coordinateSystem:"cartesian2d",z:2,legendHoverLink:!0,symbolSize:10,large:!1,largeThreshold:2e3,itemStyle:{opacity:.8},emphasis:{scale:!0},clip:!0,select:{itemStyle:{borderColor:"#212121"}},universalTransition:{divideShape:"clone"}},t}(ma),oO=4,bX=function(){function r(){}return r}(),wX=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;return a._off=0,a.hoverDataIdx=-1,a}return t.prototype.getDefaultShape=function(){return new bX},t.prototype.reset=function(){this.notClear=!1,this._off=0},t.prototype.buildPath=function(e,a){var i=a.points,n=a.size,o=this.symbolProxy,s=o.shape,l=e.getContext?e.getContext():e,u=l&&n[0]=0;u--){var v=u*2,c=n[v]-s/2,d=n[v+1]-l/2;if(e>=c&&a>=d&&e<=c+s&&a<=d+l)return u}return-1},t.prototype.contain=function(e,a){var i=this.transformCoordToLocal(e,a),n=this.getBoundingRect();if(e=i[0],a=i[1],n.contain(e,a)){var o=this.hoverDataIdx=this.findDataIndex(e,a);return o>=0}return this.hoverDataIdx=-1,!1},t.prototype.getBoundingRect=function(){var e=this._rect;if(!e){for(var a=this.shape,i=a.points,n=a.size,o=n[0],s=n[1],l=1/0,u=1/0,v=-1/0,c=-1/0,d=0;d=0&&(u.dataIndex=c+(t.startIndex||0))})},r.prototype.remove=function(){this._clear()},r.prototype._clear=function(){this._newAdded=[],this.group.removeAll()},r}(),TX=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){var n=e.getData(),o=this._updateSymbolDraw(n,e);o.updateData(n,{clipShape:this._getClipShape(e)}),this._finished=!0},t.prototype.incrementalPrepareRender=function(e,a,i){var n=e.getData(),o=this._updateSymbolDraw(n,e);o.incrementalPrepareUpdate(n),this._finished=!1},t.prototype.incrementalRender=function(e,a,i){this._symbolDraw.incrementalUpdate(e,a.getData(),{clipShape:this._getClipShape(a)}),this._finished=e.end===a.getData().count()},t.prototype.updateTransform=function(e,a,i){var n=e.getData();if(this.group.dirty(),!this._finished||n.count()>1e4)return{update:!0};var o=jh("").reset(e,a,i);o.progress&&o.progress({start:0,end:n.count(),count:n.count()},n),this._symbolDraw.updateLayout(n)},t.prototype.eachRendered=function(e){this._symbolDraw&&this._symbolDraw.eachRendered(e)},t.prototype._getClipShape=function(e){if(e.get("clip",!0)){var a=e.coordinateSystem;return a&&a.getArea&&a.getArea(.1)}},t.prototype._updateSymbolDraw=function(e,a){var i=this._symbolDraw,n=a.pipelineContext,o=n.large;return(!i||o!==this._isLargeDraw)&&(i&&i.remove(),i=this._symbolDraw=o?new SX:new Zh,this._isLargeDraw=o,this.group.removeAll()),this.group.add(i.group),i},t.prototype.remove=function(e,a){this._symbolDraw&&this._symbolDraw.remove(!0),this._symbolDraw=null},t.prototype.dispose=function(){},t.type="scatter",t}(ca),AX=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.type="grid",t.dependencies=["xAxis","yAxis"],t.layoutMode="box",t.defaultOption={show:!1,z:0,left:"10%",top:60,right:"10%",bottom:70,containLabel:!1,backgroundColor:"rgba(0,0,0,0)",borderWidth:1,borderColor:"#ccc"},t}(_r),a2=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.getCoordSysModel=function(){return this.getReferringComponents("grid",Ua).models[0]},t.type="cartesian2dAxis",t}(_r);$a(a2,$h);var sO={show:!0,z:0,inverse:!1,name:"",nameLocation:"end",nameRotate:null,nameTruncate:{maxWidth:null,ellipsis:"...",placeholder:"."},nameTextStyle:{},nameGap:15,silent:!1,triggerEvent:!1,tooltip:{show:!1},axisPointer:{},axisLine:{show:!0,onZero:!0,onZeroAxisIndex:null,lineStyle:{color:"#6E7079",width:1,type:"solid"},symbol:["none","none"],symbolSize:[10,15]},axisTick:{show:!0,inside:!1,length:5,lineStyle:{width:1}},axisLabel:{show:!0,inside:!1,rotate:0,showMinLabel:null,showMaxLabel:null,margin:8,fontSize:12},splitLine:{show:!0,showMinLine:!0,showMaxLine:!0,lineStyle:{color:["#E0E6F1"],width:1,type:"solid"}},splitArea:{show:!1,areaStyle:{color:["rgba(250,250,250,0.2)","rgba(210,219,238,0.2)"]}}},CX=Ze({boundaryGap:!0,deduplication:null,splitLine:{show:!1},axisTick:{alignWithLabel:!1,interval:"auto"},axisLabel:{interval:"auto"}},sO),_T=Ze({boundaryGap:[0,0],axisLine:{show:"auto"},axisTick:{show:"auto"},splitNumber:5,minorTick:{show:!1,splitNumber:5,length:3,lineStyle:{}},minorSplitLine:{show:!1,lineStyle:{color:"#F4F7FD",width:1}}},sO),kX=Ze({splitNumber:6,axisLabel:{showMinLabel:!1,showMaxLabel:!1,rich:{primary:{fontWeight:"bold"}}},splitLine:{show:!1}},_T),DX=ve({logBase:10},_T);const lO={category:CX,value:_T,time:kX,log:DX};var MX={value:1,category:1,time:1,log:1};function Kc(r,t,e,a){R(MX,function(i,n){var o=Ze(Ze({},lO[n],!0),a,!0),s=function(l){tt(u,l);function u(){var v=l!==null&&l.apply(this,arguments)||this;return v.type=t+"Axis."+n,v}return u.prototype.mergeDefaultAndTheme=function(v,c){var d=ch(this),f=d?cd(v):{},h=c.getTheme();Ze(v,h.get(n+"Axis")),Ze(v,this.getDefaultOption()),v.type=WM(v),d&&zl(v,f,d)},u.prototype.optionUpdated=function(){var v=this.option;v.type==="category"&&(this.__ordinalMeta=jw.createByAxisModel(this))},u.prototype.getCategories=function(v){var c=this.option;if(c.type==="category")return v?c.data:this.__ordinalMeta.categories},u.prototype.getOrdinalMeta=function(){return this.__ordinalMeta},u.type=t+"Axis."+n,u.defaultOption=o,u}(e);r.registerComponentModel(s)}),r.registerSubTypeDefaulter(t+"Axis",WM)}function WM(r){return r.type||(r.data?"category":"value")}var LX=function(){function r(t){this.type="cartesian",this._dimList=[],this._axes={},this.name=t||""}return r.prototype.getAxis=function(t){return this._axes[t]},r.prototype.getAxes=function(){return pt(this._dimList,function(t){return this._axes[t]},this)},r.prototype.getAxesByScale=function(t){return t=t.toLowerCase(),ta(this.getAxes(),function(e){return e.scale.type===t})},r.prototype.addAxis=function(t){var e=t.dim;this._axes[e]=t,this._dimList.push(e)},r}(),i2=["x","y"];function UM(r){return r.type==="interval"||r.type==="time"}var IX=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type="cartesian2d",e.dimensions=i2,e}return t.prototype.calcAffineTransform=function(){this._transform=this._invTransform=null;var e=this.getAxis("x").scale,a=this.getAxis("y").scale;if(!(!UM(e)||!UM(a))){var i=e.getExtent(),n=a.getExtent(),o=this.dataToPoint([i[0],n[0]]),s=this.dataToPoint([i[1],n[1]]),l=i[1]-i[0],u=n[1]-n[0];if(!(!l||!u)){var v=(s[0]-o[0])/l,c=(s[1]-o[1])/u,d=o[0]-i[0]*v,f=o[1]-n[0]*c,h=this._transform=[v,0,0,c,d,f];this._invTransform=id([],h)}}},t.prototype.getBaseAxis=function(){return this.getAxesByScale("ordinal")[0]||this.getAxesByScale("time")[0]||this.getAxis("x")},t.prototype.containPoint=function(e){var a=this.getAxis("x"),i=this.getAxis("y");return a.contain(a.toLocalCoord(e[0]))&&i.contain(i.toLocalCoord(e[1]))},t.prototype.containData=function(e){return this.getAxis("x").containData(e[0])&&this.getAxis("y").containData(e[1])},t.prototype.containZone=function(e,a){var i=this.dataToPoint(e),n=this.dataToPoint(a),o=this.getArea(),s=new er(i[0],i[1],n[0]-i[0],n[1]-i[1]);return o.intersect(s)},t.prototype.dataToPoint=function(e,a,i){i=i||[];var n=e[0],o=e[1];if(this._transform&&n!=null&&isFinite(n)&&o!=null&&isFinite(o))return pi(i,e,this._transform);var s=this.getAxis("x"),l=this.getAxis("y");return i[0]=s.toGlobalCoord(s.dataToCoord(n,a)),i[1]=l.toGlobalCoord(l.dataToCoord(o,a)),i},t.prototype.clampData=function(e,a){var i=this.getAxis("x").scale,n=this.getAxis("y").scale,o=i.getExtent(),s=n.getExtent(),l=i.parse(e[0]),u=n.parse(e[1]);return a=a||[],a[0]=Math.min(Math.max(Math.min(o[0],o[1]),l),Math.max(o[0],o[1])),a[1]=Math.min(Math.max(Math.min(s[0],s[1]),u),Math.max(s[0],s[1])),a},t.prototype.pointToData=function(e,a){var i=[];if(this._invTransform)return pi(i,e,this._invTransform);var n=this.getAxis("x"),o=this.getAxis("y");return i[0]=n.coordToData(n.toLocalCoord(e[0]),a),i[1]=o.coordToData(o.toLocalCoord(e[1]),a),i},t.prototype.getOtherAxis=function(e){return this.getAxis(e.dim==="x"?"y":"x")},t.prototype.getArea=function(e){e=e||0;var a=this.getAxis("x").getGlobalExtent(),i=this.getAxis("y").getGlobalExtent(),n=Math.min(a[0],a[1])-e,o=Math.min(i[0],i[1])-e,s=Math.max(a[0],a[1])-n+e,l=Math.max(i[0],i[1])-o+e;return new er(n,o,s,l)},t}(LX),RX=function(r){tt(t,r);function t(e,a,i,n,o){var s=r.call(this,e,a,i)||this;return s.index=0,s.type=n||"value",s.position=o||"bottom",s}return t.prototype.isHorizontal=function(){var e=this.position;return e==="top"||e==="bottom"},t.prototype.getGlobalExtent=function(e){var a=this.getExtent();return a[0]=this.toGlobalCoord(a[0]),a[1]=this.toGlobalCoord(a[1]),e&&a[0]>a[1]&&a.reverse(),a},t.prototype.pointToData=function(e,a){return this.coordToData(this.toLocalCoord(e[this.dim==="x"?0:1]),a)},t.prototype.setCategorySortInfo=function(e){if(this.type!=="category")return!1;this.model.option.categorySortInfo=e,this.scale.setSortInfo(e)},t}(Do);function n2(r,t,e){e=e||{};var a=r.coordinateSystem,i=t.axis,n={},o=i.getAxesOnZeroOf()[0],s=i.position,l=o?"onZero":s,u=i.dim,v=a.getRect(),c=[v.x,v.x+v.width,v.y,v.y+v.height],d={left:0,right:1,top:0,bottom:1,onZero:2},f=t.get("offset")||0,h=u==="x"?[c[2]-f,c[3]+f]:[c[0]-f,c[1]+f];if(o){var g=o.toGlobalCoord(o.dataToCoord(0));h[d.onZero]=Math.max(Math.min(g,h[1]),h[0])}n.position=[u==="y"?h[d[l]]:c[0],u==="x"?h[d[l]]:c[3]],n.rotation=Math.PI/2*(u==="x"?0:1);var m={top:-1,bottom:1,left:-1,right:1};n.labelDirection=n.tickDirection=n.nameDirection=m[s],n.labelOffset=o?h[d[s]]-h[d.onZero]:0,t.get(["axisTick","inside"])&&(n.tickDirection=-n.tickDirection),si(e.labelInside,t.get(["axisLabel","inside"]))&&(n.labelDirection=-n.labelDirection);var x=t.get(["axisLabel","rotate"]);return n.labelRotate=l==="top"?-x:x,n.z2=1,n}function qM(r){return r.get("coordinateSystem")==="cartesian2d"}function $M(r){var t={xAxisModel:null,yAxisModel:null};return R(t,function(e,a){var i=a.replace(/Model$/,""),n=r.getReferringComponents(i,Ua).models[0];t[a]=n}),t}var Nx=Math.log;function uO(r,t,e){var a=Gs.prototype,i=a.getTicks.call(e),n=a.getTicks.call(e,!0),o=i.length-1,s=a.getInterval.call(e),l=dN(r,t),u=l.extent,v=l.fixMin,c=l.fixMax;if(r.type==="log"){var d=Nx(r.base);u=[Nx(u[0])/d,Nx(u[1])/d]}r.setExtent(u[0],u[1]),r.calcNiceExtent({splitNumber:o,fixMin:v,fixMax:c});var f=a.getExtent.call(r);v&&(u[0]=f[0]),c&&(u[1]=f[1]);var h=a.getInterval.call(r),g=u[0],m=u[1];if(v&&c)h=(m-g)/o;else if(v)for(m=u[0]+h*o;mu[0]&&isFinite(g)&&isFinite(u[0]);)h=_x(h),g=u[1]-h*o;else{var x=r.getTicks().length-1;x>o&&(h=_x(h));var b=h*o;m=Math.ceil(u[1]/h)*h,g=Ea(m-b),g<0&&u[0]>=0?(g=0,m=Ea(b)):m>0&&u[1]<=0&&(m=0,g=-Ea(b))}var w=(i[0].value-n[0].value)/s,S=(i[o].value-n[o].value)/s;a.setExtent.call(r,g+h*w,m+h*S),a.setInterval.call(r,h),(w||S)&&a.setNiceExtent.call(r,g+h,m-h)}var PX=function(){function r(t,e,a){this.type="grid",this._coordsMap={},this._coordsList=[],this._axesMap={},this._axesList=[],this.axisPointerEnabled=!0,this.dimensions=i2,this._initCartesian(t,e,a),this.model=t}return r.prototype.getRect=function(){return this._rect},r.prototype.update=function(t,e){var a=this._axesMap;this._updateScale(t,this.model);function i(o){var s,l=xr(o),u=l.length;if(u){for(var v=[],c=u-1;c>=0;c--){var d=+l[c],f=o[d],h=f.model,g=f.scale;Kw(g)&&h.get("alignTicks")&&h.get("interval")==null?v.push(f):(Xc(g,h),Kw(g)&&(s=f))}v.length&&(s||(s=v.pop(),Xc(s.scale,s.model)),R(v,function(m){uO(m.scale,m.model,s.scale)}))}}i(a.x),i(a.y);var n={};R(a.x,function(o){YM(a,"y",o,n)}),R(a.y,function(o){YM(a,"x",o,n)}),this.resize(this.model,e)},r.prototype.resize=function(t,e,a){var i=t.getBoxLayoutParams(),n=!a&&t.get("containLabel"),o=Xa(i,{width:e.getWidth(),height:e.getHeight()});this._rect=o;var s=this._axesList;l(),n&&(R(s,function(u){if(!u.model.get(["axisLabel","inside"])){var v=fY(u);if(v){var c=u.isHorizontal()?"height":"width",d=u.model.get(["axisLabel","margin"]);o[c]-=v[c]+d,u.position==="top"?o.y+=v.height+d:u.position==="left"&&(o.x+=v.width+d)}}}),l()),R(this._coordsList,function(u){u.calcAffineTransform()});function l(){R(s,function(u){var v=u.isHorizontal(),c=v?[0,o.width]:[0,o.height],d=u.inverse?1:0;u.setExtent(c[d],c[1-d]),EX(u,v?o.x:o.y)})}},r.prototype.getAxis=function(t,e){var a=this._axesMap[t];if(a!=null)return a[e||0]},r.prototype.getAxes=function(){return this._axesList.slice()},r.prototype.getCartesian=function(t,e){if(t!=null&&e!=null){var a="x"+t+"y"+e;return this._coordsMap[a]}pe(t)&&(e=t.yAxisIndex,t=t.xAxisIndex);for(var i=0,n=this._coordsList;i0?"top":"bottom",n="center"):nh(i-Sl)?(o=a>0?"bottom":"top",n="center"):(o="middle",i>0&&i0?"right":"left":n=a>0?"left":"right"),{rotation:i,textAlign:n,textVerticalAlign:o}},r.makeAxisEventDataBase=function(t){var e={componentType:t.mainType,componentIndex:t.componentIndex};return e[t.mainType+"Index"]=t.componentIndex,e},r.isLabelSilent=function(t){var e=t.get("tooltip");return t.get("silent")||!(t.get("triggerEvent")||e&&e.show)},r}(),XM={axisLine:function(r,t,e,a){var i=t.get(["axisLine","show"]);if(i==="auto"&&r.handleAutoShown&&(i=r.handleAutoShown("axisLine")),!!i){var n=t.axis.getExtent(),o=a.transform,s=[n[0],0],l=[n[1],0],u=s[0]>l[0];o&&(pi(s,s,o),pi(l,l,o));var v=lt({lineCap:"round"},t.getModel(["axisLine","lineStyle"]).getLineStyle()),c=new Ja({shape:{x1:s[0],y1:s[1],x2:l[0],y2:l[1]},style:v,strokeContainThreshold:r.strokeContainThreshold||5,silent:!0,z2:1});$c(c.shape,c.style.lineWidth),c.anid="line",e.add(c);var d=t.get(["axisLine","symbol"]);if(d!=null){var f=t.get(["axisLine","symbolSize"]);Pt(d)&&(d=[d,d]),(Pt(f)||Or(f))&&(f=[f,f]);var h=Mv(t.get(["axisLine","symbolOffset"])||0,f),g=f[0],m=f[1];R([{rotate:r.rotation+Math.PI/2,offset:h[0],r:0},{rotate:r.rotation-Math.PI/2,offset:h[1],r:Math.sqrt((s[0]-l[0])*(s[0]-l[0])+(s[1]-l[1])*(s[1]-l[1]))}],function(x,b){if(d[b]!=="none"&&d[b]!=null){var w=qa(d[b],-g/2,-m/2,g,m,v.stroke,!0),S=x.r+x.offset,C=u?l:s;w.attr({rotation:x.rotate,x:C[0]+S*Math.cos(r.rotation),y:C[1]-S*Math.sin(r.rotation),silent:!0,z2:11}),e.add(w)}})}}},axisTickLabel:function(r,t,e,a){var i=zX(e,a,t,r),n=VX(e,a,t,r);if(OX(t,n,i),BX(e,a,t,r.tickDirection),t.get(["axisLabel","hideOverlap"])){var o=CN(pt(n,function(s){return{label:s,priority:s.z2,defaultAttr:{ignore:s.ignore}}}));MN(o)}},axisName:function(r,t,e,a){var i=si(r.axisName,t.get("name"));if(i){var n=t.get("nameLocation"),o=r.nameDirection,s=t.getModel("nameTextStyle"),l=t.get("nameGap")||0,u=t.axis.getExtent(),v=u[0]>u[1]?-1:1,c=[n==="start"?u[0]-v*l:n==="end"?u[1]+v*l:(u[0]+u[1])/2,KM(n)?r.labelOffset+o*l:0],d,f=t.get("nameRotate");f!=null&&(f=f*Sl/180);var h;KM(n)?d=zi.innerTextLayout(r.rotation,f??r.rotation,o):(d=NX(r.rotation,n,f||0,u),h=r.axisNameAvailableWidth,h!=null&&(h=Math.abs(h/Math.sin(d.rotation)),!isFinite(h)&&(h=null)));var g=s.getFont(),m=t.get("nameTruncate",!0)||{},x=m.ellipsis,b=si(r.nameTruncateMaxWidth,m.maxWidth,h),w=new Rr({x:c[0],y:c[1],rotation:d.rotation,silent:zi.isLabelSilent(t),style:ya(s,{text:i,font:g,overflow:"truncate",width:b,ellipsis:x,fill:s.getTextColor()||t.get(["axisLine","lineStyle","color"]),align:s.get("align")||d.textAlign,verticalAlign:s.get("verticalAlign")||d.textVerticalAlign}),z2:1});if(kv({el:w,componentModel:t,itemName:i}),w.__fullText=i,w.anid="name",t.get("triggerEvent")){var S=zi.makeAxisEventDataBase(t);S.targetType="axisName",S.name=i,Ie(w).eventData=S}a.add(w),w.updateTransform(),e.add(w),w.decomposeTransform()}}};function NX(r,t,e,a){var i=C4(e-r),n,o,s=a[0]>a[1],l=t==="start"&&!s||t!=="start"&&s;return nh(i-Sl/2)?(o=l?"bottom":"top",n="center"):nh(i-Sl*1.5)?(o=l?"top":"bottom",n="center"):(o="middle",iSl/2?n=l?"left":"right":n=l?"right":"left"),{rotation:i,textAlign:n,textVerticalAlign:o}}function OX(r,t,e){if(!fN(r.axis)){var a=r.get(["axisLabel","showMinLabel"]),i=r.get(["axisLabel","showMaxLabel"]);t=t||[],e=e||[];var n=t[0],o=t[1],s=t[t.length-1],l=t[t.length-2],u=e[0],v=e[1],c=e[e.length-1],d=e[e.length-2];a===!1?(Tn(n),Tn(u)):jM(n,o)&&(a?(Tn(o),Tn(v)):(Tn(n),Tn(u))),i===!1?(Tn(s),Tn(c)):jM(l,s)&&(i?(Tn(l),Tn(d)):(Tn(s),Tn(c)))}}function Tn(r){r&&(r.ignore=!0)}function jM(r,t){var e=r&&r.getBoundingRect().clone(),a=t&&t.getBoundingRect().clone();if(!(!e||!a)){var i=r_([]);return Cv(i,i,-r.rotation),e.applyTransform(Rs([],i,r.getLocalTransform())),a.applyTransform(Rs([],i,t.getLocalTransform())),e.intersect(a)}}function KM(r){return r==="middle"||r==="center"}function vO(r,t,e,a,i){for(var n=[],o=[],s=[],l=0;l=0||r===t}function qX(r){var t=xT(r);if(t){var e=t.axisPointerModel,a=t.axis.scale,i=e.option,n=e.get("status"),o=e.get("value");o!=null&&(o=a.parse(o));var s=o2(e);n==null&&(i.status=s?"show":"hide");var l=a.getExtent().slice();l[0]>l[1]&&l.reverse(),(o==null||o>l[1])&&(o=l[1]),o0&&!h.min?h.min=0:h.min!=null&&h.min<0&&!h.max&&(h.max=0);var g=l;h.color!=null&&(g=ve({color:h.color},l));var m=Ze(be(h),{boundaryGap:e,splitNumber:a,scale:i,axisLine:n,axisTick:o,axisLabel:s,name:h.text,showName:u,nameLocation:"end",nameGap:c,nameTextStyle:g,triggerEvent:d},!1);if(Pt(v)){var x=m.name;m.name=v.replace("{value}",x??"")}else le(v)&&(m.name=v(m.name,m));var b=new ia(m,null,this.ecModel);return $a(b,$h.prototype),b.mainType="radar",b.componentIndex=this.componentIndex,b},this);this._indicatorModels=f},t.prototype.getIndicatorModels=function(){return this._indicatorModels},t.type="radar",t.defaultOption={z:0,center:["50%","50%"],radius:"75%",startAngle:90,axisName:{show:!0},boundaryGap:[0,0],splitNumber:5,axisNameGap:15,scale:!1,shape:"polygon",axisLine:Ze({lineStyle:{color:"#bbb"}},of.axisLine),axisLabel:Jg(of.axisLabel,!1),axisTick:Jg(of.axisTick,!1),splitLine:Jg(of.splitLine,!0),splitArea:Jg(of.splitArea,!0),indicator:[]},t}(_r),ij=["axisLine","axisTickLabel","axisName"],nj=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){var n=this.group;n.removeAll(),this._buildAxes(e),this._buildSplitLineAndArea(e)},t.prototype._buildAxes=function(e){var a=e.coordinateSystem,i=a.getIndicatorAxes(),n=pt(i,function(o){var s=o.model.get("showName")?o.name:"",l=new zi(o.model,{axisName:s,position:[a.cx,a.cy],rotation:o.angle,labelDirection:-1,tickDirection:-1,nameDirection:1});return l});R(n,function(o){R(ij,o.add,o),this.group.add(o.getGroup())},this)},t.prototype._buildSplitLineAndArea=function(e){var a=e.coordinateSystem,i=a.getIndicatorAxes();if(!i.length)return;var n=e.get("shape"),o=e.getModel("splitLine"),s=e.getModel("splitArea"),l=o.getModel("lineStyle"),u=s.getModel("areaStyle"),v=o.get("show"),c=s.get("show"),d=l.get("color"),f=u.get("color"),h=ht(d)?d:[d],g=ht(f)?f:[f],m=[],x=[];function b(O,V,B){var z=B%V.length;return O[z]=O[z]||[],z}if(n==="circle")for(var w=i[0].getTicksCoords(),S=a.cx,C=a.cy,T=0;T3?1.4:o>1?1.2:1.1,v=n>0?u:1/u;Bx(this,"zoom","zoomOnMouseWheel",e,{scale:v,originX:s,originY:l,isAvailableBehavior:null})}if(i){var c=Math.abs(n),d=(n>0?1:-1)*(c>3?.4:c>1?.15:.05);Bx(this,"scrollMove","moveOnMouseWheel",e,{scrollDelta:d,originX:s,originY:l,isAvailableBehavior:null})}}},t.prototype._pinchHandler=function(e){if(!aL(this._zr,"globalPan")){var a=e.pinchScale>1?1.1:1/1.1;Bx(this,"zoom",null,e,{scale:a,originX:e.pinchX,originY:e.pinchY,isAvailableBehavior:null})}},t}(Kn);function Bx(r,t,e,a,i){r.pointerChecker&&r.pointerChecker(a,i.originX,i.originY)&&(Os(a.event),gO(r,t,e,a,i))}function gO(r,t,e,a,i){i.isAvailableBehavior=Nt(zy,null,e,a),r.trigger(t,i)}function zy(r,t,e){var a=e[r];return!r||a&&(!Pt(a)||t.event[a+"Key"])}function wT(r,t,e){var a=r.target;a.x+=t,a.y+=e,a.dirty()}function ST(r,t,e,a){var i=r.target,n=r.zoomLimit,o=r.zoom=r.zoom||1;if(o*=t,n){var s=n.min||0,l=n.max||1/0;o=Math.max(Math.min(l,o),s)}var u=o/r.zoom;r.zoom=o,i.x-=(e-i.x)*(u-1),i.y-=(a-i.y)*(u-1),i.scaleX*=u,i.scaleY*=u,i.dirty()}var dj={axisPointer:1,tooltip:1,brush:1};function P_(r,t,e){var a=t.getComponentByElement(r.topTarget),i=a&&a.coordinateSystem;return a&&a!==e&&!dj.hasOwnProperty(a.mainType)&&i&&i.model!==e}function yO(r){if(Pt(r)){var t=new DOMParser;r=t.parseFromString(r,"text/xml")}var e=r;for(e.nodeType===9&&(e=e.firstChild);e.nodeName.toLowerCase()!=="svg"||e.nodeType!==1;)e=e.nextSibling;return e}var Vx,xm={fill:"fill",stroke:"stroke","stroke-width":"lineWidth",opacity:"opacity","fill-opacity":"fillOpacity","stroke-opacity":"strokeOpacity","stroke-dasharray":"lineDash","stroke-dashoffset":"lineDashOffset","stroke-linecap":"lineCap","stroke-linejoin":"lineJoin","stroke-miterlimit":"miterLimit","font-family":"fontFamily","font-size":"fontSize","font-style":"fontStyle","font-weight":"fontWeight","text-anchor":"textAlign",visibility:"visibility",display:"display"},iL=xr(xm),bm={"alignment-baseline":"textBaseline","stop-color":"stopColor"},nL=xr(bm),fj=function(){function r(){this._defs={},this._root=null}return r.prototype.parse=function(t,e){e=e||{};var a=yO(t);this._defsUsePending=[];var i=new Ae;this._root=i;var n=[],o=a.getAttribute("viewBox")||"",s=parseFloat(a.getAttribute("width")||e.width),l=parseFloat(a.getAttribute("height")||e.height);isNaN(s)&&(s=null),isNaN(l)&&(l=null),sn(a,i,null,!0,!1);for(var u=a.firstChild;u;)this._parseNode(u,i,n,null,!1,!1),u=u.nextSibling;gj(this._defs,this._defsUsePending),this._defsUsePending=[];var v,c;if(o){var d=E_(o);d.length>=4&&(v={x:parseFloat(d[0]||0),y:parseFloat(d[1]||0),width:parseFloat(d[2]),height:parseFloat(d[3])})}if(v&&s!=null&&l!=null&&(c=_O(v,{x:0,y:0,width:s,height:l}),!e.ignoreViewBox)){var f=i;i=new Ae,i.add(f),f.scaleX=f.scaleY=c.scale,f.x=c.x,f.y=c.y}return!e.ignoreRootClip&&s!=null&&l!=null&&i.setClipPath(new Mr({shape:{x:0,y:0,width:s,height:l}})),{root:i,width:s,height:l,viewBoxRect:v,viewBoxTransform:c,named:n}},r.prototype._parseNode=function(t,e,a,i,n,o){var s=t.nodeName.toLowerCase(),l,u=i;if(s==="defs"&&(n=!0),s==="text"&&(o=!0),s==="defs"||s==="switch")l=e;else{if(!n){var v=Vx[s];if(v&&Vt(Vx,s)){l=v.call(this,t,e);var c=t.getAttribute("name");if(c){var d={name:c,namedFrom:null,svgNodeTagLower:s,el:l};a.push(d),s==="g"&&(u=d)}else i&&a.push({name:i.name,namedFrom:i,svgNodeTagLower:s,el:l});e.add(l)}}var f=oL[s];if(f&&Vt(oL,s)){var h=f.call(this,t),g=t.getAttribute("id");g&&(this._defs[g]=h)}}if(l&&l.isGroup)for(var m=t.firstChild;m;)m.nodeType===1?this._parseNode(m,l,a,u,n,o):m.nodeType===3&&o&&this._parseText(m,l),m=m.nextSibling},r.prototype._parseText=function(t,e){var a=new qc({style:{text:t.textContent},silent:!0,x:this._textX||0,y:this._textY||0});An(e,a),sn(t,a,this._defsUsePending,!1,!1),hj(a,e);var i=a.style,n=i.fontSize;n&&n<9&&(i.fontSize=9,a.scaleX*=n/9,a.scaleY*=n/9);var o=(i.fontSize||i.fontFamily)&&[i.fontStyle,i.fontWeight,(i.fontSize||12)+"px",i.fontFamily||"sans-serif"].join(" ");i.font=o;var s=a.getBoundingRect();return this._textX+=s.width,e.add(a),a},r.internalField=function(){Vx={g:function(t,e){var a=new Ae;return An(e,a),sn(t,a,this._defsUsePending,!1,!1),a},rect:function(t,e){var a=new Mr;return An(e,a),sn(t,a,this._defsUsePending,!1,!1),a.setShape({x:parseFloat(t.getAttribute("x")||"0"),y:parseFloat(t.getAttribute("y")||"0"),width:parseFloat(t.getAttribute("width")||"0"),height:parseFloat(t.getAttribute("height")||"0")}),a.silent=!0,a},circle:function(t,e){var a=new $s;return An(e,a),sn(t,a,this._defsUsePending,!1,!1),a.setShape({cx:parseFloat(t.getAttribute("cx")||"0"),cy:parseFloat(t.getAttribute("cy")||"0"),r:parseFloat(t.getAttribute("r")||"0")}),a.silent=!0,a},line:function(t,e){var a=new Ja;return An(e,a),sn(t,a,this._defsUsePending,!1,!1),a.setShape({x1:parseFloat(t.getAttribute("x1")||"0"),y1:parseFloat(t.getAttribute("y1")||"0"),x2:parseFloat(t.getAttribute("x2")||"0"),y2:parseFloat(t.getAttribute("y2")||"0")}),a.silent=!0,a},ellipse:function(t,e){var a=new d_;return An(e,a),sn(t,a,this._defsUsePending,!1,!1),a.setShape({cx:parseFloat(t.getAttribute("cx")||"0"),cy:parseFloat(t.getAttribute("cy")||"0"),rx:parseFloat(t.getAttribute("rx")||"0"),ry:parseFloat(t.getAttribute("ry")||"0")}),a.silent=!0,a},polygon:function(t,e){var a=t.getAttribute("points"),i;a&&(i=uL(a));var n=new Hi({shape:{points:i||[]},silent:!0});return An(e,n),sn(t,n,this._defsUsePending,!1,!1),n},polyline:function(t,e){var a=t.getAttribute("points"),i;a&&(i=uL(a));var n=new Ui({shape:{points:i||[]},silent:!0});return An(e,n),sn(t,n,this._defsUsePending,!1,!1),n},image:function(t,e){var a=new ui;return An(e,a),sn(t,a,this._defsUsePending,!1,!1),a.setStyle({image:t.getAttribute("xlink:href")||t.getAttribute("href"),x:+t.getAttribute("x"),y:+t.getAttribute("y"),width:+t.getAttribute("width"),height:+t.getAttribute("height")}),a.silent=!0,a},text:function(t,e){var a=t.getAttribute("x")||"0",i=t.getAttribute("y")||"0",n=t.getAttribute("dx")||"0",o=t.getAttribute("dy")||"0";this._textX=parseFloat(a)+parseFloat(n),this._textY=parseFloat(i)+parseFloat(o);var s=new Ae;return An(e,s),sn(t,s,this._defsUsePending,!1,!0),s},tspan:function(t,e){var a=t.getAttribute("x"),i=t.getAttribute("y");a!=null&&(this._textX=parseFloat(a)),i!=null&&(this._textY=parseFloat(i));var n=t.getAttribute("dx")||"0",o=t.getAttribute("dy")||"0",s=new Ae;return An(e,s),sn(t,s,this._defsUsePending,!1,!0),this._textX+=parseFloat(n),this._textY+=parseFloat(o),s},path:function(t,e){var a=t.getAttribute("d")||"",i=s5(a);return An(e,i),sn(t,i,this._defsUsePending,!1,!1),i.silent=!0,i}}}(),r}(),oL={lineargradient:function(r){var t=parseInt(r.getAttribute("x1")||"0",10),e=parseInt(r.getAttribute("y1")||"0",10),a=parseInt(r.getAttribute("x2")||"10",10),i=parseInt(r.getAttribute("y2")||"0",10),n=new Fh(t,e,a,i);return sL(r,n),lL(r,n),n},radialgradient:function(r){var t=parseInt(r.getAttribute("cx")||"0",10),e=parseInt(r.getAttribute("cy")||"0",10),a=parseInt(r.getAttribute("r")||"0",10),i=new c5(t,e,a);return sL(r,i),lL(r,i),i}};function sL(r,t){var e=r.getAttribute("gradientUnits");e==="userSpaceOnUse"&&(t.global=!0)}function lL(r,t){for(var e=r.firstChild;e;){if(e.nodeType===1&&e.nodeName.toLocaleLowerCase()==="stop"){var a=e.getAttribute("offset"),i=void 0;a&&a.indexOf("%")>0?i=parseInt(a,10)/100:a?i=parseFloat(a):i=0;var n={};mO(e,n,n);var o=n.stopColor||e.getAttribute("stop-color")||"#000000";t.colorStops.push({offset:i,color:o})}e=e.nextSibling}}function An(r,t){r&&r.__inheritedStyle&&(t.__inheritedStyle||(t.__inheritedStyle={}),ve(t.__inheritedStyle,r.__inheritedStyle))}function uL(r){for(var t=E_(r),e=[],a=0;a0;n-=2){var o=a[n],s=a[n-1],l=E_(o);switch(i=i||pn(),s){case"translate":ss(i,i,[parseFloat(l[0]),parseFloat(l[1]||"0")]);break;case"scale":mS(i,i,[parseFloat(l[0]),parseFloat(l[1]||l[0])]);break;case"rotate":Cv(i,i,-parseFloat(l[0])*Fx,[parseFloat(l[1]||"0"),parseFloat(l[2]||"0")]);break;case"skewX":var u=Math.tan(parseFloat(l[0])*Fx);Rs(i,[1,0,u,1,0,0],i);break;case"skewY":var v=Math.tan(parseFloat(l[0])*Fx);Rs(i,[1,v,0,1,0,0],i);break;case"matrix":i[0]=parseFloat(l[0]),i[1]=parseFloat(l[1]),i[2]=parseFloat(l[2]),i[3]=parseFloat(l[3]),i[4]=parseFloat(l[4]),i[5]=parseFloat(l[5]);break}}t.setLocalTransform(i)}}var cL=/([^\s:;]+)\s*:\s*([^:;]+)/g;function mO(r,t,e){var a=r.getAttribute("style");if(a){cL.lastIndex=0;for(var i;(i=cL.exec(a))!=null;){var n=i[1],o=Vt(xm,n)?xm[n]:null;o&&(t[o]=i[2]);var s=Vt(bm,n)?bm[n]:null;s&&(e[s]=i[2])}}}function xj(r,t,e){for(var a=0;a0,m={api:a,geo:l,mapOrGeoModel:t,data:s,isVisualEncodedByVisualMap:g,isGeo:o,transformInfoRaw:d};l.resourceType==="geoJSON"?this._buildGeoJSON(m):l.resourceType==="geoSVG"&&this._buildSVG(m),this._updateController(t,e,a),this._updateMapSelectHandler(t,u,a,i)},r.prototype._buildGeoJSON=function(t){var e=this._regionsGroupByName=Wt(),a=Wt(),i=this._regionsGroup,n=t.transformInfoRaw,o=t.mapOrGeoModel,s=t.data,l=t.geo.projection,u=l&&l.stream;function v(f,h){return h&&(f=h(f)),f&&[f[0]*n.scaleX+n.x,f[1]*n.scaleY+n.y]}function c(f){for(var h=[],g=!u&&l&&l.project,m=0;m=0)&&(d=i);var f=o?{normal:{align:"center",verticalAlign:"middle"}}:null;mi(t,ai(a),{labelFetcher:d,labelDataIndex:c,defaultText:e},f);var h=t.getTextContent();if(h&&(xO(h).ignore=h.ignore,t.textConfig&&o)){var g=t.getBoundingRect().clone();t.textConfig.layoutRect=g,t.textConfig.position=[(o[0]-g.x)/g.width*100+"%",(o[1]-g.y)/g.height*100+"%"]}t.disableLabelAnimation=!0}else t.removeTextContent(),t.removeTextConfig(),t.disableLabelAnimation=null}function gL(r,t,e,a,i,n){r.data?r.data.setItemGraphicEl(n,t):Ie(t).eventData={componentType:"geo",componentIndex:i.componentIndex,geoIndex:i.componentIndex,name:e,region:a&&a.option||{}}}function yL(r,t,e,a,i){r.data||kv({el:t,componentModel:i,itemName:e,itemTooltipOption:a.get("tooltip")})}function mL(r,t,e,a,i){t.highDownSilentOnTouch=!!i.get("selectedMode");var n=a.getModel("emphasis"),o=n.get("focus");return Ia(t,o,n.get("blurScope"),n.get("disabled")),r.isGeo&&EH(t,i,e),o}function _L(r,t,e){var a=[],i;function n(){i=[]}function o(){i.length&&(a.push(i),i=[])}var s=t({polygonStart:n,polygonEnd:o,lineStart:n,lineEnd:o,point:function(l,u){isFinite(l)&&isFinite(u)&&i.push([l,u])},sphere:function(){}});return!e&&s.polygonStart(),R(r,function(l){s.lineStart();for(var u=0;u-1&&(i.style.stroke=i.style.fill,i.style.fill="#fff",i.style.lineWidth=2),i},t.type="series.map",t.dependencies=["geo"],t.layoutMode="box",t.defaultOption={z:2,coordinateSystem:"geo",map:"",left:"center",top:"center",aspectScale:null,showLegendSymbol:!0,boundingCoords:null,center:null,zoom:1,scaleLimit:null,selectedMode:!0,label:{show:!1,color:"#000"},itemStyle:{borderWidth:.5,borderColor:"#444",areaColor:"#eee"},emphasis:{label:{show:!0,color:"rgb(100,0,0)"},itemStyle:{areaColor:"rgba(255,215,0,0.8)"}},select:{label:{show:!0,color:"rgb(100,0,0)"},itemStyle:{color:"rgba(255,215,0,0.8)"}},nameProperty:"name"},t}(ma);function Fj(r,t){var e={};return R(r,function(a){a.each(a.mapDimension("value"),function(i,n){var o="ec-"+a.getName(n);e[o]=e[o]||[],isNaN(i)||e[o].push(i)})}),r[0].map(r[0].mapDimension("value"),function(a,i){for(var n="ec-"+r[0].getName(i),o=0,s=1/0,l=-1/0,u=e[n].length,v=0;v1?(S.width=w,S.height=w/m):(S.height=w,S.width=w*m),S.y=b[1]-S.height/2,S.x=b[0]-S.width/2;else{var C=r.getBoxLayoutParams();C.aspect=m,S=Xa(C,{width:h,height:g})}this.setViewRect(S.x,S.y,S.width,S.height),this.setCenter(r.get("center"),t),this.setZoom(r.get("zoom"))}function Uj(r,t){R(t.get("geoCoord"),function(e,a){r.addGeoCoord(a,e)})}var qj=function(){function r(){this.dimensions=wO}return r.prototype.create=function(t,e){var a=[];function i(o){return{nameProperty:o.get("nameProperty"),aspectScale:o.get("aspectScale"),projection:o.get("projection")}}t.eachComponent("geo",function(o,s){var l=o.get("map"),u=new u2(l+s,l,lt({nameMap:o.get("nameMap")},i(o)));u.zoomLimit=o.get("scaleLimit"),a.push(u),o.coordinateSystem=u,u.model=o,u.resize=SL,u.resize(o,e)}),t.eachSeries(function(o){var s=o.get("coordinateSystem");if(s==="geo"){var l=o.get("geoIndex")||0;o.coordinateSystem=a[l]}});var n={};return t.eachSeriesByType("map",function(o){if(!o.getHostGeoModel()){var s=o.getMapType();n[s]=n[s]||[],n[s].push(o)}}),R(n,function(o,s){var l=pt(o,function(v){return v.get("nameMap")}),u=new u2(s,s,lt({nameMap:fS(l)},i(o[0])));u.zoomLimit=si.apply(null,pt(o,function(v){return v.get("scaleLimit")})),a.push(u),u.resize=SL,u.resize(o[0],e),R(o,function(v){v.coordinateSystem=u,Uj(u,v)})}),a},r.prototype.getFilledRegions=function(t,e,a,i){for(var n=(t||[]).slice(),o=Wt(),s=0;s=0;o--){var s=i[o];s.hierNode={defaultAncestor:null,ancestor:s,prelim:0,modifier:0,change:0,shift:0,i:o,thread:null},e.push(s)}}function Kj(r,t){var e=r.isExpand?r.children:[],a=r.parentNode.children,i=r.hierNode.i?a[r.hierNode.i-1]:null;if(e.length){tK(r);var n=(e[0].hierNode.prelim+e[e.length-1].hierNode.prelim)/2;i?(r.hierNode.prelim=i.hierNode.prelim+t(r,i),r.hierNode.modifier=r.hierNode.prelim-n):r.hierNode.prelim=n}else i&&(r.hierNode.prelim=i.hierNode.prelim+t(r,i));r.parentNode.hierNode.defaultAncestor=eK(r,i,r.parentNode.hierNode.defaultAncestor||a[0],t)}function Qj(r){var t=r.hierNode.prelim+r.parentNode.hierNode.modifier;r.setLayout({x:t},!0),r.hierNode.modifier+=r.parentNode.hierNode.modifier}function AL(r){return arguments.length?r:iK}function Cf(r,t){return r-=Math.PI/2,{x:t*Math.cos(r),y:t*Math.sin(r)}}function Jj(r,t){return Xa(r.getBoxLayoutParams(),{width:t.getWidth(),height:t.getHeight()})}function tK(r){for(var t=r.children,e=t.length,a=0,i=0;--e>=0;){var n=t[e];n.hierNode.prelim+=a,n.hierNode.modifier+=a,i+=n.hierNode.change,a+=n.hierNode.shift+i}}function eK(r,t,e,a){if(t){for(var i=r,n=r,o=n.parentNode.children[0],s=t,l=i.hierNode.modifier,u=n.hierNode.modifier,v=o.hierNode.modifier,c=s.hierNode.modifier;s=Gx(s),n=Hx(n),s&&n;){i=Gx(i),o=Hx(o),i.hierNode.ancestor=r;var d=s.hierNode.prelim+c-n.hierNode.prelim-u+a(s,n);d>0&&(aK(rK(s,r,e),r,d),u+=d,l+=d),c+=s.hierNode.modifier,u+=n.hierNode.modifier,l+=i.hierNode.modifier,v+=o.hierNode.modifier}s&&!Gx(i)&&(i.hierNode.thread=s,i.hierNode.modifier+=c-l),n&&!Hx(o)&&(o.hierNode.thread=n,o.hierNode.modifier+=u-v,e=r)}return e}function Gx(r){var t=r.children;return t.length&&r.isExpand?t[t.length-1]:r.hierNode.thread}function Hx(r){var t=r.children;return t.length&&r.isExpand?t[0]:r.hierNode.thread}function rK(r,t,e){return r.hierNode.ancestor.parentNode===t.parentNode?r.hierNode.ancestor:e}function aK(r,t,e){var a=e/(t.hierNode.i-r.hierNode.i);t.hierNode.change-=a,t.hierNode.shift+=e,t.hierNode.modifier+=e,t.hierNode.prelim+=e,r.hierNode.change+=a}function iK(r,t){return r.parentNode===t.parentNode?1:2}var nK=function(){function r(){this.parentPoint=[],this.childPoints=[]}return r}(),oK=function(r){tt(t,r);function t(e){return r.call(this,e)||this}return t.prototype.getDefaultStyle=function(){return{stroke:"#000",fill:null}},t.prototype.getDefaultShape=function(){return new nK},t.prototype.buildPath=function(e,a){var i=a.childPoints,n=i.length,o=a.parentPoint,s=i[0],l=i[n-1];if(n===1){e.moveTo(o[0],o[1]),e.lineTo(s[0],s[1]);return}var u=a.orient,v=u==="TB"||u==="BT"?0:1,c=1-v,d=Lt(a.forkPosition,1),f=[];f[v]=o[v],f[c]=o[c]+(l[c]-o[c])*d,e.moveTo(o[0],o[1]),e.lineTo(f[0],f[1]),e.moveTo(s[0],s[1]),f[v]=s[v],e.lineTo(f[0],f[1]),f[v]=l[v],e.lineTo(f[0],f[1]),e.lineTo(l[0],l[1]);for(var h=1;hb.x,C||(S=S-Math.PI));var k=C?"left":"right",D=s.getModel("label"),M=D.get("rotate"),L=M*(Math.PI/180),I=m.getTextContent();I&&(m.setTextConfig({position:D.get("position")||k,rotation:M==null?-S:L,origin:"center"}),I.setStyle("verticalAlign","middle"))}var P=s.get(["emphasis","focus"]),E=P==="relative"?eh(o.getAncestorsIndices(),o.getDescendantIndices()):P==="ancestor"?o.getAncestorsIndices():P==="descendant"?o.getDescendantIndices():null;E&&(Ie(e).focus=E),lK(i,o,v,e,h,f,g,a),e.__edge&&(e.onHoverStateChange=function(N){if(N!=="blur"){var O=o.parentNode&&r.getItemGraphicEl(o.parentNode.dataIndex);O&&O.hoverState===zh||tm(e.__edge,N)}})}function lK(r,t,e,a,i,n,o,s){var l=t.getModel(),u=r.get("edgeShape"),v=r.get("layout"),c=r.getOrient(),d=r.get(["lineStyle","curveness"]),f=r.get("edgeForkPosition"),h=l.getModel("lineStyle").getLineStyle(),g=a.__edge;if(u==="curve")t.parentNode&&t.parentNode!==e&&(g||(g=a.__edge=new Vh({shape:v2(v,c,d,i,i)})),Gr(g,{shape:v2(v,c,d,n,o)},r));else if(u==="polyline"&&v==="orthogonal"&&t!==e&&t.children&&t.children.length!==0&&t.isExpand===!0){for(var m=t.children,x=[],b=0;be&&(e=i.height)}this.height=e+1},r.prototype.getNodeById=function(t){if(this.getId()===t)return this;for(var e=0,a=this.children,i=a.length;e=0&&this.hostTree.data.setItemLayout(this.dataIndex,t,e)},r.prototype.getLayout=function(){return this.hostTree.data.getItemLayout(this.dataIndex)},r.prototype.getModel=function(t){if(!(this.dataIndex<0)){var e=this.hostTree,a=e.data.getItemModel(this.dataIndex);return a.getModel(t)}},r.prototype.getLevelModel=function(){return(this.hostTree.levelModels||[])[this.depth]},r.prototype.setVisual=function(t,e){this.dataIndex>=0&&this.hostTree.data.setItemVisual(this.dataIndex,t,e)},r.prototype.getVisual=function(t){return this.hostTree.data.getItemVisual(this.dataIndex,t)},r.prototype.getRawIndex=function(){return this.hostTree.data.getRawIndex(this.dataIndex)},r.prototype.getId=function(){return this.hostTree.data.getId(this.dataIndex)},r.prototype.getChildIndex=function(){if(this.parentNode){for(var t=this.parentNode.children,e=0;e=0){var a=e.getData().tree.root,i=r.targetNode;if(Pt(i)&&(i=a.getNodeById(i)),i&&a.contains(i))return{node:i};var n=r.targetNodeId;if(n!=null&&(i=a.getNodeById(n)))return{node:i}}}function DO(r){for(var t=[];r;)r=r.parentNode,r&&t.push(r);return t.reverse()}function DT(r,t){var e=DO(r);return ir(e,t)>=0}function N_(r,t){for(var e=[];r;){var a=r.dataIndex;e.push({name:r.name,dataIndex:a,value:t.getRawValue(a)}),r=r.parentNode}return e.reverse(),e}var yK=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.hasSymbolVisual=!0,e.ignoreStyleOnData=!0,e}return t.prototype.getInitialData=function(e){var a={name:e.name,children:e.data},i=e.leaves||{},n=new ia(i,this,this.ecModel),o=kT.createTree(a,this,s);function s(c){c.wrapMethod("getItemModel",function(d,f){var h=o.getNodeByDataIndex(f);return h&&h.children.length&&h.isExpand||(d.parentModel=n),d})}var l=0;o.eachNode("preorder",function(c){c.depth>l&&(l=c.depth)});var u=e.expandAndCollapse,v=u&&e.initialTreeDepth>=0?e.initialTreeDepth:l;return o.root.eachNode("preorder",function(c){var d=c.hostTree.data.getRawDataItem(c.dataIndex);c.isExpand=d&&d.collapsed!=null?!d.collapsed:c.depth<=v}),o.data},t.prototype.getOrient=function(){var e=this.get("orient");return e==="horizontal"?e="LR":e==="vertical"&&(e="TB"),e},t.prototype.setZoom=function(e){this.option.zoom=e},t.prototype.setCenter=function(e){this.option.center=e},t.prototype.formatTooltip=function(e,a,i){for(var n=this.getData().tree,o=n.root.children[0],s=n.getNodeByDataIndex(e),l=s.getValue(),u=s.name;s&&s!==o;)u=s.parentNode.name+"."+u,s=s.parentNode;return ii("nameValue",{name:u,value:l,noValue:isNaN(l)||l==null})},t.prototype.getDataParams=function(e){var a=r.prototype.getDataParams.apply(this,arguments),i=this.getData().tree.getNodeByDataIndex(e);return a.treeAncestors=N_(i,this),a.collapsed=!i.isExpand,a},t.type="series.tree",t.layoutMode="box",t.defaultOption={z:2,coordinateSystem:"view",left:"12%",top:"12%",right:"12%",bottom:"12%",layout:"orthogonal",edgeShape:"curve",edgeForkPosition:"50%",roam:!1,nodeScaleRatio:.4,center:null,zoom:1,orient:"LR",symbol:"emptyCircle",symbolSize:7,expandAndCollapse:!0,initialTreeDepth:2,lineStyle:{color:"#ccc",width:1.5,curveness:.5},itemStyle:{color:"lightsteelblue",borderWidth:1.5},label:{show:!0},animationEasing:"linear",animationDuration:700,animationDurationUpdate:500},t}(ma);function mK(r,t,e){for(var a=[r],i=[],n;n=a.pop();)if(i.push(n),n.isExpand){var o=n.children;if(o.length)for(var s=0;s=0;n--)e.push(i[n])}}function _K(r,t){r.eachSeriesByType("tree",function(e){xK(e,t)})}function xK(r,t){var e=Jj(r,t);r.layoutInfo=e;var a=r.get("layout"),i=0,n=0,o=null;a==="radial"?(i=2*Math.PI,n=Math.min(e.height,e.width)/2,o=AL(function(w,S){return(w.parentNode===S.parentNode?1:2)/w.depth})):(i=e.width,n=e.height,o=AL());var s=r.getData().tree.root,l=s.children[0];if(l){jj(s),mK(l,Kj,o),s.hierNode.modifier=-l.hierNode.prelim,lf(l,Qj);var u=l,v=l,c=l;lf(l,function(w){var S=w.getLayout().x;Sv.getLayout().x&&(v=w),w.depth>c.depth&&(c=w)});var d=u===v?1:o(u,v)/2,f=d-u.getLayout().x,h=0,g=0,m=0,x=0;if(a==="radial")h=i/(v.getLayout().x+d+f),g=n/(c.depth-1||1),lf(l,function(w){m=(w.getLayout().x+f)*h,x=(w.depth-1)*g;var S=Cf(m,x);w.setLayout({x:S.x,y:S.y,rawX:m,rawY:x},!0)});else{var b=r.getOrient();b==="RL"||b==="LR"?(g=n/(v.getLayout().x+d+f),h=i/(c.depth-1||1),lf(l,function(w){x=(w.getLayout().x+f)*g,m=b==="LR"?(w.depth-1)*h:i-(w.depth-1)*h,w.setLayout({x:m,y:x},!0)})):(b==="TB"||b==="BT")&&(h=i/(v.getLayout().x+d+f),g=n/(c.depth-1||1),lf(l,function(w){m=(w.getLayout().x+f)*h,x=b==="TB"?(w.depth-1)*g:n-(w.depth-1)*g,w.setLayout({x:m,y:x},!0)}))}}}function bK(r){r.eachSeriesByType("tree",function(t){var e=t.getData(),a=e.tree;a.eachNode(function(i){var n=i.getModel(),o=n.getModel("itemStyle").getItemStyle(),s=e.ensureUniqueItemVisual(i.dataIndex,"style");lt(s,o)})})}function wK(r){r.registerAction({type:"treeExpandAndCollapse",event:"treeExpandAndCollapse",update:"update"},function(t,e){e.eachComponent({mainType:"series",subType:"tree",query:t},function(a){var i=t.dataIndex,n=a.getData().tree,o=n.getNodeByDataIndex(i);o.isExpand=!o.isExpand})}),r.registerAction({type:"treeRoam",event:"treeRoam",update:"none"},function(t,e,a){e.eachComponent({mainType:"series",subType:"tree",query:t},function(i){var n=i.coordinateSystem,o=AT(n,t,void 0,a);i.setCenter&&i.setCenter(o.center),i.setZoom&&i.setZoom(o.zoom)})})}function SK(r){r.registerChartView(sK),r.registerSeriesModel(yK),r.registerLayout(_K),r.registerVisual(bK),wK(r)}var LL=["treemapZoomToNode","treemapRender","treemapMove"];function TK(r){for(var t=0;t1;)n=n.parentNode;var o=Bw(r.ecModel,n.name||n.dataIndex+"",a);i.setVisual("decal",o)})}var AK=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.preventUsingHoverLayer=!0,e}return t.prototype.getInitialData=function(e,a){var i={name:e.name,children:e.data};LO(i);var n=e.levels||[],o=this.designatedVisualItemStyle={},s=new ia({itemStyle:o},this,a);n=e.levels=CK(n,a);var l=pt(n||[],function(c){return new ia(c,s,a)},this),u=kT.createTree(i,this,v);function v(c){c.wrapMethod("getItemModel",function(d,f){var h=u.getNodeByDataIndex(f),g=h?l[h.depth]:null;return d.parentModel=g||s,d})}return u.data},t.prototype.optionUpdated=function(){this.resetViewRoot()},t.prototype.formatTooltip=function(e,a,i){var n=this.getData(),o=this.getRawValue(e),s=n.getName(e);return ii("nameValue",{name:s,value:o})},t.prototype.getDataParams=function(e){var a=r.prototype.getDataParams.apply(this,arguments),i=this.getData().tree.getNodeByDataIndex(e);return a.treeAncestors=N_(i,this),a.treePathInfo=a.treeAncestors,a},t.prototype.setLayoutInfo=function(e){this.layoutInfo=this.layoutInfo||{},lt(this.layoutInfo,e)},t.prototype.mapIdToIndex=function(e){var a=this._idIndexMap;a||(a=this._idIndexMap=Wt(),this._idIndexMapCount=0);var i=a.get(e);return i==null&&a.set(e,i=this._idIndexMapCount++),i},t.prototype.getViewRoot=function(){return this._viewRoot},t.prototype.resetViewRoot=function(e){e?this._viewRoot=e:e=this._viewRoot;var a=this.getRawData().tree.root;(!e||e!==a&&!a.contains(e))&&(this._viewRoot=a)},t.prototype.enableAriaDecal=function(){MO(this)},t.type="series.treemap",t.layoutMode="box",t.defaultOption={progressive:0,left:"center",top:"middle",width:"80%",height:"80%",sort:!0,clipWindow:"origin",squareRatio:.5*(1+Math.sqrt(5)),leafDepth:null,drillDownIcon:"▶",zoomToNodeRatio:.32*.32,scaleLimit:null,roam:!0,nodeClick:"zoomToNode",animation:!0,animationDurationUpdate:900,animationEasing:"quinticInOut",breadcrumb:{show:!0,height:22,left:"center",top:"bottom",emptyItemWidth:25,itemStyle:{color:"rgba(0,0,0,0.7)",textStyle:{color:"#fff"}},emphasis:{itemStyle:{color:"rgba(0,0,0,0.9)"}}},label:{show:!0,distance:0,padding:5,position:"inside",color:"#fff",overflow:"truncate"},upperLabel:{show:!1,position:[0,"50%"],height:20,overflow:"truncate",verticalAlign:"middle"},itemStyle:{color:null,colorAlpha:null,colorSaturation:null,borderWidth:0,gapWidth:0,borderColor:"#fff",borderColorSaturation:null},emphasis:{upperLabel:{show:!0,position:[0,"50%"],overflow:"truncate",verticalAlign:"middle"}},visualDimension:0,visualMin:null,visualMax:null,color:[],colorAlpha:null,colorSaturation:null,colorMappingBy:"index",visibleMin:10,childrenVisibleMin:null,levels:[]},t}(ma);function LO(r){var t=0;R(r.children,function(a){LO(a);var i=a.value;ht(i)&&(i=i[0]),t+=i});var e=r.value;ht(e)&&(e=e[0]),(e==null||isNaN(e))&&(e=t),e<0&&(e=0),ht(r.value)?r.value[0]=e:r.value=e}function CK(r,t){var e=la(t.get("color")),a=la(t.get(["aria","decal","decals"]));if(e){r=r||[];var i,n;R(r,function(s){var l=new ia(s),u=l.get("color"),v=l.get("decal");(l.get(["itemStyle","color"])||u&&u!=="none")&&(i=!0),(l.get(["itemStyle","decal"])||v&&v!=="none")&&(n=!0)});var o=r[0]||(r[0]={});return i||(o.color=e.slice()),!n&&a&&(o.decal=a.slice()),r}}var kK=8,IL=8,Wx=5,DK=function(){function r(t){this.group=new Ae,t.add(this.group)}return r.prototype.render=function(t,e,a,i){var n=t.getModel("breadcrumb"),o=this.group;if(o.removeAll(),!(!n.get("show")||!a)){var s=n.getModel("itemStyle"),l=n.getModel("emphasis"),u=s.getModel("textStyle"),v=l.getModel(["itemStyle","textStyle"]),c={pos:{left:n.get("left"),right:n.get("right"),top:n.get("top"),bottom:n.get("bottom")},box:{width:e.getWidth(),height:e.getHeight()},emptyItemWidth:n.get("emptyItemWidth"),totalWidth:0,renderList:[]};this._prepare(a,c,u),this._renderContent(t,c,s,l,u,v,i),w_(o,c.pos,c.box)}},r.prototype._prepare=function(t,e,a){for(var i=t;i;i=i.parentNode){var n=Za(i.getModel().get("name"),""),o=a.getTextRect(n),s=Math.max(o.width+kK*2,e.emptyItemWidth);e.totalWidth+=s+IL,e.renderList.push({node:i,text:n,width:s})}},r.prototype._renderContent=function(t,e,a,i,n,o,s){for(var l=0,u=e.emptyItemWidth,v=t.get(["breadcrumb","height"]),c=OW(e.pos,e.box),d=e.totalWidth,f=e.renderList,h=i.getModel("itemStyle").getItemStyle(),g=f.length-1;g>=0;g--){var m=f[g],x=m.node,b=m.width,w=m.text;d>c.width&&(d-=b-u,b=u,w=null);var S=new Hi({shape:{points:MK(l,0,b,v,g===f.length-1,g===0)},style:ve(a.getItemStyle(),{lineJoin:"bevel"}),textContent:new Rr({style:ya(n,{text:w})}),textConfig:{position:"inside"},z2:od*1e4,onclick:We(s,x)});S.disableLabelAnimation=!0,S.getTextContent().ensureState("emphasis").style=ya(o,{text:w}),S.ensureState("emphasis").style=h,Ia(S,i.get("focus"),i.get("blurScope"),i.get("disabled")),this.group.add(S),LK(S,t,x),l+=b+IL}},r.prototype.remove=function(){this.group.removeAll()},r}();function MK(r,t,e,a,i,n){var o=[[i?r:r-Wx,t],[r+e,t],[r+e,t+a],[i?r:r-Wx,t+a]];return!n&&o.splice(2,0,[r+e+Wx,t+a/2]),!i&&o.push([r,t+a/2]),o}function LK(r,t,e){Ie(r).eventData={componentType:"series",componentSubType:"treemap",componentIndex:t.componentIndex,seriesIndex:t.seriesIndex,seriesName:t.name,seriesType:"treemap",selfType:"breadcrumb",nodeData:{dataIndex:e&&e.dataIndex,name:e&&e.name},treePathInfo:e&&N_(e,t)}}var IK=function(){function r(){this._storage=[],this._elExistsMap={}}return r.prototype.add=function(t,e,a,i,n){return this._elExistsMap[t.id]?!1:(this._elExistsMap[t.id]=!0,this._storage.push({el:t,target:e,duration:a,delay:i,easing:n}),!0)},r.prototype.finished=function(t){return this._finishedCallback=t,this},r.prototype.start=function(){for(var t=this,e=this._storage.length,a=function(){e--,e<=0&&(t._storage.length=0,t._elExistsMap={},t._finishedCallback&&t._finishedCallback())},i=0,n=this._storage.length;iPL||Math.abs(e.dy)>PL)){var a=this.seriesModel.getData().tree.root;if(!a)return;var i=a.getLayout();if(!i)return;this.api.dispatchAction({type:"treemapMove",from:this.uid,seriesId:this.seriesModel.id,rootRect:{x:i.x+e.dx,y:i.y+e.dy,width:i.width,height:i.height}})}},t.prototype._onZoom=function(e){var a=e.originX,i=e.originY,n=e.scale;if(this._state!=="animating"){var o=this.seriesModel.getData().tree.root;if(!o)return;var s=o.getLayout();if(!s)return;var l=new er(s.x,s.y,s.width,s.height),u=null,v=this._controllerHost;u=v.zoomLimit;var c=v.zoom=v.zoom||1;if(c*=n,u){var d=u.min||0,f=u.max||1/0;c=Math.max(Math.min(f,c),d)}var h=c/v.zoom;v.zoom=c;var g=this.seriesModel.layoutInfo;a-=g.x,i-=g.y;var m=pn();ss(m,m,[-a,-i]),mS(m,m,[h,h]),ss(m,m,[a,i]),l.applyTransform(m),this.api.dispatchAction({type:"treemapRender",from:this.uid,seriesId:this.seriesModel.id,rootRect:{x:l.x,y:l.y,width:l.width,height:l.height}})}},t.prototype._initEvents=function(e){var a=this;e.on("click",function(i){if(a._state==="ready"){var n=a.seriesModel.get("nodeClick",!0);if(n){var o=a.findTarget(i.offsetX,i.offsetY);if(o){var s=o.node;if(s.getLayout().isLeafRoot)a._rootToNode(o);else if(n==="zoomToNode")a._zoomToNode(o);else if(n==="link"){var l=s.hostTree.data.getItemModel(s.dataIndex),u=l.get("link",!0),v=l.get("target",!0)||"blank";u&&om(u,v)}}}}},this)},t.prototype._renderBreadcrumb=function(e,a,i){var n=this;i||(i=e.get("leafDepth",!0)!=null?{node:e.getViewRoot()}:this.findTarget(a.getWidth()/2,a.getHeight()/2),i||(i={node:e.getData().tree.root})),(this._breadcrumb||(this._breadcrumb=new DK(this.group))).render(e,a,i.node,function(o){n._state!=="animating"&&(DT(e.getViewRoot(),o)?n._rootToNode({node:o}):n._zoomToNode({node:o}))})},t.prototype.remove=function(){this._clearController(),this._containerGroup&&this._containerGroup.removeAll(),this._storage=uf(),this._state="ready",this._breadcrumb&&this._breadcrumb.remove()},t.prototype.dispose=function(){this._clearController()},t.prototype._zoomToNode=function(e){this.api.dispatchAction({type:"treemapZoomToNode",from:this.uid,seriesId:this.seriesModel.id,targetNode:e.node})},t.prototype._rootToNode=function(e){this.api.dispatchAction({type:"treemapRootToNode",from:this.uid,seriesId:this.seriesModel.id,targetNode:e.node})},t.prototype.findTarget=function(e,a){var i,n=this.seriesModel.getViewRoot();return n.eachNode({attr:"viewChildren",order:"preorder"},function(o){var s=this._storage.background[o.getRawIndex()];if(s){var l=s.transformCoordToLocal(e,a),u=s.shape;if(u.x<=l[0]&&l[0]<=u.x+u.width&&u.y<=l[1]&&l[1]<=u.y+u.height)i={node:o,offsetX:l[0],offsetY:l[1]};else return!1}},this),i},t.type="treemap",t}(ca);function uf(){return{nodeGroup:[],background:[],content:[]}}function zK(r,t,e,a,i,n,o,s,l,u){if(!o)return;var v=o.getLayout(),c=r.getData(),d=o.getModel();if(c.setItemGraphicEl(o.dataIndex,null),!v||!v.isInView)return;var f=v.width,h=v.height,g=v.borderWidth,m=v.invisible,x=o.getRawIndex(),b=s&&s.getRawIndex(),w=o.viewChildren,S=v.upperHeight,C=w&&w.length,T=d.getModel("itemStyle"),k=d.getModel(["emphasis","itemStyle"]),D=d.getModel(["blur","itemStyle"]),M=d.getModel(["select","itemStyle"]),L=T.get("borderRadius")||0,I=et("nodeGroup",c2);if(!I)return;if(l.add(I),I.x=v.x||0,I.y=v.y||0,I.markRedraw(),wm(I).nodeWidth=f,wm(I).nodeHeight=h,v.isAboveViewRoot)return I;var P=et("background",RL,u,EK);P&&Y(I,P,C&&v.upperLabelHeight);var E=d.getModel("emphasis"),N=E.get("focus"),O=E.get("blurScope"),V=E.get("disabled"),B=N==="ancestor"?o.getAncestorsIndices():N==="descendant"?o.getDescendantIndices():N;if(C)lh(I)&&Fu(I,!1),P&&(Fu(P,!V),c.setItemGraphicEl(o.dataIndex,P),Lw(P,B,O));else{var z=et("content",RL,u,NK);z&&$(I,z),P.disableMorphing=!0,P&&lh(P)&&Fu(P,!1),Fu(I,!V),c.setItemGraphicEl(o.dataIndex,I);var H=d.getShallow("cursor");H&&z.attr("cursor",H),Lw(I,B,O)}return I;function Y(xt,mt,wt){var ft=Ie(mt);if(ft.dataIndex=o.dataIndex,ft.seriesIndex=r.seriesIndex,mt.setShape({x:0,y:0,width:f,height:h,r:L}),m)Z(mt);else{mt.invisible=!1;var K=o.getVisual("style"),Ct=K.stroke,Mt=OL(T);Mt.fill=Ct;var zt=Cu(k);zt.fill=k.get("borderColor");var Yt=Cu(D);Yt.fill=D.get("borderColor");var we=Cu(M);if(we.fill=M.get("borderColor"),wt){var Kt=f-2*g;Q(mt,Ct,K.opacity,{x:g,y:0,width:Kt,height:S})}else mt.removeTextContent();mt.setStyle(Mt),mt.ensureState("emphasis").style=zt,mt.ensureState("blur").style=Yt,mt.ensureState("select").style=we,mv(mt)}xt.add(mt)}function $(xt,mt){var wt=Ie(mt);wt.dataIndex=o.dataIndex,wt.seriesIndex=r.seriesIndex;var ft=Math.max(f-2*g,0),K=Math.max(h-2*g,0);if(mt.culling=!0,mt.setShape({x:g,y:g,width:ft,height:K,r:L}),m)Z(mt);else{mt.invisible=!1;var Ct=o.getVisual("style"),Mt=Ct.fill,zt=OL(T);zt.fill=Mt,zt.decal=Ct.decal;var Yt=Cu(k),we=Cu(D),Kt=Cu(M);Q(mt,Mt,Ct.opacity,null),mt.setStyle(zt),mt.ensureState("emphasis").style=Yt,mt.ensureState("blur").style=we,mt.ensureState("select").style=Kt,mv(mt)}xt.add(mt)}function Z(xt){!xt.invisible&&n.push(xt)}function Q(xt,mt,wt,ft){var K=d.getModel(ft?NL:EL),Ct=Za(d.get("name"),null),Mt=K.getShallow("show");mi(xt,ai(d,ft?NL:EL),{defaultText:Mt?Ct:null,inheritColor:mt,defaultOpacity:wt,labelFetcher:r,labelDataIndex:o.dataIndex});var zt=xt.getTextContent();if(zt){var Yt=zt.style,we=pS(Yt.padding||0);ft&&(xt.setTextConfig({layoutRect:ft}),zt.disableLabelLayout=!0),zt.beforeUpdate=function(){var qe=Math.max((ft?ft.width:xt.shape.width)-we[1]-we[3],0),fr=Math.max((ft?ft.height:xt.shape.height)-we[0]-we[2],0);(Yt.width!==qe||Yt.height!==fr)&&zt.setStyle({width:qe,height:fr})},Yt.truncateMinChar=2,Yt.lineOverflow="truncate",at(Yt,ft,v);var Kt=zt.getState("emphasis");at(Kt?Kt.style:null,ft,v)}}function at(xt,mt,wt){var ft=xt?xt.text:null;if(!mt&&wt.isLeafRoot&&ft!=null){var K=r.get("drillDownIcon",!0);xt.text=K?K+" "+ft:ft}}function et(xt,mt,wt,ft){var K=b!=null&&e[xt][b],Ct=i[xt];return K?(e[xt][b]=null,_t(Ct,K)):m||(K=new mt,K instanceof jn&&(K.z2=BK(wt,ft)),Ot(Ct,K)),t[xt][x]=K}function _t(xt,mt){var wt=xt[x]={};mt instanceof c2?(wt.oldX=mt.x,wt.oldY=mt.y):wt.oldShape=lt({},mt.shape)}function Ot(xt,mt){var wt=xt[x]={},ft=o.parentNode,K=mt instanceof Ae;if(ft&&(!a||a.direction==="drillDown")){var Ct=0,Mt=0,zt=i.background[ft.getRawIndex()];!a&&zt&&zt.oldShape&&(Ct=zt.oldShape.width,Mt=zt.oldShape.height),K?(wt.oldX=0,wt.oldY=Mt):wt.oldShape={x:Ct,y:Mt,width:0,height:0}}wt.fadein=!K}}function BK(r,t){return r*PK+t}var wh=R,VK=pe,Sm=-1,ei=function(){function r(t){var e=t.mappingMethod,a=t.type,i=this.option=be(t);this.type=a,this.mappingMethod=e,this._normalizeData=HK[e];var n=r.visualHandlers[a];this.applyVisual=n.applyVisual,this.getColorMapper=n.getColorMapper,this._normalizedToVisual=n._normalizedToVisual[e],e==="piecewise"?(Ux(i),FK(i)):e==="category"?i.categories?GK(i):Ux(i,!0):(Vi(e!=="linear"||i.dataExtent),Ux(i))}return r.prototype.mapValueToVisual=function(t){var e=this._normalizeData(t);return this._normalizedToVisual(e,t)},r.prototype.getNormalizer=function(){return Nt(this._normalizeData,this)},r.listVisualTypes=function(){return xr(r.visualHandlers)},r.isValidType=function(t){return r.visualHandlers.hasOwnProperty(t)},r.eachVisual=function(t,e,a){pe(t)?R(t,e,a):e.call(a,t)},r.mapVisual=function(t,e,a){var i,n=ht(t)?[]:pe(t)?{}:(i=!0,null);return r.eachVisual(t,function(o,s){var l=e.call(a,o,s);i?n=l:n[s]=l}),n},r.retrieveVisuals=function(t){var e={},a;return t&&wh(r.visualHandlers,function(i,n){t.hasOwnProperty(n)&&(e[n]=t[n],a=!0)}),a?e:null},r.prepareVisualTypes=function(t){if(ht(t))t=t.slice();else if(VK(t)){var e=[];wh(t,function(a,i){e.push(i)}),t=e}else return[];return t.sort(function(a,i){return i==="color"&&a!=="color"&&a.indexOf("color")===0?1:-1}),t},r.dependsOn=function(t,e){return e==="color"?!!(t&&t.indexOf(e)===0):t===e},r.findPieceIndex=function(t,e,a){for(var i,n=1/0,o=0,s=e.length;o=0;n--)a[n]==null&&(delete e[t[n]],t.pop())}function Ux(r,t){var e=r.visual,a=[];pe(e)?wh(e,function(n){a.push(n)}):e!=null&&a.push(e);var i={color:1,symbol:1};!t&&a.length===1&&!i.hasOwnProperty(r.type)&&(a[1]=a[0]),IO(r,a)}function ey(r){return{applyVisual:function(t,e,a){var i=this.mapValueToVisual(t);a("color",r(e("color"),i))},_normalizedToVisual:d2([0,1])}}function zL(r){var t=this.option.visual;return t[Math.round(ra(r,[0,1],[0,t.length-1],!0))]||{}}function vf(r){return function(t,e,a){a(r,this.mapValueToVisual(t))}}function kf(r){var t=this.option.visual;return t[this.option.loop&&r!==Sm?r%t.length:r]}function ku(){return this.option.visual[0]}function d2(r){return{linear:function(t){return ra(t,r,this.option.visual,!0)},category:kf,piecewise:function(t,e){var a=f2.call(this,e);return a==null&&(a=ra(t,r,this.option.visual,!0)),a},fixed:ku}}function f2(r){var t=this.option,e=t.pieceList;if(t.hasSpecialVisual){var a=ei.findPieceIndex(r,e),i=e[a];if(i&&i.visual)return i.visual[this.type]}}function IO(r,t){return r.visual=t,r.type==="color"&&(r.parsedVisual=pt(t,function(e){var a=gn(e);return a||[0,0,0,1]})),t}var HK={linear:function(r){return ra(r,this.option.dataExtent,[0,1],!0)},piecewise:function(r){var t=this.option.pieceList,e=ei.findPieceIndex(r,t,!0);if(e!=null)return ra(e,[0,t.length-1],[0,1],!0)},category:function(r){var t=this.option.categories?this.option.categoryMap[r]:r;return t??Sm},fixed:Fa};function ry(r,t,e){return r?t<=e:t=e.length||g===e[g.depth]){var x=ZK(i,l,g,m,h,a);PO(g,x,e,a)}})}}}function qK(r,t,e){var a=lt({},t),i=e.designatedVisualItemStyle;return R(["color","colorAlpha","colorSaturation"],function(n){i[n]=t[n];var o=r.get(n);i[n]=null,o!=null&&(a[n]=o)}),a}function BL(r){var t=qx(r,"color");if(t){var e=qx(r,"colorAlpha"),a=qx(r,"colorSaturation");return a&&(t=zf(t,null,null,a)),e&&(t=Zy(t,e)),t}}function $K(r,t){return t!=null?zf(t,null,null,r):null}function qx(r,t){var e=r[t];if(e!=null&&e!=="none")return e}function YK(r,t,e,a,i,n){if(!(!n||!n.length)){var o=$x(t,"color")||i.color!=null&&i.color!=="none"&&($x(t,"colorAlpha")||$x(t,"colorSaturation"));if(o){var s=t.get("visualMin"),l=t.get("visualMax"),u=e.dataExtent.slice();s!=null&&su[1]&&(u[1]=l);var v=t.get("colorMappingBy"),c={type:o.name,dataExtent:u,visual:o.range};c.type==="color"&&(v==="index"||v==="id")?(c.mappingMethod="category",c.loop=!0):c.mappingMethod="linear";var d=new ei(c);return RO(d).drColorMappingBy=v,d}}}function $x(r,t){var e=r.get(t);return ht(e)&&e.length?{name:t,range:e}:null}function ZK(r,t,e,a,i,n){var o=lt({},t);if(i){var s=i.type,l=s==="color"&&RO(i).drColorMappingBy,u=l==="index"?a:l==="id"?n.mapIdToIndex(e.getId()):e.getValue(r.get("visualDimension"));o[s]=i.mapValueToVisual(u)}return o}var Sh=Math.max,Tm=Math.min,VL=si,MT=R,EO=["itemStyle","borderWidth"],XK=["itemStyle","gapWidth"],jK=["upperLabel","show"],KK=["upperLabel","height"];const QK={seriesType:"treemap",reset:function(r,t,e,a){var i=e.getWidth(),n=e.getHeight(),o=r.option,s=Xa(r.getBoxLayoutParams(),{width:e.getWidth(),height:e.getHeight()}),l=o.size||[],u=Lt(VL(s.width,l[0]),i),v=Lt(VL(s.height,l[1]),n),c=a&&a.type,d=["treemapZoomToNode","treemapRootToNode"],f=bh(a,d,r),h=c==="treemapRender"||c==="treemapMove"?a.rootRect:null,g=r.getViewRoot(),m=DO(g);if(c!=="treemapMove"){var x=c==="treemapZoomToNode"?iQ(r,f,g,u,v):h?[h.width,h.height]:[u,v],b=o.sort;b&&b!=="asc"&&b!=="desc"&&(b="desc");var w={squareRatio:o.squareRatio,sort:b,leafDepth:o.leafDepth};g.hostTree.clearLayouts();var S={x:0,y:0,width:x[0],height:x[1],area:x[0]*x[1]};g.setLayout(S),NO(g,w,!1,0),S=g.getLayout(),MT(m,function(T,k){var D=(m[k+1]||g).getValue();T.setLayout(lt({dataExtent:[D,D],borderWidth:0,upperHeight:0},S))})}var C=r.getData().tree.root;C.setLayout(nQ(s,h,f),!0),r.setLayoutInfo(s),OO(C,new er(-s.x,-s.y,i,n),m,g,0)}};function NO(r,t,e,a){var i,n;if(!r.isRemoved()){var o=r.getLayout();i=o.width,n=o.height;var s=r.getModel(),l=s.get(EO),u=s.get(XK)/2,v=zO(s),c=Math.max(l,v),d=l-u,f=c-u;r.setLayout({borderWidth:l,upperHeight:c,upperLabelHeight:v},!0),i=Sh(i-2*d,0),n=Sh(n-d-f,0);var h=i*n,g=JK(r,s,h,t,e,a);if(g.length){var m={x:d,y:f,width:i,height:n},x=Tm(i,n),b=1/0,w=[];w.area=0;for(var S=0,C=g.length;S=0;l--){var u=i[a==="asc"?o-l-1:l].getValue();u/e*ts[1]&&(s[1]=u)})),{sum:a,dataExtent:s}}function aQ(r,t,e){for(var a=0,i=1/0,n=0,o=void 0,s=r.length;na&&(a=o));var l=r.area*r.area,u=t*t*e;return l?Sh(u*a/l,l/(u*i)):1/0}function FL(r,t,e,a,i){var n=t===e.width?0:1,o=1-n,s=["x","y"],l=["width","height"],u=e[s[n]],v=t?r.area/t:0;(i||v>e[l[o]])&&(v=e[l[o]]);for(var c=0,d=r.length;cJC&&(u=JC),n=s}ua&&(a=t);var n=a%2?a+2:a+3;i=[];for(var o=0;o0&&(C[0]=-C[0],C[1]=-C[1]);var k=S[0]<0?-1:1;if(n.__position!=="start"&&n.__position!=="end"){var D=-Math.atan2(S[1],S[0]);c[0].8?"left":d[0]<-.8?"right":"center",g=d[1]>.8?"top":d[1]<-.8?"bottom":"middle";break;case"start":n.x=-d[0]*x+v[0],n.y=-d[1]*b+v[1],h=d[0]>.8?"right":d[0]<-.8?"left":"center",g=d[1]>.8?"bottom":d[1]<-.8?"top":"middle";break;case"insideStartTop":case"insideStart":case"insideStartBottom":n.x=x*k+v[0],n.y=v[1]+M,h=S[0]<0?"right":"left",n.originX=-x*k,n.originY=-M;break;case"insideMiddleTop":case"insideMiddle":case"insideMiddleBottom":case"middle":n.x=T[0],n.y=T[1]+M,h="center",n.originY=-M;break;case"insideEndTop":case"insideEnd":case"insideEndBottom":n.x=-x*k+c[0],n.y=c[1]+M,h=S[0]>=0?"right":"left",n.originX=x*k,n.originY=-M;break}n.scaleX=n.scaleY=o,n.setStyle({verticalAlign:n.__verticalAlign||g,align:n.__align||h})}},t}(Ae),ET=function(){function r(t){this.group=new Ae,this._LineCtor=t||PT}return r.prototype.updateData=function(t){var e=this;this._progressiveEls=null;var a=this,i=a.group,n=a._lineData;a._lineData=t,n||i.removeAll();var o=$L(t);t.diff(n).add(function(s){e._doAdd(t,s,o)}).update(function(s,l){e._doUpdate(n,t,l,s,o)}).remove(function(s){i.remove(n.getItemGraphicEl(s))}).execute()},r.prototype.updateLayout=function(){var t=this._lineData;t&&t.eachItemGraphicEl(function(e,a){e.updateLayout(t,a)},this)},r.prototype.incrementalPrepareUpdate=function(t){this._seriesScope=$L(t),this._lineData=null,this.group.removeAll()},r.prototype.incrementalUpdate=function(t,e){this._progressiveEls=[];function a(s){!s.isGroup&&!SQ(s)&&(s.incremental=!0,s.ensureState("emphasis").hoverLayer=!0)}for(var i=t.start;i0}function $L(r){var t=r.hostModel,e=t.getModel("emphasis");return{lineStyle:t.getModel("lineStyle").getLineStyle(),emphasisLineStyle:e.getModel(["lineStyle"]).getLineStyle(),blurLineStyle:t.getModel(["blur","lineStyle"]).getLineStyle(),selectLineStyle:t.getModel(["select","lineStyle"]).getLineStyle(),emphasisDisabled:e.get("disabled"),blurScope:e.get("blurScope"),focus:e.get("focus"),labelStatesModels:ai(t)}}function YL(r){return isNaN(r[0])||isNaN(r[1])}function Kx(r){return r&&!YL(r[0])&&!YL(r[1])}var Qx=[],Jx=[],tb=[],rc=oi,eb=ev,ZL=Math.abs;function XL(r,t,e){for(var a=r[0],i=r[1],n=r[2],o=1/0,s,l=e*e,u=.1,v=.1;v<=.9;v+=.1){Qx[0]=rc(a[0],i[0],n[0],v),Qx[1]=rc(a[1],i[1],n[1],v);var c=ZL(eb(Qx,t)-l);c=0?s=s+u:s=s-u:h>=0?s=s-u:s=s+u}return s}function rb(r,t){var e=[],a=rh,i=[[],[],[]],n=[[],[]],o=[];t/=2,r.eachEdge(function(s,l){var u=s.getLayout(),v=s.getVisual("fromSymbol"),c=s.getVisual("toSymbol");u.__original||(u.__original=[Is(u[0]),Is(u[1])],u[2]&&u.__original.push(Is(u[2])));var d=u.__original;if(u[2]!=null){if(Ri(i[0],d[0]),Ri(i[1],d[2]),Ri(i[2],d[1]),v&&v!=="none"){var f=Mf(s.node1),h=XL(i,d[0],f*t);a(i[0][0],i[1][0],i[2][0],h,e),i[0][0]=e[3],i[1][0]=e[4],a(i[0][1],i[1][1],i[2][1],h,e),i[0][1]=e[3],i[1][1]=e[4]}if(c&&c!=="none"){var f=Mf(s.node2),h=XL(i,d[1],f*t);a(i[0][0],i[1][0],i[2][0],h,e),i[1][0]=e[1],i[2][0]=e[2],a(i[0][1],i[1][1],i[2][1],h,e),i[1][1]=e[1],i[2][1]=e[2]}Ri(u[0],i[0]),Ri(u[1],i[2]),Ri(u[2],i[1])}else{if(Ri(n[0],d[0]),Ri(n[1],d[1]),Bu(o,n[1],n[0]),ad(o,o),v&&v!=="none"){var f=Mf(s.node1);ew(n[0],n[0],o,f*t)}if(c&&c!=="none"){var f=Mf(s.node2);ew(n[1],n[1],o,-f*t)}Ri(u[0],n[0]),Ri(u[1],n[1])}})}function jL(r){return r.type==="view"}var TQ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.init=function(e,a){var i=new Zh,n=new ET,o=this.group;this._controller=new Jh(a.getZr()),this._controllerHost={target:o},o.add(i.group),o.add(n.group),this._symbolDraw=i,this._lineDraw=n,this._firstRender=!0},t.prototype.render=function(e,a,i){var n=this,o=e.coordinateSystem;this._model=e;var s=this._symbolDraw,l=this._lineDraw,u=this.group;if(jL(o)){var v={x:o.x,y:o.y,scaleX:o.scaleX,scaleY:o.scaleY};this._firstRender?u.attr(v):Gr(u,v,e)}rb(e.getGraph(),Df(e));var c=e.getData();s.updateData(c);var d=e.getEdgeData();l.updateData(d),this._updateNodeAndLinkScale(),this._updateController(e,a,i),clearTimeout(this._layoutTimeout);var f=e.forceLayout,h=e.get(["force","layoutAnimation"]);f&&this._startForceLayoutIteration(f,h);var g=e.get("layout");c.graph.eachNode(function(w){var S=w.dataIndex,C=w.getGraphicEl(),T=w.getModel();if(C){C.off("drag").off("dragend");var k=T.get("draggable");k&&C.on("drag",function(M){switch(g){case"force":f.warmUp(),!n._layouting&&n._startForceLayoutIteration(f,h),f.setFixed(S),c.setItemLayout(S,[C.x,C.y]);break;case"circular":c.setItemLayout(S,[C.x,C.y]),w.setLayout({fixed:!0},!0),RT(e,"symbolSize",w,[M.offsetX,M.offsetY]),n.updateLayout(e);break;case"none":default:c.setItemLayout(S,[C.x,C.y]),IT(e.getGraph(),e),n.updateLayout(e);break}}).on("dragend",function(){f&&f.setUnfixed(S)}),C.setDraggable(k,!!T.get("cursor"));var D=T.get(["emphasis","focus"]);D==="adjacency"&&(Ie(C).focus=w.getAdjacentDataIndices())}}),c.graph.eachEdge(function(w){var S=w.getGraphicEl(),C=w.getModel().get(["emphasis","focus"]);S&&C==="adjacency"&&(Ie(S).focus={edge:[w.dataIndex],node:[w.node1.dataIndex,w.node2.dataIndex]})});var m=e.get("layout")==="circular"&&e.get(["circular","rotateLabel"]),x=c.getLayout("cx"),b=c.getLayout("cy");c.graph.eachNode(function(w){GO(w,m,x,b)}),this._firstRender=!1},t.prototype.dispose=function(){this.remove(),this._controller&&this._controller.dispose(),this._controllerHost=null},t.prototype._startForceLayoutIteration=function(e,a){var i=this;(function n(){e.step(function(o){i.updateLayout(i._model),(i._layouting=!o)&&(a?i._layoutTimeout=setTimeout(n,16):n())})})()},t.prototype._updateController=function(e,a,i){var n=this,o=this._controller,s=this._controllerHost,l=this.group;if(o.setPointerChecker(function(u,v,c){var d=l.getBoundingRect();return d.applyTransform(l.transform),d.contain(v,c)&&!P_(u,i,e)}),!jL(e.coordinateSystem)){o.disable();return}o.enable(e.get("roam")),s.zoomLimit=e.get("scaleLimit"),s.zoom=e.coordinateSystem.getZoom(),o.off("pan").off("zoom").on("pan",function(u){wT(s,u.dx,u.dy),i.dispatchAction({seriesId:e.id,type:"graphRoam",dx:u.dx,dy:u.dy})}).on("zoom",function(u){ST(s,u.scale,u.originX,u.originY),i.dispatchAction({seriesId:e.id,type:"graphRoam",zoom:u.scale,originX:u.originX,originY:u.originY}),n._updateNodeAndLinkScale(),rb(e.getGraph(),Df(e)),n._lineDraw.updateLayout(),i.updateLabelLayout()})},t.prototype._updateNodeAndLinkScale=function(){var e=this._model,a=e.getData(),i=Df(e);a.eachItemGraphicEl(function(n,o){n&&n.setSymbolScale(i)})},t.prototype.updateLayout=function(e){rb(e.getGraph(),Df(e)),this._symbolDraw.updateLayout(),this._lineDraw.updateLayout()},t.prototype.remove=function(){clearTimeout(this._layoutTimeout),this._layouting=!1,this._layoutTimeout=null,this._symbolDraw&&this._symbolDraw.remove(),this._lineDraw&&this._lineDraw.remove()},t.type="graph",t}(ca);function ac(r){return"_EC_"+r}var AQ=function(){function r(t){this.type="graph",this.nodes=[],this.edges=[],this._nodesMap={},this._edgesMap={},this._directed=t||!1}return r.prototype.isDirected=function(){return this._directed},r.prototype.addNode=function(t,e){t=t==null?""+e:""+t;var a=this._nodesMap;if(!a[ac(t)]){var i=new Du(t,e);return i.hostGraph=this,this.nodes.push(i),a[ac(t)]=i,i}},r.prototype.getNodeByIndex=function(t){var e=this.data.getRawIndex(t);return this.nodes[e]},r.prototype.getNodeById=function(t){return this._nodesMap[ac(t)]},r.prototype.addEdge=function(t,e,a){var i=this._nodesMap,n=this._edgesMap;if(Or(t)&&(t=this.nodes[t]),Or(e)&&(e=this.nodes[e]),t instanceof Du||(t=i[ac(t)]),e instanceof Du||(e=i[ac(e)]),!(!t||!e)){var o=t.id+"-"+e.id,s=new WO(t,e,a);return s.hostGraph=this,this._directed&&(t.outEdges.push(s),e.inEdges.push(s)),t.edges.push(s),t!==e&&e.edges.push(s),this.edges.push(s),n[o]=s,s}},r.prototype.getEdgeByIndex=function(t){var e=this.edgeData.getRawIndex(t);return this.edges[e]},r.prototype.getEdge=function(t,e){t instanceof Du&&(t=t.id),e instanceof Du&&(e=e.id);var a=this._edgesMap;return this._directed?a[t+"-"+e]:a[t+"-"+e]||a[e+"-"+t]},r.prototype.eachNode=function(t,e){for(var a=this.nodes,i=a.length,n=0;n=0&&t.call(e,a[n],n)},r.prototype.eachEdge=function(t,e){for(var a=this.edges,i=a.length,n=0;n=0&&a[n].node1.dataIndex>=0&&a[n].node2.dataIndex>=0&&t.call(e,a[n],n)},r.prototype.breadthFirstTraverse=function(t,e,a,i){if(e instanceof Du||(e=this._nodesMap[ac(e)]),!!e){for(var n=a==="out"?"outEdges":a==="in"?"inEdges":"edges",o=0;o=0&&l.node2.dataIndex>=0});for(var n=0,o=i.length;n=0&&this[r][t].setItemVisual(this.dataIndex,e,a)},getVisual:function(e){return this[r][t].getItemVisual(this.dataIndex,e)},setLayout:function(e,a){this.dataIndex>=0&&this[r][t].setItemLayout(this.dataIndex,e,a)},getLayout:function(){return this[r][t].getItemLayout(this.dataIndex)},getGraphicEl:function(){return this[r][t].getItemGraphicEl(this.dataIndex)},getRawIndex:function(){return this[r][t].getRawIndex(this.dataIndex)}}}$a(Du,UO("hostGraph","data"));$a(WO,UO("hostGraph","edgeData"));function qO(r,t,e,a,i){for(var n=new AQ(a),o=0;o "+d)),u++)}var f=e.get("coordinateSystem"),h;if(f==="cartesian2d"||f==="polar")h=Ys(r,e);else{var g=Wh.get(f),m=g?g.dimensions||[]:[];ir(m,"value")<0&&m.concat(["value"]);var x=qh(r,{coordDimensions:m,encodeDefine:e.getEncode()}).dimensions;h=new Oi(x,e),h.initData(r)}var b=new Oi(["value"],e);return b.initData(l,s),i&&i(h,b),CO({mainData:h,struct:n,structAttr:"graph",datas:{node:h,edge:b},datasAttr:{node:"data",edge:"edgeData"}}),n.update(),n}var CQ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.hasSymbolVisual=!0,e}return t.prototype.init=function(e){r.prototype.init.apply(this,arguments);var a=this;function i(){return a._categoriesData}this.legendVisualProvider=new Qh(i,i),this.fillDataTextStyle(e.edges||e.links),this._updateCategoriesData()},t.prototype.mergeOption=function(e){r.prototype.mergeOption.apply(this,arguments),this.fillDataTextStyle(e.edges||e.links),this._updateCategoriesData()},t.prototype.mergeDefaultAndTheme=function(e){r.prototype.mergeDefaultAndTheme.apply(this,arguments),pv(e,"edgeLabel",["show"])},t.prototype.getInitialData=function(e,a){var i=e.edges||e.links||[],n=e.data||e.nodes||[],o=this;if(n&&i){dQ(this);var s=qO(n,i,this,!0,l);return R(s.edges,function(u){fQ(u.node1,u.node2,this,u.dataIndex)},this),s.data}function l(u,v){u.wrapMethod("getItemModel",function(h){var g=o._categoriesModels,m=h.getShallow("category"),x=g[m];return x&&(x.parentModel=h.parentModel,h.parentModel=x),h});var c=ia.prototype.getModel;function d(h,g){var m=c.call(this,h,g);return m.resolveParentPath=f,m}v.wrapMethod("getItemModel",function(h){return h.resolveParentPath=f,h.getModel=d,h});function f(h){if(h&&(h[0]==="label"||h[1]==="label")){var g=h.slice();return h[0]==="label"?g[0]="edgeLabel":h[1]==="label"&&(g[1]="edgeLabel"),g}return h}}},t.prototype.getGraph=function(){return this.getData().graph},t.prototype.getEdgeData=function(){return this.getGraph().edgeData},t.prototype.getCategoriesData=function(){return this._categoriesData},t.prototype.formatTooltip=function(e,a,i){if(i==="edge"){var n=this.getData(),o=this.getDataParams(e,i),s=n.graph.getEdgeByIndex(e),l=n.getName(s.node1.dataIndex),u=n.getName(s.node2.dataIndex),v=[];return l!=null&&v.push(l),u!=null&&v.push(u),ii("nameValue",{name:v.join(" > "),value:o.value,noValue:o.value==null})}var c=p3({series:this,dataIndex:e,multipleSeries:a});return c},t.prototype._updateCategoriesData=function(){var e=pt(this.option.categories||[],function(i){return i.value!=null?i:lt({value:0},i)}),a=new Oi(["value"],this);a.initData(e),this._categoriesData=a,this._categoriesModels=a.mapArray(function(i){return a.getItemModel(i)})},t.prototype.setZoom=function(e){this.option.zoom=e},t.prototype.setCenter=function(e){this.option.center=e},t.prototype.isAnimationEnabled=function(){return r.prototype.isAnimationEnabled.call(this)&&!(this.get("layout")==="force"&&this.get(["force","layoutAnimation"]))},t.type="series.graph",t.dependencies=["grid","polar","geo","singleAxis","calendar"],t.defaultOption={z:2,coordinateSystem:"view",legendHoverLink:!0,layout:null,circular:{rotateLabel:!1},force:{initLayout:null,repulsion:[0,50],gravity:.1,friction:.6,edgeLength:30,layoutAnimation:!0},left:"center",top:"center",symbol:"circle",symbolSize:10,edgeSymbol:["none","none"],edgeSymbolSize:10,edgeLabel:{position:"middle",distance:5},draggable:!1,roam:!1,center:null,zoom:1,nodeScaleRatio:.6,label:{show:!1,formatter:"{b}"},itemStyle:{},lineStyle:{color:"#aaa",width:1,opacity:.5},emphasis:{scale:!0,label:{show:!0}},select:{itemStyle:{borderColor:"#212121"}}},t}(ma),kQ={type:"graphRoam",event:"graphRoam",update:"none"};function DQ(r){r.registerChartView(TQ),r.registerSeriesModel(CQ),r.registerProcessor(sQ),r.registerVisual(lQ),r.registerVisual(uQ),r.registerLayout(hQ),r.registerLayout(r.PRIORITY.VISUAL.POST_CHART_LAYOUT,gQ),r.registerLayout(mQ),r.registerCoordinateSystem("graphView",{dimensions:tp.dimensions,create:xQ}),r.registerAction({type:"focusNodeAdjacency",event:"focusNodeAdjacency",update:"series:focusNodeAdjacency"},Fa),r.registerAction({type:"unfocusNodeAdjacency",event:"unfocusNodeAdjacency",update:"series:unfocusNodeAdjacency"},Fa),r.registerAction(kQ,function(t,e,a){e.eachComponent({mainType:"series",query:t},function(i){var n=i.coordinateSystem,o=AT(n,t,void 0,a);i.setCenter&&i.setCenter(o.center),i.setZoom&&i.setZoom(o.zoom)})})}var MQ=function(){function r(){this.angle=0,this.width=10,this.r=10,this.x=0,this.y=0}return r}(),LQ=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;return a.type="pointer",a}return t.prototype.getDefaultShape=function(){return new MQ},t.prototype.buildPath=function(e,a){var i=Math.cos,n=Math.sin,o=a.r,s=a.width,l=a.angle,u=a.x-i(l)*s*(s>=o/3?1:2),v=a.y-n(l)*s*(s>=o/3?1:2);l=a.angle-Math.PI/2,e.moveTo(u,v),e.lineTo(a.x+i(l)*s,a.y+n(l)*s),e.lineTo(a.x+i(a.angle)*o,a.y+n(a.angle)*o),e.lineTo(a.x-i(l)*s,a.y-n(l)*s),e.lineTo(u,v)},t}(ur);function IQ(r,t){var e=r.get("center"),a=t.getWidth(),i=t.getHeight(),n=Math.min(a,i),o=Lt(e[0],t.getWidth()),s=Lt(e[1],t.getHeight()),l=Lt(r.get("radius"),n/2);return{cx:o,cy:s,r:l}}function iy(r,t){var e=r==null?"":r+"";return t&&(Pt(t)?e=t.replace("{value}",e):le(t)&&(e=t(r))),e}var RQ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){this.group.removeAll();var n=e.get(["axisLine","lineStyle","color"]),o=IQ(e,i);this._renderMain(e,a,i,n,o),this._data=e.getData()},t.prototype.dispose=function(){},t.prototype._renderMain=function(e,a,i,n,o){var s=this.group,l=e.get("clockwise"),u=-e.get("startAngle")/180*Math.PI,v=-e.get("endAngle")/180*Math.PI,c=e.getModel("axisLine"),d=c.get("roundCap"),f=d?_m:Gi,h=c.get("show"),g=c.getModel("lineStyle"),m=g.get("width"),x=[u,v];kS(x,!l),u=x[0],v=x[1];for(var b=v-u,w=u,S=[],C=0;h&&C=M&&(L===0?0:n[L-1][0])Math.PI/2&&(at+=Math.PI)):Q==="tangential"?at=-D-Math.PI/2:Or(Q)&&(at=Q*Math.PI/180),at===0?c.add(new Rr({style:ya(w,{text:H,x:$,y:Z,verticalAlign:O<-.8?"top":O>.8?"bottom":"middle",align:N<-.4?"left":N>.4?"right":"center"},{inheritColor:Y}),silent:!0})):c.add(new Rr({style:ya(w,{text:H,x:$,y:Z,verticalAlign:"middle",align:"center"},{inheritColor:Y}),silent:!0,originX:$,originY:Z,rotation:at}))}if(b.get("show")&&V!==S){var B=b.get("distance");B=B?B+v:v;for(var et=0;et<=C;et++){N=Math.cos(D),O=Math.sin(D);var _t=new Ja({shape:{x1:N*(h-B)+d,y1:O*(h-B)+f,x2:N*(h-k-B)+d,y2:O*(h-k-B)+f},silent:!0,style:P});P.stroke==="auto"&&_t.setStyle({stroke:n((V+et/C)/S)}),c.add(_t),D+=L}D-=L}else D+=M}},t.prototype._renderPointer=function(e,a,i,n,o,s,l,u,v){var c=this.group,d=this._data,f=this._progressEls,h=[],g=e.get(["pointer","show"]),m=e.getModel("progress"),x=m.get("show"),b=e.getData(),w=b.mapDimension("value"),S=+e.get("min"),C=+e.get("max"),T=[S,C],k=[s,l];function D(L,I){var P=b.getItemModel(L),E=P.getModel("pointer"),N=Lt(E.get("width"),o.r),O=Lt(E.get("length"),o.r),V=e.get(["pointer","icon"]),B=E.get("offsetCenter"),z=Lt(B[0],o.r),H=Lt(B[1],o.r),Y=E.get("keepAspect"),$;return V?$=qa(V,z-N/2,H-O,N,O,null,Y):$=new LQ({shape:{angle:-Math.PI/2,width:N,r:O,x:z,y:H}}),$.rotation=-(I+Math.PI/2),$.x=o.cx,$.y=o.cy,$}function M(L,I){var P=m.get("roundCap"),E=P?_m:Gi,N=m.get("overlap"),O=N?m.get("width"):v/b.count(),V=N?o.r-O:o.r-(L+1)*O,B=N?o.r:o.r-L*O,z=new E({shape:{startAngle:s,endAngle:I,cx:o.cx,cy:o.cy,clockwise:u,r0:V,r:B}});return N&&(z.z2=ra(b.get(w,L),[S,C],[100,0],!0)),z}(x||g)&&(b.diff(d).add(function(L){var I=b.get(w,L);if(g){var P=D(L,s);Ta(P,{rotation:-((isNaN(+I)?k[0]:ra(I,T,k,!0))+Math.PI/2)},e),c.add(P),b.setItemGraphicEl(L,P)}if(x){var E=M(L,s),N=m.get("clip");Ta(E,{shape:{endAngle:ra(I,T,k,N)}},e),c.add(E),kw(e.seriesIndex,b.dataType,L,E),h[L]=E}}).update(function(L,I){var P=b.get(w,L);if(g){var E=d.getItemGraphicEl(I),N=E?E.rotation:s,O=D(L,N);O.rotation=N,Gr(O,{rotation:-((isNaN(+P)?k[0]:ra(P,T,k,!0))+Math.PI/2)},e),c.add(O),b.setItemGraphicEl(L,O)}if(x){var V=f[I],B=V?V.shape.endAngle:s,z=M(L,B),H=m.get("clip");Gr(z,{shape:{endAngle:ra(P,T,k,H)}},e),c.add(z),kw(e.seriesIndex,b.dataType,L,z),h[L]=z}}).execute(),b.each(function(L){var I=b.getItemModel(L),P=I.getModel("emphasis"),E=P.get("focus"),N=P.get("blurScope"),O=P.get("disabled");if(g){var V=b.getItemGraphicEl(L),B=b.getItemVisual(L,"style"),z=B.fill;if(V instanceof ui){var H=V.style;V.useStyle(lt({image:H.image,x:H.x,y:H.y,width:H.width,height:H.height},B))}else V.useStyle(B),V.type!=="pointer"&&V.setColor(z);V.setStyle(I.getModel(["pointer","itemStyle"]).getItemStyle()),V.style.fill==="auto"&&V.setStyle("fill",n(ra(b.get(w,L),T,[0,1],!0))),V.z2EmphasisLift=0,yi(V,I),Ia(V,E,N,O)}if(x){var Y=h[L];Y.useStyle(b.getItemVisual(L,"style")),Y.setStyle(I.getModel(["progress","itemStyle"]).getItemStyle()),Y.z2EmphasisLift=0,yi(Y,I),Ia(Y,E,N,O)}}),this._progressEls=h)},t.prototype._renderAnchor=function(e,a){var i=e.getModel("anchor"),n=i.get("show");if(n){var o=i.get("size"),s=i.get("icon"),l=i.get("offsetCenter"),u=i.get("keepAspect"),v=qa(s,a.cx-o/2+Lt(l[0],a.r),a.cy-o/2+Lt(l[1],a.r),o,o,null,u);v.z2=i.get("showAbove")?1:0,v.setStyle(i.getModel("itemStyle").getItemStyle()),this.group.add(v)}},t.prototype._renderTitleAndDetail=function(e,a,i,n,o){var s=this,l=e.getData(),u=l.mapDimension("value"),v=+e.get("min"),c=+e.get("max"),d=new Ae,f=[],h=[],g=e.isAnimationEnabled(),m=e.get(["pointer","showAbove"]);l.diff(this._data).add(function(x){f[x]=new Rr({silent:!0}),h[x]=new Rr({silent:!0})}).update(function(x,b){f[x]=s._titleEls[b],h[x]=s._detailEls[b]}).execute(),l.each(function(x){var b=l.getItemModel(x),w=l.get(u,x),S=new Ae,C=n(ra(w,[v,c],[0,1],!0)),T=b.getModel("title");if(T.get("show")){var k=T.get("offsetCenter"),D=o.cx+Lt(k[0],o.r),M=o.cy+Lt(k[1],o.r),L=f[x];L.attr({z2:m?0:2,style:ya(T,{x:D,y:M,text:l.getName(x),align:"center",verticalAlign:"middle"},{inheritColor:C})}),S.add(L)}var I=b.getModel("detail");if(I.get("show")){var P=I.get("offsetCenter"),E=o.cx+Lt(P[0],o.r),N=o.cy+Lt(P[1],o.r),O=Lt(I.get("width"),o.r),V=Lt(I.get("height"),o.r),B=e.get(["progress","show"])?l.getItemVisual(x,"style").fill:C,L=h[x],z=I.get("formatter");L.attr({z2:m?0:2,style:ya(I,{x:E,y:N,text:iy(w,z),width:isNaN(O)?null:O,height:isNaN(V)?null:V,align:"center",verticalAlign:"middle"},{inheritColor:B})}),_5(L,{normal:I},w,function(Y){return iy(Y,z)}),g&&x5(L,x,l,e,{getFormattedLabel:function(Y,$,Z,Q,at,et){return iy(et?et.interpolatedValue:w,z)}}),S.add(L)}d.add(S)}),this.group.add(d),this._titleEls=f,this._detailEls=h},t.type="gauge",t}(ca),PQ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.visualStyleAccessPath="itemStyle",e}return t.prototype.getInitialData=function(e,a){return yd(this,["value"])},t.type="series.gauge",t.defaultOption={z:2,colorBy:"data",center:["50%","50%"],legendHoverLink:!0,radius:"75%",startAngle:225,endAngle:-45,clockwise:!0,min:0,max:100,splitNumber:10,axisLine:{show:!0,roundCap:!1,lineStyle:{color:[[1,"#E6EBF8"]],width:10}},progress:{show:!1,overlap:!0,width:10,roundCap:!1,clip:!0},splitLine:{show:!0,length:10,distance:10,lineStyle:{color:"#63677A",width:3,type:"solid"}},axisTick:{show:!0,splitNumber:5,length:6,distance:10,lineStyle:{color:"#63677A",width:1,type:"solid"}},axisLabel:{show:!0,distance:15,color:"#464646",fontSize:12,rotate:0},pointer:{icon:null,offsetCenter:[0,0],show:!0,showAbove:!0,length:"60%",width:6,keepAspect:!1},anchor:{show:!1,showAbove:!1,size:6,icon:"circle",offsetCenter:[0,0],keepAspect:!1,itemStyle:{color:"#fff",borderWidth:0,borderColor:"#5470c6"}},title:{show:!0,offsetCenter:[0,"20%"],color:"#464646",fontSize:16,valueAnimation:!1},detail:{show:!0,backgroundColor:"rgba(0,0,0,0)",borderWidth:0,borderColor:"#ccc",width:100,height:null,padding:[5,10],offsetCenter:[0,"40%"],color:"#464646",fontSize:30,fontWeight:"bold",lineHeight:30,valueAnimation:!1}},t}(ma);function EQ(r){r.registerChartView(RQ),r.registerSeriesModel(PQ)}var NQ=["itemStyle","opacity"],OQ=function(r){tt(t,r);function t(e,a){var i=r.call(this)||this,n=i,o=new Ui,s=new Rr;return n.setTextContent(s),i.setTextGuideLine(o),i.updateData(e,a,!0),i}return t.prototype.updateData=function(e,a,i){var n=this,o=e.hostModel,s=e.getItemModel(a),l=e.getItemLayout(a),u=s.getModel("emphasis"),v=s.get(NQ);v=v??1,i||Co(n),n.useStyle(e.getItemVisual(a,"style")),n.style.lineJoin="round",i?(n.setShape({points:l.points}),n.style.opacity=0,Ta(n,{style:{opacity:v}},o,a)):Gr(n,{style:{opacity:v},shape:{points:l.points}},o,a),yi(n,s),this._updateLabel(e,a),Ia(this,u.get("focus"),u.get("blurScope"),u.get("disabled"))},t.prototype._updateLabel=function(e,a){var i=this,n=this.getTextGuideLine(),o=i.getTextContent(),s=e.hostModel,l=e.getItemModel(a),u=e.getItemLayout(a),v=u.label,c=e.getItemVisual(a,"style"),d=c.fill;mi(o,ai(l),{labelFetcher:e.hostModel,labelDataIndex:a,defaultOpacity:c.opacity,defaultText:e.getName(a)},{normal:{align:v.textAlign,verticalAlign:v.verticalAlign}}),i.setTextConfig({local:!0,inside:!!v.inside,insideStroke:d,outsideFill:d});var f=v.linePoints;n.setShape({points:f}),i.textGuideLineConfig={anchor:f?new Je(f[0][0],f[0][1]):null},Gr(o,{style:{x:v.x,y:v.y}},s,a),o.attr({rotation:v.rotation,originX:v.x,originY:v.y,z2:10}),cT(i,dT(l),{stroke:d})},t}(Hi),zQ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.ignoreLabelLineUpdate=!0,e}return t.prototype.render=function(e,a,i){var n=e.getData(),o=this._data,s=this.group;n.diff(o).add(function(l){var u=new OQ(n,l);n.setItemGraphicEl(l,u),s.add(u)}).update(function(l,u){var v=o.getItemGraphicEl(u);v.updateData(n,l),s.add(v),n.setItemGraphicEl(l,v)}).remove(function(l){var u=o.getItemGraphicEl(l);uh(u,e,l)}).execute(),this._data=n},t.prototype.remove=function(){this.group.removeAll(),this._data=null},t.prototype.dispose=function(){},t.type="funnel",t}(ca),BQ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.init=function(e){r.prototype.init.apply(this,arguments),this.legendVisualProvider=new Qh(Nt(this.getData,this),Nt(this.getRawData,this)),this._defaultLabelLine(e)},t.prototype.getInitialData=function(e,a){return yd(this,{coordDimensions:["value"],encodeDefaulter:We(US,this)})},t.prototype._defaultLabelLine=function(e){pv(e,"labelLine",["show"]);var a=e.labelLine,i=e.emphasis.labelLine;a.show=a.show&&e.label.show,i.show=i.show&&e.emphasis.label.show},t.prototype.getDataParams=function(e){var a=this.getData(),i=r.prototype.getDataParams.call(this,e),n=a.mapDimension("value"),o=a.getSum(n);return i.percent=o?+(a.get(n,e)/o*100).toFixed(2):0,i.$vars.push("percent"),i},t.type="series.funnel",t.defaultOption={z:2,legendHoverLink:!0,colorBy:"data",left:80,top:60,right:80,bottom:60,minSize:"0%",maxSize:"100%",sort:"descending",orient:"vertical",gap:0,funnelAlign:"center",label:{show:!0,position:"outer"},labelLine:{show:!0,length:20,lineStyle:{width:1}},itemStyle:{borderColor:"#fff",borderWidth:1},emphasis:{label:{show:!0}},select:{itemStyle:{borderColor:"#212121"}}},t}(ma);function VQ(r,t){return Xa(r.getBoxLayoutParams(),{width:t.getWidth(),height:t.getHeight()})}function FQ(r,t){for(var e=r.mapDimension("value"),a=r.mapArray(e,function(l){return l}),i=[],n=t==="ascending",o=0,s=r.count();orJ)return;var i=this._model.coordinateSystem.getSlidedAxisExpandWindow([r.offsetX,r.offsetY]);i.behavior!=="none"&&this._dispatchExpand({axisExpandWindow:i.axisExpandWindow})}this._mouseDownPoint=null},mousemove:function(r){if(!(this._mouseDownPoint||!ib(this,"mousemove"))){var t=this._model,e=t.coordinateSystem.getSlidedAxisExpandWindow([r.offsetX,r.offsetY]),a=e.behavior;a==="jump"&&this._throttledDispatchExpand.debounceNextCall(t.get("axisExpandDebounce")),this._throttledDispatchExpand(a==="none"?null:{axisExpandWindow:e.axisExpandWindow,animation:a==="jump"?null:{duration:0}})}}};function ib(r,t){var e=r._model;return e.get("axisExpandable")&&e.get("axisExpandTriggerOn")===t}var nJ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.init=function(){r.prototype.init.apply(this,arguments),this.mergeOption({})},t.prototype.mergeOption=function(e){var a=this.option;e&&Ze(a,e,!0),this._initDimensions()},t.prototype.contains=function(e,a){var i=e.get("parallelIndex");return i!=null&&a.getComponent("parallel",i)===this},t.prototype.setAxisExpand=function(e){R(["axisExpandable","axisExpandCenter","axisExpandCount","axisExpandWidth","axisExpandWindow"],function(a){e.hasOwnProperty(a)&&(this.option[a]=e[a])},this)},t.prototype._initDimensions=function(){var e=this.dimensions=[],a=this.parallelAxisIndex=[],i=ta(this.ecModel.queryComponents({mainType:"parallelAxis"}),function(n){return(n.get("parallelIndex")||0)===this.componentIndex},this);R(i,function(n){e.push("dim"+n.get("dim")),a.push(n.componentIndex)})},t.type="parallel",t.dependencies=["parallelAxis"],t.layoutMode="box",t.defaultOption={z:0,left:80,top:60,right:80,bottom:60,layout:"horizontal",axisExpandable:!1,axisExpandCenter:null,axisExpandCount:0,axisExpandWidth:50,axisExpandRate:17,axisExpandDebounce:50,axisExpandSlideTriggerArea:[-.15,.05,.4],axisExpandTriggerOn:"click",parallelAxisDefault:null},t}(_r),oJ=function(r){tt(t,r);function t(e,a,i,n,o){var s=r.call(this,e,a,i)||this;return s.type=n||"value",s.axisIndex=o,s}return t.prototype.isHorizontal=function(){return this.coordinateSystem.getModel().get("layout")!=="horizontal"},t}(Do);function Pv(r,t,e,a,i,n){r=r||0;var o=e[1]-e[0];if(i!=null&&(i=ic(i,[0,o])),n!=null&&(n=Math.max(n,i??0)),a==="all"){var s=Math.abs(t[1]-t[0]);s=ic(s,[0,o]),i=n=ic(s,[i,n]),a=0}t[0]=ic(t[0],e),t[1]=ic(t[1],e);var l=nb(t,a);t[a]+=r;var u=i||0,v=e.slice();l.sign<0?v[0]+=u:v[1]-=u,t[a]=ic(t[a],v);var c;return c=nb(t,a),i!=null&&(c.sign!==l.sign||c.spann&&(t[1-a]=t[a]+c.sign*n),t}function nb(r,t){var e=r[t]-r[1-t];return{span:Math.abs(e),sign:e>0?-1:e<0?1:t?-1:1}}function ic(r,t){return Math.min(t[1]!=null?t[1]:1/0,Math.max(t[0]!=null?t[0]:-1/0,r))}var ob=R,YO=Math.min,ZO=Math.max,JL=Math.floor,sJ=Math.ceil,tI=Ea,lJ=Math.PI,uJ=function(){function r(t,e,a){this.type="parallel",this._axesMap=Wt(),this._axesLayout={},this.dimensions=t.dimensions,this._model=t,this._init(t,e,a)}return r.prototype._init=function(t,e,a){var i=t.dimensions,n=t.parallelAxisIndex;ob(i,function(o,s){var l=n[s],u=e.getComponent("parallelAxis",l),v=this._axesMap.set(o,new oJ(o,I_(u),[0,0],u.get("type"),l)),c=v.type==="category";v.onBand=c&&u.get("boundaryGap"),v.inverse=u.get("inverse"),u.axis=v,v.model=u,v.coordinateSystem=u.coordinateSystem=this},this)},r.prototype.update=function(t,e){this._updateAxesFromSeries(this._model,t)},r.prototype.containPoint=function(t){var e=this._makeLayoutInfo(),a=e.axisBase,i=e.layoutBase,n=e.pixelDimIndex,o=t[1-n],s=t[n];return o>=a&&o<=a+e.axisLength&&s>=i&&s<=i+e.layoutLength},r.prototype.getModel=function(){return this._model},r.prototype._updateAxesFromSeries=function(t,e){e.eachSeries(function(a){if(t.contains(a,e)){var i=a.getData();ob(this.dimensions,function(n){var o=this._axesMap.get(n);o.scale.unionExtentFromData(i,i.mapDimension(n)),Xc(o.scale,o.model)},this)}},this)},r.prototype.resize=function(t,e){this._rect=Xa(t.getBoxLayoutParams(),{width:e.getWidth(),height:e.getHeight()}),this._layoutAxes()},r.prototype.getRect=function(){return this._rect},r.prototype._makeLayoutInfo=function(){var t=this._model,e=this._rect,a=["x","y"],i=["width","height"],n=t.get("layout"),o=n==="horizontal"?0:1,s=e[i[o]],l=[0,s],u=this.dimensions.length,v=ny(t.get("axisExpandWidth"),l),c=ny(t.get("axisExpandCount")||0,[0,u]),d=t.get("axisExpandable")&&u>3&&u>c&&c>1&&v>0&&s>0,f=t.get("axisExpandWindow"),h;if(f)h=ny(f[1]-f[0],l),f[1]=f[0]+h;else{h=ny(v*(c-1),l);var g=t.get("axisExpandCenter")||JL(u/2);f=[v*g-h/2],f[1]=f[0]+h}var m=(s-h)/(u-c);m<3&&(m=0);var x=[JL(tI(f[0]/v,1))+1,sJ(tI(f[1]/v,1))-1],b=m/v*f[0];return{layout:n,pixelDimIndex:o,layoutBase:e[a[o]],layoutLength:s,axisBase:e[a[1-o]],axisLength:e[i[1-o]],axisExpandable:d,axisExpandWidth:v,axisCollapseWidth:m,axisExpandWindow:f,axisCount:u,winInnerIndices:x,axisExpandWindow0Pos:b}},r.prototype._layoutAxes=function(){var t=this._rect,e=this._axesMap,a=this.dimensions,i=this._makeLayoutInfo(),n=i.layout;e.each(function(o){var s=[0,i.axisLength],l=o.inverse?1:0;o.setExtent(s[l],s[1-l])}),ob(a,function(o,s){var l=(i.axisExpandable?cJ:vJ)(s,i),u={horizontal:{x:l.position,y:i.axisLength},vertical:{x:0,y:l.position}},v={horizontal:lJ/2,vertical:0},c=[u[n].x+t.x,u[n].y+t.y],d=v[n],f=pn();Cv(f,f,d),ss(f,f,c),this._axesLayout[o]={position:c,rotation:d,transform:f,axisNameAvailableWidth:l.axisNameAvailableWidth,axisLabelShow:l.axisLabelShow,nameTruncateMaxWidth:l.nameTruncateMaxWidth,tickDirection:1,labelDirection:1}},this)},r.prototype.getAxis=function(t){return this._axesMap.get(t)},r.prototype.dataToPoint=function(t,e){return this.axisCoordToPoint(this._axesMap.get(e).dataToCoord(t),e)},r.prototype.eachActiveState=function(t,e,a,i){a==null&&(a=0),i==null&&(i=t.count());var n=this._axesMap,o=this.dimensions,s=[],l=[];R(o,function(m){s.push(t.mapDimension(m)),l.push(n.get(m).model)});for(var u=this.hasAxisBrushed(),v=a;vn*(1-c[0])?(u="jump",l=s-n*(1-c[2])):(l=s-n*c[1])>=0&&(l=s-n*(1-c[1]))<=0&&(l=0),l*=e.axisExpandWidth/v,l?Pv(l,i,o,"all"):u="none";else{var f=i[1]-i[0],h=o[1]*s/f;i=[ZO(0,h-f/2)],i[1]=YO(o[1],i[0]+f),i[0]=i[1]-f}return{axisExpandWindow:i,behavior:u}},r}();function ny(r,t){return YO(ZO(r,t[0]),t[1])}function vJ(r,t){var e=t.layoutLength/(t.axisCount-1);return{position:e*r,axisNameAvailableWidth:e,axisLabelShow:!0}}function cJ(r,t){var e=t.layoutLength,a=t.axisExpandWidth,i=t.axisCount,n=t.axisCollapseWidth,o=t.winInnerIndices,s,l=n,u=!1,v;return r=0;i--)Un(a[i])},t.prototype.getActiveState=function(e){var a=this.activeIntervals;if(!a.length)return"normal";if(e==null||isNaN(+e))return"inactive";if(a.length===1){var i=a[0];if(i[0]<=e&&e<=i[1])return"active"}else for(var n=0,o=a.length;ngJ}function tz(r){var t=r.length-1;return t<0&&(t=0),[r[0],r[t]]}function ez(r,t,e,a){var i=new Ae;return i.add(new Mr({name:"main",style:VT(e),silent:!0,draggable:!0,cursor:"move",drift:We(aI,r,t,i,["n","s","w","e"]),ondragend:We(bv,t,{isEnd:!0})})),R(a,function(n){i.add(new Mr({name:n.join(""),style:{opacity:0},draggable:!0,silent:!0,invisible:!0,drift:We(aI,r,t,i,n),ondragend:We(bv,t,{isEnd:!0})}))}),i}function rz(r,t,e,a){var i=a.brushStyle.lineWidth||0,n=Qc(i,yJ),o=e[0][0],s=e[1][0],l=o-i/2,u=s-i/2,v=e[0][1],c=e[1][1],d=v-n+i/2,f=c-n+i/2,h=v-o,g=c-s,m=h+i,x=g+i;xs(r,t,"main",o,s,h,g),a.transformable&&(xs(r,t,"w",l,u,n,x),xs(r,t,"e",d,u,n,x),xs(r,t,"n",l,u,m,n),xs(r,t,"s",l,f,m,n),xs(r,t,"nw",l,u,n,n),xs(r,t,"ne",d,u,n,n),xs(r,t,"sw",l,f,n,n),xs(r,t,"se",d,f,n,n))}function _2(r,t){var e=t.__brushOption,a=e.transformable,i=t.childAt(0);i.useStyle(VT(e)),i.attr({silent:!a,cursor:a?"move":"default"}),R([["w"],["e"],["n"],["s"],["s","e"],["s","w"],["n","e"],["n","w"]],function(n){var o=t.childOfName(n.join("")),s=n.length===1?x2(r,n[0]):SJ(r,n);o&&o.attr({silent:!a,invisible:!a,cursor:a?_J[s]+"-resize":null})})}function xs(r,t,e,a,i,n,o){var s=t.childOfName(e);s&&s.setShape(AJ(FT(r,t,[[a,i],[a+n,i+o]])))}function VT(r){return ve({strokeNoScale:!0},r.brushStyle)}function az(r,t,e,a){var i=[Ah(r,e),Ah(t,a)],n=[Qc(r,e),Qc(t,a)];return[[i[0],n[0]],[i[1],n[1]]]}function wJ(r){return ov(r.group)}function x2(r,t){var e={w:"left",e:"right",n:"top",s:"bottom"},a={left:"w",right:"e",top:"n",bottom:"s"},i=p_(e[t],wJ(r));return a[i]}function SJ(r,t){var e=[x2(r,t[0]),x2(r,t[1])];return(e[0]==="e"||e[0]==="w")&&e.reverse(),e.join("")}function aI(r,t,e,a,i,n){var o=e.__brushOption,s=r.toRectRange(o.range),l=iz(t,i,n);R(a,function(u){var v=mJ[u];s[v[0]][v[1]]+=l[v[0]]}),o.range=r.fromRectRange(az(s[0][0],s[1][0],s[0][1],s[1][1])),OT(t,e),bv(t,{isEnd:!1})}function TJ(r,t,e,a){var i=t.__brushOption.range,n=iz(r,e,a);R(i,function(o){o[0]+=n[0],o[1]+=n[1]}),OT(r,t),bv(r,{isEnd:!1})}function iz(r,t,e){var a=r.group,i=a.transformCoordToLocal(t,e),n=a.transformCoordToLocal(0,0);return[i[0]-n[0],i[1]-n[1]]}function FT(r,t,e){var a=JO(r,t);return a&&a!==xv?a.clipPath(e,r._transform):be(e)}function AJ(r){var t=Ah(r[0][0],r[1][0]),e=Ah(r[0][1],r[1][1]),a=Qc(r[0][0],r[1][0]),i=Qc(r[0][1],r[1][1]);return{x:t,y:e,width:a-t,height:i-e}}function CJ(r,t,e){if(!(!r._brushType||DJ(r,t.offsetX,t.offsetY))){var a=r._zr,i=r._covers,n=BT(r,t,e);if(!r._dragging)for(var o=0;oa.getWidth()||e<0||e>a.getHeight()}var z_={lineX:oI(0),lineY:oI(1),rect:{createCover:function(r,t){function e(a){return a}return ez({toRectRange:e,fromRectRange:e},r,t,[["w"],["e"],["n"],["s"],["s","e"],["s","w"],["n","e"],["n","w"]])},getCreatingRange:function(r){var t=tz(r);return az(t[1][0],t[1][1],t[0][0],t[0][1])},updateCoverShape:function(r,t,e,a){rz(r,t,e,a)},updateCommon:_2,contain:w2},polygon:{createCover:function(r,t){var e=new Ae;return e.add(new Ui({name:"main",style:VT(t),silent:!0})),e},getCreatingRange:function(r){return r},endCreating:function(r,t){t.remove(t.childAt(0)),t.add(new Hi({name:"main",draggable:!0,drift:We(TJ,r,t),ondragend:We(bv,r,{isEnd:!0})}))},updateCoverShape:function(r,t,e,a){t.childAt(0).setShape({points:FT(r,t,e)})},updateCommon:_2,contain:w2}};function oI(r){return{createCover:function(t,e){return ez({toRectRange:function(a){var i=[a,[0,100]];return r&&i.reverse(),i},fromRectRange:function(a){return a[r]}},t,e,[[["w"],["e"]],[["n"],["s"]]][r])},getCreatingRange:function(t){var e=tz(t),a=Ah(e[0][r],e[1][r]),i=Qc(e[0][r],e[1][r]);return[a,i]},updateCoverShape:function(t,e,a,i){var n,o=JO(t,e);if(o!==xv&&o.getLinearBrushOtherExtent)n=o.getLinearBrushOtherExtent(r);else{var s=t._zr;n=[0,[s.getWidth(),s.getHeight()][1-r]]}var l=[a,n];r&&l.reverse(),rz(t,e,l,i)},updateCommon:_2,contain:w2}}function oz(r){return r=GT(r),function(t){return g5(t,r)}}function sz(r,t){return r=GT(r),function(e){var a=t??e,i=a?r.width:r.height,n=a?r.x:r.y;return[n,n+(i||0)]}}function lz(r,t,e){var a=GT(r);return function(i,n){return a.contain(n[0],n[1])&&!P_(i,t,e)}}function GT(r){return er.create(r)}var MJ=["axisLine","axisTickLabel","axisName"],LJ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.init=function(e,a){r.prototype.init.apply(this,arguments),(this._brushController=new NT(a.getZr())).on("brush",Nt(this._onBrush,this))},t.prototype.render=function(e,a,i,n){if(!IJ(e,a,n)){this.axisModel=e,this.api=i,this.group.removeAll();var o=this._axisGroup;if(this._axisGroup=new Ae,this.group.add(this._axisGroup),!!e.get("show")){var s=PJ(e,a),l=s.coordinateSystem,u=e.getAreaSelectStyle(),v=u.width,c=e.axis.dim,d=l.getAxisLayout(c),f=lt({strokeContainThreshold:v},d),h=new zi(e,f);R(MJ,h.add,h),this._axisGroup.add(h.getGroup()),this._refreshBrushController(f,u,e,s,v,i),Gh(o,this._axisGroup,e)}}},t.prototype._refreshBrushController=function(e,a,i,n,o,s){var l=i.axis.getExtent(),u=l[1]-l[0],v=Math.min(30,Math.abs(u)*.1),c=er.create({x:l[0],y:-o/2,width:u,height:o});c.x-=v,c.width+=2*v,this._brushController.mount({enableGlobalPan:!0,rotation:e.rotation,x:e.position[0],y:e.position[1]}).setPanels([{panelId:"pl",clipPath:oz(c),isTargetByCursor:lz(c,s,n),getLinearBrushOtherExtent:sz(c,0)}]).enableBrush({brushType:"lineX",brushStyle:a,removeOnClick:!0}).updateCovers(RJ(i))},t.prototype._onBrush=function(e){var a=e.areas,i=this.axisModel,n=i.axis,o=pt(a,function(s){return[n.coordToData(s.range[0],!0),n.coordToData(s.range[1],!0)]});(!i.option.realtime===e.isEnd||e.removeOnClick)&&this.api.dispatchAction({type:"axisAreaSelect",parallelAxisId:i.id,intervals:o})},t.prototype.dispose=function(){this._brushController.dispose()},t.type="parallelAxis",t}(Ma);function IJ(r,t,e){return e&&e.type==="axisAreaSelect"&&t.findComponents({mainType:"parallelAxis",query:e})[0]===r}function RJ(r){var t=r.axis;return pt(r.activeIntervals,function(e){return{brushType:"lineX",panelId:"pl",range:[t.dataToCoord(e[0],!0),t.dataToCoord(e[1],!0)]}})}function PJ(r,t){return t.getComponent("parallel",r.get("parallelIndex"))}var EJ={type:"axisAreaSelect",event:"axisAreaSelected"};function NJ(r){r.registerAction(EJ,function(t,e){e.eachComponent({mainType:"parallelAxis",query:t},function(a){a.axis.model.setActiveIntervals(t.intervals)})}),r.registerAction("parallelAxisExpand",function(t,e){e.eachComponent({mainType:"parallel",query:t},function(a){a.setAxisExpand(t)})})}var OJ={type:"value",areaSelectStyle:{width:20,borderWidth:1,borderColor:"rgba(160,197,232)",color:"rgba(160,197,232)",opacity:.3},realtime:!0,z:10};function uz(r){r.registerComponentView(aJ),r.registerComponentModel(nJ),r.registerCoordinateSystem("parallel",fJ),r.registerPreprocessor(JQ),r.registerComponentModel(y2),r.registerComponentView(LJ),Kc(r,"parallel",y2,OJ),NJ(r)}function zJ(r){sr(uz),r.registerChartView(qQ),r.registerSeriesModel(ZQ),r.registerVisual(r.PRIORITY.VISUAL.BRUSH,QQ)}var BJ=function(){function r(){this.x1=0,this.y1=0,this.x2=0,this.y2=0,this.cpx1=0,this.cpy1=0,this.cpx2=0,this.cpy2=0,this.extent=0}return r}(),VJ=function(r){tt(t,r);function t(e){return r.call(this,e)||this}return t.prototype.getDefaultShape=function(){return new BJ},t.prototype.buildPath=function(e,a){var i=a.extent;e.moveTo(a.x1,a.y1),e.bezierCurveTo(a.cpx1,a.cpy1,a.cpx2,a.cpy2,a.x2,a.y2),a.orient==="vertical"?(e.lineTo(a.x2+i,a.y2),e.bezierCurveTo(a.cpx2+i,a.cpy2,a.cpx1+i,a.cpy1,a.x1+i,a.y1)):(e.lineTo(a.x2,a.y2+i),e.bezierCurveTo(a.cpx2,a.cpy2+i,a.cpx1,a.cpy1+i,a.x1,a.y1+i)),e.closePath()},t.prototype.highlight=function(){Bs(this)},t.prototype.downplay=function(){Vs(this)},t}(ur),FJ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e._focusAdjacencyDisabled=!1,e}return t.prototype.render=function(e,a,i){var n=this,o=e.getGraph(),s=this.group,l=e.layoutInfo,u=l.width,v=l.height,c=e.getData(),d=e.getData("edge"),f=e.get("orient");this._model=e,s.removeAll(),s.x=l.x,s.y=l.y,o.eachEdge(function(h){var g=new VJ,m=Ie(g);m.dataIndex=h.dataIndex,m.seriesIndex=e.seriesIndex,m.dataType="edge";var x=h.getModel(),b=x.getModel("lineStyle"),w=b.get("curveness"),S=h.node1.getLayout(),C=h.node1.getModel(),T=C.get("localX"),k=C.get("localY"),D=h.node2.getLayout(),M=h.node2.getModel(),L=M.get("localX"),I=M.get("localY"),P=h.getLayout(),E,N,O,V,B,z,H,Y;g.shape.extent=Math.max(1,P.dy),g.shape.orient=f,f==="vertical"?(E=(T!=null?T*u:S.x)+P.sy,N=(k!=null?k*v:S.y)+S.dy,O=(L!=null?L*u:D.x)+P.ty,V=I!=null?I*v:D.y,B=E,z=N*(1-w)+V*w,H=O,Y=N*w+V*(1-w)):(E=(T!=null?T*u:S.x)+S.dx,N=(k!=null?k*v:S.y)+P.sy,O=L!=null?L*u:D.x,V=(I!=null?I*v:D.y)+P.ty,B=E*(1-w)+O*w,z=N,H=E*w+O*(1-w),Y=V),g.setShape({x1:E,y1:N,x2:O,y2:V,cpx1:B,cpy1:z,cpx2:H,cpy2:Y}),g.useStyle(b.getItemStyle()),sI(g.style,f,h);var $=""+x.get("value"),Z=ai(x,"edgeLabel");mi(g,Z,{labelFetcher:{getFormattedLabel:function(et,_t,Ot,xt,mt,wt){return e.getFormattedLabel(et,_t,"edge",xt,as(mt,Z.normal&&Z.normal.get("formatter"),$),wt)}},labelDataIndex:h.dataIndex,defaultText:$}),g.setTextConfig({position:"inside"});var Q=x.getModel("emphasis");yi(g,x,"lineStyle",function(et){var _t=et.getItemStyle();return sI(_t,f,h),_t}),s.add(g),d.setItemGraphicEl(h.dataIndex,g);var at=Q.get("focus");Ia(g,at==="adjacency"?h.getAdjacentDataIndices():at==="trajectory"?h.getTrajectoryDataIndices():at,Q.get("blurScope"),Q.get("disabled"))}),o.eachNode(function(h){var g=h.getLayout(),m=h.getModel(),x=m.get("localX"),b=m.get("localY"),w=m.getModel("emphasis"),S=m.get(["itemStyle","borderRadius"])||0,C=new Mr({shape:{x:x!=null?x*u:g.x,y:b!=null?b*v:g.y,width:g.dx,height:g.dy,r:S},style:m.getModel("itemStyle").getItemStyle(),z2:10});mi(C,ai(m),{labelFetcher:{getFormattedLabel:function(k,D){return e.getFormattedLabel(k,D,"node")}},labelDataIndex:h.dataIndex,defaultText:h.id}),C.disableLabelAnimation=!0,C.setStyle("fill",h.getVisual("color")),C.setStyle("decal",h.getVisual("style").decal),yi(C,m),s.add(C),c.setItemGraphicEl(h.dataIndex,C),Ie(C).dataType="node";var T=w.get("focus");Ia(C,T==="adjacency"?h.getAdjacentDataIndices():T==="trajectory"?h.getTrajectoryDataIndices():T,w.get("blurScope"),w.get("disabled"))}),c.eachItemGraphicEl(function(h,g){var m=c.getItemModel(g);m.get("draggable")&&(h.drift=function(x,b){n._focusAdjacencyDisabled=!0,this.shape.x+=x,this.shape.y+=b,this.dirty(),i.dispatchAction({type:"dragNode",seriesId:e.id,dataIndex:c.getRawIndex(g),localX:this.shape.x/u,localY:this.shape.y/v})},h.ondragend=function(){n._focusAdjacencyDisabled=!1},h.draggable=!0,h.cursor="move")}),!this._data&&e.isAnimationEnabled()&&s.setClipPath(GJ(s.getBoundingRect(),e,function(){s.removeClipPath()})),this._data=e.getData()},t.prototype.dispose=function(){},t.type="sankey",t}(ca);function sI(r,t,e){switch(r.fill){case"source":r.fill=e.node1.getVisual("color"),r.decal=e.node1.getVisual("style").decal;break;case"target":r.fill=e.node2.getVisual("color"),r.decal=e.node2.getVisual("style").decal;break;case"gradient":var a=e.node1.getVisual("color"),i=e.node2.getVisual("color");Pt(a)&&Pt(i)&&(r.fill=new Fh(0,0,+(t==="horizontal"),+(t==="vertical"),[{color:a,offset:0},{color:i,offset:1}]))}}function GJ(r,t,e){var a=new Mr({shape:{x:r.x-10,y:r.y-10,width:0,height:r.height+20}});return Ta(a,{shape:{width:r.width+20}},t,e),a}var HJ=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.getInitialData=function(e,a){var i=e.edges||e.links||[],n=e.data||e.nodes||[],o=e.levels||[];this.levelModels=[];for(var s=this.levelModels,l=0;l=0&&(s[o[l].depth]=new ia(o[l],this,a));var u=qO(n,i,this,!0,v);return u.data;function v(c,d){c.wrapMethod("getItemModel",function(f,h){var g=f.parentModel,m=g.getData().getItemLayout(h);if(m){var x=m.depth,b=g.levelModels[x];b&&(f.parentModel=b)}return f}),d.wrapMethod("getItemModel",function(f,h){var g=f.parentModel,m=g.getGraph().getEdgeByIndex(h),x=m.node1.getLayout();if(x){var b=x.depth,w=g.levelModels[b];w&&(f.parentModel=w)}return f})}},t.prototype.setNodePosition=function(e,a){var i=this.option.data||this.option.nodes,n=i[e];n.localX=a[0],n.localY=a[1]},t.prototype.getGraph=function(){return this.getData().graph},t.prototype.getEdgeData=function(){return this.getGraph().edgeData},t.prototype.formatTooltip=function(e,a,i){function n(f){return isNaN(f)||f==null}if(i==="edge"){var o=this.getDataParams(e,i),s=o.data,l=o.value,u=s.source+" -- "+s.target;return ii("nameValue",{name:u,value:l,noValue:n(l)})}else{var v=this.getGraph().getNodeByIndex(e),c=v.getLayout().value,d=this.getDataParams(e,i).data.name;return ii("nameValue",{name:d!=null?d+"":null,value:c,noValue:n(c)})}},t.prototype.optionUpdated=function(){},t.prototype.getDataParams=function(e,a){var i=r.prototype.getDataParams.call(this,e,a);if(i.value==null&&a==="node"){var n=this.getGraph().getNodeByIndex(e),o=n.getLayout().value;i.value=o}return i},t.type="series.sankey",t.defaultOption={z:2,coordinateSystem:"view",left:"5%",top:"5%",right:"20%",bottom:"5%",orient:"horizontal",nodeWidth:20,nodeGap:8,draggable:!0,layoutIterations:32,label:{show:!0,position:"right",fontSize:12},edgeLabel:{show:!1,fontSize:12},levels:[],nodeAlign:"justify",lineStyle:{color:"#314656",opacity:.2,curveness:.5},emphasis:{label:{show:!0},lineStyle:{opacity:.5}},select:{itemStyle:{borderColor:"#212121"}},animationEasing:"linear",animationDuration:1e3},t}(ma);function WJ(r,t){r.eachSeriesByType("sankey",function(e){var a=e.get("nodeWidth"),i=e.get("nodeGap"),n=UJ(e,t);e.layoutInfo=n;var o=n.width,s=n.height,l=e.getGraph(),u=l.nodes,v=l.edges;$J(u);var c=ta(u,function(g){return g.getLayout().value===0}),d=c.length!==0?0:e.get("layoutIterations"),f=e.get("orient"),h=e.get("nodeAlign");qJ(u,v,a,i,o,s,d,f,h)})}function UJ(r,t){return Xa(r.getBoxLayoutParams(),{width:t.getWidth(),height:t.getHeight()})}function qJ(r,t,e,a,i,n,o,s,l){YJ(r,t,e,i,n,s,l),KJ(r,t,n,i,a,o,s),ott(r,s)}function $J(r){R(r,function(t){var e=Il(t.outEdges,Am),a=Il(t.inEdges,Am),i=t.getValue()||0,n=Math.max(e,a,i);t.setLayout({value:n},!0)})}function YJ(r,t,e,a,i,n,o){for(var s=[],l=[],u=[],v=[],c=0,d=0;d=0;x&&m.depth>f&&(f=m.depth),g.setLayout({depth:x?m.depth:c},!0),n==="vertical"?g.setLayout({dy:e},!0):g.setLayout({dx:e},!0);for(var b=0;bc-1?f:c-1;o&&o!=="left"&&ZJ(r,o,n,k);var D=n==="vertical"?(i-e)/k:(a-e)/k;jJ(r,D,n)}function vz(r){var t=r.hostGraph.data.getRawDataItem(r.dataIndex);return t.depth!=null&&t.depth>=0}function ZJ(r,t,e,a){if(t==="right"){for(var i=[],n=r,o=0;n.length;){for(var s=0;s0;n--)l*=.99,ttt(s,l,o),sb(s,i,e,a,o),ntt(s,l,o),sb(s,i,e,a,o)}function QJ(r,t){var e=[],a=t==="vertical"?"y":"x",i=Sw(r,function(n){return n.getLayout()[a]});return i.keys.sort(function(n,o){return n-o}),R(i.keys,function(n){e.push(i.buckets.get(n))}),e}function JJ(r,t,e,a,i,n){var o=1/0;R(r,function(s){var l=s.length,u=0;R(s,function(c){u+=c.getLayout().value});var v=n==="vertical"?(a-(l-1)*i)/u:(e-(l-1)*i)/u;v0&&(s=l.getLayout()[n]+u,i==="vertical"?l.setLayout({x:s},!0):l.setLayout({y:s},!0)),v=l.getLayout()[n]+l.getLayout()[d]+t;var h=i==="vertical"?a:e;if(u=v-t-h,u>0){s=l.getLayout()[n]-u,i==="vertical"?l.setLayout({x:s},!0):l.setLayout({y:s},!0),v=s;for(var f=c-2;f>=0;--f)l=o[f],u=l.getLayout()[n]+l.getLayout()[d]+t-v,u>0&&(s=l.getLayout()[n]-u,i==="vertical"?l.setLayout({x:s},!0):l.setLayout({y:s},!0)),v=l.getLayout()[n]}})}function ttt(r,t,e){R(r.slice().reverse(),function(a){R(a,function(i){if(i.outEdges.length){var n=Il(i.outEdges,ett,e)/Il(i.outEdges,Am);if(isNaN(n)){var o=i.outEdges.length;n=o?Il(i.outEdges,rtt,e)/o:0}if(e==="vertical"){var s=i.getLayout().x+(n-Vl(i,e))*t;i.setLayout({x:s},!0)}else{var l=i.getLayout().y+(n-Vl(i,e))*t;i.setLayout({y:l},!0)}}})})}function ett(r,t){return Vl(r.node2,t)*r.getValue()}function rtt(r,t){return Vl(r.node2,t)}function att(r,t){return Vl(r.node1,t)*r.getValue()}function itt(r,t){return Vl(r.node1,t)}function Vl(r,t){return t==="vertical"?r.getLayout().x+r.getLayout().dx/2:r.getLayout().y+r.getLayout().dy/2}function Am(r){return r.getValue()}function Il(r,t,e){for(var a=0,i=r.length,n=-1;++no&&(o=l)}),R(a,function(s){var l=new ei({type:"color",mappingMethod:"linear",dataExtent:[n,o],visual:t.get("color")}),u=l.mapValueToVisual(s.getLayout().value),v=s.getModel().get(["itemStyle","color"]);v!=null?(s.setVisual("color",v),s.setVisual("style",{fill:v})):(s.setVisual("color",u),s.setVisual("style",{fill:u}))})}i.length&&R(i,function(s){var l=s.getModel().get("lineStyle");s.setVisual("style",l)})})}function ltt(r){r.registerChartView(FJ),r.registerSeriesModel(HJ),r.registerLayout(WJ),r.registerVisual(stt),r.registerAction({type:"dragNode",event:"dragnode",update:"update"},function(t,e){e.eachComponent({mainType:"series",subType:"sankey",query:t},function(a){a.setNodePosition(t.dataIndex,[t.localX,t.localY])})})}var cz=function(){function r(){}return r.prototype._hasEncodeRule=function(t){var e=this.getEncode();return e&&e.get(t)!=null},r.prototype.getInitialData=function(t,e){var a,i=e.getComponent("xAxis",this.get("xAxisIndex")),n=e.getComponent("yAxis",this.get("yAxisIndex")),o=i.get("type"),s=n.get("type"),l;o==="category"?(t.layout="horizontal",a=i.getOrdinalMeta(),l=!this._hasEncodeRule("x")):s==="category"?(t.layout="vertical",a=n.getOrdinalMeta(),l=!this._hasEncodeRule("y")):t.layout=t.layout||"horizontal";var u=["x","y"],v=t.layout==="horizontal"?0:1,c=this._baseAxisDim=u[v],d=u[1-v],f=[i,n],h=f[v].get("type"),g=f[1-v].get("type"),m=t.data;if(m&&l){var x=[];R(m,function(S,C){var T;ht(S)?(T=S.slice(),S.unshift(C)):ht(S.value)?(T=lt({},S),T.value=T.value.slice(),S.value.unshift(C)):T=S,x.push(T)}),t.data=x}var b=this.defaultValueDimensions,w=[{name:c,type:hm(h),ordinalMeta:a,otherDims:{tooltip:!1,itemName:0},dimsDef:["base"]},{name:d,type:hm(g),dimsDef:b.slice()}];return yd(this,{coordDimensions:w,dimensionsCount:b.length+1,encodeDefaulter:We(H5,w,this)})},r.prototype.getBaseAxis=function(){var t=this._baseAxisDim;return this.ecModel.getComponent(t+"Axis",this.get(t+"AxisIndex")).axis},r}(),dz=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.defaultValueDimensions=[{name:"min",defaultTooltip:!0},{name:"Q1",defaultTooltip:!0},{name:"median",defaultTooltip:!0},{name:"Q3",defaultTooltip:!0},{name:"max",defaultTooltip:!0}],e.visualDrawType="stroke",e}return t.type="series.boxplot",t.dependencies=["xAxis","yAxis","grid"],t.defaultOption={z:2,coordinateSystem:"cartesian2d",legendHoverLink:!0,layout:null,boxWidth:[7,50],itemStyle:{color:"#fff",borderWidth:1},emphasis:{scale:!0,itemStyle:{borderWidth:2,shadowBlur:5,shadowOffsetX:1,shadowOffsetY:1,shadowColor:"rgba(0,0,0,0.2)"}},animationDuration:800},t}(ma);$a(dz,cz,!0);var utt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){var n=e.getData(),o=this.group,s=this._data;this._data||o.removeAll();var l=e.get("layout")==="horizontal"?1:0;n.diff(s).add(function(u){if(n.hasValue(u)){var v=n.getItemLayout(u),c=lI(v,n,u,l,!0);n.setItemGraphicEl(u,c),o.add(c)}}).update(function(u,v){var c=s.getItemGraphicEl(v);if(!n.hasValue(u)){o.remove(c);return}var d=n.getItemLayout(u);c?(Co(c),fz(d,c,n,u)):c=lI(d,n,u,l),o.add(c),n.setItemGraphicEl(u,c)}).remove(function(u){var v=s.getItemGraphicEl(u);v&&o.remove(v)}).execute(),this._data=n},t.prototype.remove=function(e){var a=this.group,i=this._data;this._data=null,i&&i.eachItemGraphicEl(function(n){n&&a.remove(n)})},t.type="boxplot",t}(ca),vtt=function(){function r(){}return r}(),ctt=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;return a.type="boxplotBoxPath",a}return t.prototype.getDefaultShape=function(){return new vtt},t.prototype.buildPath=function(e,a){var i=a.points,n=0;for(e.moveTo(i[n][0],i[n][1]),n++;n<4;n++)e.lineTo(i[n][0],i[n][1]);for(e.closePath();ng){var S=[x,w];a.push(S)}}}return{boxData:e,outliers:a}}var mtt={type:"echarts:boxplot",transform:function(t){var e=t.upstream;if(e.sourceFormat!==Di){var a="";Jr(a)}var i=ytt(e.getRawData(),t.config);return[{dimensions:["ItemName","Low","Q1","Q2","Q3","High"],data:i.boxData},{data:i.outliers}]}};function _tt(r){r.registerSeriesModel(dz),r.registerChartView(utt),r.registerLayout(ftt),r.registerTransform(mtt)}var xtt=["itemStyle","borderColor"],btt=["itemStyle","borderColor0"],wtt=["itemStyle","borderColorDoji"],Stt=["itemStyle","color"],Ttt=["itemStyle","color0"];function HT(r,t){return t.get(r>0?Stt:Ttt)}function WT(r,t){return t.get(r===0?wtt:r>0?xtt:btt)}var Att={seriesType:"candlestick",plan:fd(),performRawSeries:!0,reset:function(r,t){if(!t.isSeriesFiltered(r)){var e=r.pipelineContext.large;return!e&&{progress:function(a,i){for(var n;(n=a.next())!=null;){var o=i.getItemModel(n),s=i.getItemLayout(n).sign,l=o.getItemStyle();l.fill=HT(s,o),l.stroke=WT(s,o)||l.fill;var u=i.ensureUniqueItemVisual(n,"style");lt(u,l)}}}}}},Ctt=["color","borderColor"],ktt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){this.group.removeClipPath(),this._progressiveEls=null,this._updateDrawMode(e),this._isLargeDraw?this._renderLarge(e):this._renderNormal(e)},t.prototype.incrementalPrepareRender=function(e,a,i){this._clear(),this._updateDrawMode(e)},t.prototype.incrementalRender=function(e,a,i,n){this._progressiveEls=[],this._isLargeDraw?this._incrementalRenderLarge(e,a):this._incrementalRenderNormal(e,a)},t.prototype.eachRendered=function(e){Wl(this._progressiveEls||this.group,e)},t.prototype._updateDrawMode=function(e){var a=e.pipelineContext.large;(this._isLargeDraw==null||a!==this._isLargeDraw)&&(this._isLargeDraw=a,this._clear())},t.prototype._renderNormal=function(e){var a=e.getData(),i=this._data,n=this.group,o=a.getLayout("isSimpleBox"),s=e.get("clip",!0),l=e.coordinateSystem,u=l.getArea&&l.getArea();this._data||n.removeAll(),a.diff(i).add(function(v){if(a.hasValue(v)){var c=a.getItemLayout(v);if(s&&uI(u,c))return;var d=lb(c,v,!0);Ta(d,{shape:{points:c.ends}},e,v),ub(d,a,v,o),n.add(d),a.setItemGraphicEl(v,d)}}).update(function(v,c){var d=i.getItemGraphicEl(c);if(!a.hasValue(v)){n.remove(d);return}var f=a.getItemLayout(v);if(s&&uI(u,f)){n.remove(d);return}d?(Gr(d,{shape:{points:f.ends}},e,v),Co(d)):d=lb(f),ub(d,a,v,o),n.add(d),a.setItemGraphicEl(v,d)}).remove(function(v){var c=i.getItemGraphicEl(v);c&&n.remove(c)}).execute(),this._data=a},t.prototype._renderLarge=function(e){this._clear(),vI(e,this.group);var a=e.get("clip",!0)?Xh(e.coordinateSystem,!1,e):null;a?this.group.setClipPath(a):this.group.removeClipPath()},t.prototype._incrementalRenderNormal=function(e,a){for(var i=a.getData(),n=i.getLayout("isSimpleBox"),o;(o=e.next())!=null;){var s=i.getItemLayout(o),l=lb(s);ub(l,i,o,n),l.incremental=!0,this.group.add(l),this._progressiveEls.push(l)}},t.prototype._incrementalRenderLarge=function(e,a){vI(a,this.group,this._progressiveEls,!0)},t.prototype.remove=function(e){this._clear()},t.prototype._clear=function(){this.group.removeAll(),this._data=null},t.type="candlestick",t}(ca),Dtt=function(){function r(){}return r}(),Mtt=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;return a.type="normalCandlestickBox",a}return t.prototype.getDefaultShape=function(){return new Dtt},t.prototype.buildPath=function(e,a){var i=a.points;this.__simpleBox?(e.moveTo(i[4][0],i[4][1]),e.lineTo(i[6][0],i[6][1])):(e.moveTo(i[0][0],i[0][1]),e.lineTo(i[1][0],i[1][1]),e.lineTo(i[2][0],i[2][1]),e.lineTo(i[3][0],i[3][1]),e.closePath(),e.moveTo(i[4][0],i[4][1]),e.lineTo(i[5][0],i[5][1]),e.moveTo(i[6][0],i[6][1]),e.lineTo(i[7][0],i[7][1]))},t}(ur);function lb(r,t,e){var a=r.ends;return new Mtt({shape:{points:e?Ltt(a,r):a},z2:100})}function uI(r,t){for(var e=!0,a=0;aC?I[n]:L[n],ends:N,brushRect:H(T,k,w)})}function B($,Z){var Q=[];return Q[i]=Z,Q[n]=$,isNaN(Z)||isNaN($)?[NaN,NaN]:t.dataToPoint(Q)}function z($,Z,Q){var at=Z.slice(),et=Z.slice();at[i]=Ry(at[i]+a/2,1,!1),et[i]=Ry(et[i]-a/2,1,!0),Q?$.push(at,et):$.push(et,at)}function H($,Z,Q){var at=B($,Q),et=B(Z,Q);return at[i]-=a/2,et[i]-=a/2,{x:at[0],y:at[1],width:a,height:et[1]-at[1]}}function Y($){return $[i]=Ry($[i],1),$}}function h(g,m){for(var x=ts(g.count*4),b=0,w,S=[],C=[],T,k=m.getStore(),D=!!r.get(["itemStyle","borderColorDoji"]);(T=g.next())!=null;){var M=k.get(s,T),L=k.get(u,T),I=k.get(v,T),P=k.get(c,T),E=k.get(d,T);if(isNaN(M)||isNaN(P)||isNaN(E)){x[b++]=NaN,b+=3;continue}x[b++]=cI(k,T,L,I,v,D),S[i]=M,S[n]=P,w=t.dataToPoint(S,null,C),x[b++]=w?w[0]:NaN,x[b++]=w?w[1]:NaN,S[n]=E,w=t.dataToPoint(S,null,C),x[b++]=w?w[1]:NaN}m.setLayout("largePoints",x)}}};function cI(r,t,e,a,i,n){var o;return e>a?o=-1:e0?r.get(i,t-1)<=a?1:-1:1,o}function Ett(r,t){var e=r.getBaseAxis(),a,i=e.type==="category"?e.getBandWidth():(a=e.getExtent(),Math.abs(a[1]-a[0])/t.count()),n=Lt(Be(r.get("barMaxWidth"),i),i),o=Lt(Be(r.get("barMinWidth"),1),i),s=r.get("barWidth");return s!=null?Lt(s,i):Math.max(Math.min(i/2,n),o)}function Ntt(r){r.registerChartView(ktt),r.registerSeriesModel(hz),r.registerPreprocessor(Rtt),r.registerVisual(Att),r.registerLayout(Ptt)}function dI(r,t){var e=t.rippleEffectColor||t.color;r.eachChild(function(a){a.attr({z:t.z,zlevel:t.zlevel,style:{stroke:t.brushType==="stroke"?e:null,fill:t.brushType==="fill"?e:null}})})}var Ott=function(r){tt(t,r);function t(e,a){var i=r.call(this)||this,n=new Yh(e,a),o=new Ae;return i.add(n),i.add(o),i.updateData(e,a),i}return t.prototype.stopEffectAnimation=function(){this.childAt(1).removeAll()},t.prototype.startEffectAnimation=function(e){for(var a=e.symbolType,i=e.color,n=e.rippleNumber,o=this.childAt(1),s=0;s0&&(s=this._getLineLength(n)/v*1e3),s!==this._period||l!==this._loop||u!==this._roundTrip){n.stopAnimation();var d=void 0;le(c)?d=c(i):d=c,n.__t>0&&(d=-s*n.__t),this._animateSymbol(n,s,d,l,u)}this._period=s,this._loop=l,this._roundTrip=u}},t.prototype._animateSymbol=function(e,a,i,n,o){if(a>0){e.__t=0;var s=this,l=e.animate("",n).when(o?a*2:a,{__t:o?2:1}).delay(i).during(function(){s._updateSymbolPosition(e)});n||l.done(function(){s.remove(e)}),l.start()}},t.prototype._getLineLength=function(e){return yl(e.__p1,e.__cp1)+yl(e.__cp1,e.__p2)},t.prototype._updateAnimationPoints=function(e,a){e.__p1=a[0],e.__p2=a[1],e.__cp1=a[2]||[(a[0][0]+a[1][0])/2,(a[0][1]+a[1][1])/2]},t.prototype.updateData=function(e,a,i){this.childAt(0).updateData(e,a,i),this._updateEffectSymbol(e,a)},t.prototype._updateSymbolPosition=function(e){var a=e.__p1,i=e.__p2,n=e.__cp1,o=e.__t<1?e.__t:2-e.__t,s=[e.x,e.y],l=s.slice(),u=oi,v=lw;s[0]=u(a[0],n[0],i[0],o),s[1]=u(a[1],n[1],i[1],o);var c=e.__t<1?v(a[0],n[0],i[0],o):v(i[0],n[0],a[0],1-o),d=e.__t<1?v(a[1],n[1],i[1],o):v(i[1],n[1],a[1],1-o);e.rotation=-Math.atan2(d,c)-Math.PI/2,(this._symbolType==="line"||this._symbolType==="rect"||this._symbolType==="roundRect")&&(e.__lastT!==void 0&&e.__lastT=0&&!(n[l]<=a);l--);l=Math.min(l,o-2)}else{for(l=s;la);l++);l=Math.min(l-1,o-2)}var v=(a-n[l])/(n[l+1]-n[l]),c=i[l],d=i[l+1];e.x=c[0]*(1-v)+v*d[0],e.y=c[1]*(1-v)+v*d[1];var f=e.__t<1?d[0]-c[0]:c[0]-d[0],h=e.__t<1?d[1]-c[1]:c[1]-d[1];e.rotation=-Math.atan2(h,f)-Math.PI/2,this._lastFrame=l,this._lastFramePercent=a,e.ignore=!1}},t}(pz),Gtt=function(){function r(){this.polyline=!1,this.curveness=0,this.segs=[]}return r}(),Htt=function(r){tt(t,r);function t(e){var a=r.call(this,e)||this;return a._off=0,a.hoverDataIdx=-1,a}return t.prototype.reset=function(){this.notClear=!1,this._off=0},t.prototype.getDefaultStyle=function(){return{stroke:"#000",fill:null}},t.prototype.getDefaultShape=function(){return new Gtt},t.prototype.buildPath=function(e,a){var i=a.segs,n=a.curveness,o;if(a.polyline)for(o=this._off;o0){e.moveTo(i[o++],i[o++]);for(var l=1;l0){var f=(u+c)/2-(v-d)*n,h=(v+d)/2-(c-u)*n;e.quadraticCurveTo(f,h,c,d)}else e.lineTo(c,d)}this.incremental&&(this._off=o,this.notClear=!0)},t.prototype.findDataIndex=function(e,a){var i=this.shape,n=i.segs,o=i.curveness,s=this.style.lineWidth;if(i.polyline)for(var l=0,u=0;u0)for(var c=n[u++],d=n[u++],f=1;f0){var m=(c+h)/2-(d-g)*o,x=(d+g)/2-(h-c)*o;if(H4(c,d,m,x,h,g,s,e,a))return l}else if(dl(c,d,h,g,s,e,a))return l;l++}return-1},t.prototype.contain=function(e,a){var i=this.transformCoordToLocal(e,a),n=this.getBoundingRect();if(e=i[0],a=i[1],n.contain(e,a)){var o=this.hoverDataIdx=this.findDataIndex(e,a);return o>=0}return this.hoverDataIdx=-1,!1},t.prototype.getBoundingRect=function(){var e=this._rect;if(!e){for(var a=this.shape,i=a.segs,n=1/0,o=1/0,s=-1/0,l=-1/0,u=0;u0&&(o.dataIndex=l+t.__startIndex)})},r.prototype._clear=function(){this._newAdded=[],this.group.removeAll()},r}(),yz={seriesType:"lines",plan:fd(),reset:function(r){var t=r.coordinateSystem;if(t){var e=r.get("polyline"),a=r.pipelineContext.large;return{progress:function(i,n){var o=[];if(a){var s=void 0,l=i.end-i.start;if(e){for(var u=0,v=i.start;v0&&(v||u.configLayer(s,{motionBlur:!0,lastFrameAlpha:Math.max(Math.min(l/10+.9,1),0)})),o.updateData(n);var c=e.get("clip",!0)&&Xh(e.coordinateSystem,!1,e);c?this.group.setClipPath(c):this.group.removeClipPath(),this._lastZlevel=s,this._finished=!0},t.prototype.incrementalPrepareRender=function(e,a,i){var n=e.getData(),o=this._updateLineDraw(n,e);o.incrementalPrepareUpdate(n),this._clearLayer(i),this._finished=!1},t.prototype.incrementalRender=function(e,a,i){this._lineDraw.incrementalUpdate(e,a.getData()),this._finished=e.end===a.getData().count()},t.prototype.eachRendered=function(e){this._lineDraw&&this._lineDraw.eachRendered(e)},t.prototype.updateTransform=function(e,a,i){var n=e.getData(),o=e.pipelineContext;if(!this._finished||o.large||o.progressiveRender)return{update:!0};var s=yz.reset(e,a,i);s.progress&&s.progress({start:0,end:n.count(),count:n.count()},n),this._lineDraw.updateLayout(),this._clearLayer(i)},t.prototype._updateLineDraw=function(e,a){var i=this._lineDraw,n=this._showEffect(a),o=!!a.get("polyline"),s=a.pipelineContext,l=s.large;return(!i||n!==this._hasEffet||o!==this._isPolyline||l!==this._isLargeDraw)&&(i&&i.remove(),i=this._lineDraw=l?new Wtt:new ET(o?n?Ftt:gz:n?pz:PT),this._hasEffet=n,this._isPolyline=o,this._isLargeDraw=l),this.group.add(i.group),i},t.prototype._showEffect=function(e){return!!e.get(["effect","show"])},t.prototype._clearLayer=function(e){var a=e.getZr(),i=a.painter.getType()==="svg";!i&&this._lastZlevel!=null&&a.painter.getLayer(this._lastZlevel).clear(!0)},t.prototype.remove=function(e,a){this._lineDraw&&this._lineDraw.remove(),this._lineDraw=null,this._clearLayer(a)},t.prototype.dispose=function(e,a){this.remove(e,a)},t.type="lines",t}(ca),qtt=typeof Uint32Array>"u"?Array:Uint32Array,$tt=typeof Float64Array>"u"?Array:Float64Array;function fI(r){var t=r.data;t&&t[0]&&t[0][0]&&t[0][0].coord&&(r.data=pt(t,function(e){var a=[e[0].coord,e[1].coord],i={coords:a};return e[0].name&&(i.fromName=e[0].name),e[1].name&&(i.toName=e[1].name),fS([i,e[0],e[1]])}))}var Ytt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.visualStyleAccessPath="lineStyle",e.visualDrawType="stroke",e}return t.prototype.init=function(e){e.data=e.data||[],fI(e);var a=this._processFlatCoordsArray(e.data);this._flatCoords=a.flatCoords,this._flatCoordsOffset=a.flatCoordsOffset,a.flatCoords&&(e.data=new Float32Array(a.count)),r.prototype.init.apply(this,arguments)},t.prototype.mergeOption=function(e){if(fI(e),e.data){var a=this._processFlatCoordsArray(e.data);this._flatCoords=a.flatCoords,this._flatCoordsOffset=a.flatCoordsOffset,a.flatCoords&&(e.data=new Float32Array(a.count))}r.prototype.mergeOption.apply(this,arguments)},t.prototype.appendData=function(e){var a=this._processFlatCoordsArray(e.data);a.flatCoords&&(this._flatCoords?(this._flatCoords=eh(this._flatCoords,a.flatCoords),this._flatCoordsOffset=eh(this._flatCoordsOffset,a.flatCoordsOffset)):(this._flatCoords=a.flatCoords,this._flatCoordsOffset=a.flatCoordsOffset),e.data=new Float32Array(a.count)),this.getRawData().appendData(e.data)},t.prototype._getCoordsFromItemModel=function(e){var a=this.getData().getItemModel(e),i=a.option instanceof Array?a.option:a.getShallow("coords");return i},t.prototype.getLineCoordsCount=function(e){return this._flatCoordsOffset?this._flatCoordsOffset[e*2+1]:this._getCoordsFromItemModel(e).length},t.prototype.getLineCoords=function(e,a){if(this._flatCoordsOffset){for(var i=this._flatCoordsOffset[e*2],n=this._flatCoordsOffset[e*2+1],o=0;o ")})},t.prototype.preventIncremental=function(){return!!this.get(["effect","show"])},t.prototype.getProgressive=function(){var e=this.option.progressive;return e??(this.option.large?1e4:this.get("progressive"))},t.prototype.getProgressiveThreshold=function(){var e=this.option.progressiveThreshold;return e??(this.option.large?2e4:this.get("progressiveThreshold"))},t.prototype.getZLevelKey=function(){var e=this.getModel("effect"),a=e.get("trailLength");return this.getData().count()>this.getProgressiveThreshold()?this.id:e.get("show")&&a>0?a+"":""},t.type="series.lines",t.dependencies=["grid","polar","geo","calendar"],t.defaultOption={coordinateSystem:"geo",z:2,legendHoverLink:!0,xAxisIndex:0,yAxisIndex:0,symbol:["none","none"],symbolSize:[10,10],geoIndex:0,effect:{show:!1,period:4,constantSpeed:0,symbol:"circle",symbolSize:3,loop:!0,trailLength:.2},large:!1,largeThreshold:2e3,polyline:!1,clip:!0,label:{show:!1,position:"end"},lineStyle:{opacity:.5}},t}(ma);function oy(r){return r instanceof Array||(r=[r,r]),r}var Ztt={seriesType:"lines",reset:function(r){var t=oy(r.get("symbol")),e=oy(r.get("symbolSize")),a=r.getData();a.setVisual("fromSymbol",t&&t[0]),a.setVisual("toSymbol",t&&t[1]),a.setVisual("fromSymbolSize",e&&e[0]),a.setVisual("toSymbolSize",e&&e[1]);function i(n,o){var s=n.getItemModel(o),l=oy(s.getShallow("symbol",!0)),u=oy(s.getShallow("symbolSize",!0));l[0]&&n.setItemVisual(o,"fromSymbol",l[0]),l[1]&&n.setItemVisual(o,"toSymbol",l[1]),u[0]&&n.setItemVisual(o,"fromSymbolSize",u[0]),u[1]&&n.setItemVisual(o,"toSymbolSize",u[1])}return{dataEach:a.hasItemOption?i:null}}};function Xtt(r){r.registerChartView(Utt),r.registerSeriesModel(Ytt),r.registerLayout(yz),r.registerVisual(Ztt)}var jtt=256,Ktt=function(){function r(){this.blurSize=30,this.pointSize=20,this.maxOpacity=1,this.minOpacity=0,this._gradientPixels={inRange:null,outOfRange:null};var t=El.createCanvas();this.canvas=t}return r.prototype.update=function(t,e,a,i,n,o){var s=this._getBrush(),l=this._getGradient(n,"inRange"),u=this._getGradient(n,"outOfRange"),v=this.pointSize+this.blurSize,c=this.canvas,d=c.getContext("2d"),f=t.length;c.width=e,c.height=a;for(var h=0;h0){var P=o(w)?l:u;w>0&&(w=w*L+D),C[T++]=P[I],C[T++]=P[I+1],C[T++]=P[I+2],C[T++]=P[I+3]*w*256}else T+=4}return d.putImageData(S,0,0),c},r.prototype._getBrush=function(){var t=this._brushCanvas||(this._brushCanvas=El.createCanvas()),e=this.pointSize+this.blurSize,a=e*2;t.width=a,t.height=a;var i=t.getContext("2d");return i.clearRect(0,0,a,a),i.shadowOffsetX=a,i.shadowBlur=this.blurSize,i.shadowColor="#000",i.beginPath(),i.arc(-e,e,this.pointSize,0,Math.PI*2,!0),i.closePath(),i.fill(),t},r.prototype._getGradient=function(t,e){for(var a=this._gradientPixels,i=a[e]||(a[e]=new Uint8ClampedArray(256*4)),n=[0,0,0,0],o=0,s=0;s<256;s++)t[e](s/255,!0,n),i[o++]=n[0],i[o++]=n[1],i[o++]=n[2],i[o++]=n[3];return i},r}();function Qtt(r,t,e){var a=r[1]-r[0];t=pt(t,function(o){return{interval:[(o.interval[0]-r[0])/a,(o.interval[1]-r[0])/a]}});var i=t.length,n=0;return function(o){var s;for(s=n;s=0;s--){var l=t[s].interval;if(l[0]<=o&&o<=l[1]){n=s;break}}return s>=0&&s=t[0]&&a<=t[1]}}function hI(r){var t=r.dimensions;return t[0]==="lng"&&t[1]==="lat"}var tet=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){var n;a.eachComponent("visualMap",function(s){s.eachTargetSeries(function(l){l===e&&(n=s)})}),this._progressiveEls=null,this.group.removeAll();var o=e.coordinateSystem;o.type==="cartesian2d"||o.type==="calendar"?this._renderOnCartesianAndCalendar(e,i,0,e.getData().count()):hI(o)&&this._renderOnGeo(o,e,n,i)},t.prototype.incrementalPrepareRender=function(e,a,i){this.group.removeAll()},t.prototype.incrementalRender=function(e,a,i,n){var o=a.coordinateSystem;o&&(hI(o)?this.render(a,i,n):(this._progressiveEls=[],this._renderOnCartesianAndCalendar(a,n,e.start,e.end,!0)))},t.prototype.eachRendered=function(e){Wl(this._progressiveEls||this.group,e)},t.prototype._renderOnCartesianAndCalendar=function(e,a,i,n,o){var s=e.coordinateSystem,l=Iv(s,"cartesian2d"),u,v,c,d;if(l){var f=s.getAxis("x"),h=s.getAxis("y");u=f.getBandWidth()+.5,v=h.getBandWidth()+.5,c=f.scale.getExtent(),d=h.scale.getExtent()}for(var g=this.group,m=e.getData(),x=e.getModel(["emphasis","itemStyle"]).getItemStyle(),b=e.getModel(["blur","itemStyle"]).getItemStyle(),w=e.getModel(["select","itemStyle"]).getItemStyle(),S=e.get(["itemStyle","borderRadius"]),C=ai(e),T=e.getModel("emphasis"),k=T.get("focus"),D=T.get("blurScope"),M=T.get("disabled"),L=l?[m.mapDimension("x"),m.mapDimension("y"),m.mapDimension("value")]:[m.mapDimension("time"),m.mapDimension("value")],I=i;Ic[1]||Od[1])continue;var V=s.dataToPoint([N,O]);P=new Mr({shape:{x:V[0]-u/2,y:V[1]-v/2,width:u,height:v},style:E})}else{if(isNaN(m.get(L[1],I)))continue;P=new Mr({z2:1,shape:s.dataToRect([m.get(L[0],I)]).contentShape,style:E})}if(m.hasItemOption){var B=m.getItemModel(I),z=B.getModel("emphasis");x=z.getModel("itemStyle").getItemStyle(),b=B.getModel(["blur","itemStyle"]).getItemStyle(),w=B.getModel(["select","itemStyle"]).getItemStyle(),S=B.get(["itemStyle","borderRadius"]),k=z.get("focus"),D=z.get("blurScope"),M=z.get("disabled"),C=ai(B)}P.shape.r=S;var H=e.getRawValue(I),Y="-";H&&H[2]!=null&&(Y=H[2]+""),mi(P,C,{labelFetcher:e,labelDataIndex:I,defaultOpacity:E.opacity,defaultText:Y}),P.ensureState("emphasis").style=x,P.ensureState("blur").style=b,P.ensureState("select").style=w,Ia(P,k,D,M),P.incremental=o,o&&(P.states.emphasis.hoverLayer=!0),g.add(P),m.setItemGraphicEl(I,P),this._progressiveEls&&this._progressiveEls.push(P)}},t.prototype._renderOnGeo=function(e,a,i,n){var o=i.targetVisuals.inRange,s=i.targetVisuals.outOfRange,l=a.getData(),u=this._hmLayer||this._hmLayer||new Ktt;u.blurSize=a.get("blurSize"),u.pointSize=a.get("pointSize"),u.minOpacity=a.get("minOpacity"),u.maxOpacity=a.get("maxOpacity");var v=e.getViewRect().clone(),c=e.getRoamTransform();v.applyTransform(c);var d=Math.max(v.x,0),f=Math.max(v.y,0),h=Math.min(v.width+v.x,n.getWidth()),g=Math.min(v.height+v.y,n.getHeight()),m=h-d,x=g-f,b=[l.mapDimension("lng"),l.mapDimension("lat"),l.mapDimension("value")],w=l.mapArray(b,function(k,D,M){var L=e.dataToPoint([k,D]);return L[0]-=d,L[1]-=f,L.push(M),L}),S=i.getExtent(),C=i.type==="visualMap.continuous"?Jtt(S,i.option.range):Qtt(S,i.getPieceList(),i.option.selected);u.update(w,m,x,o.color.getNormalizer(),{inRange:o.color.getColorMapper(),outOfRange:s.color.getColorMapper()},C);var T=new ui({style:{width:m,height:x,x:d,y:f,image:u.canvas},silent:!0});this.group.add(T)},t.type="heatmap",t}(ca),eet=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.getInitialData=function(e,a){return Ys(null,this,{generateCoord:"value"})},t.prototype.preventIncremental=function(){var e=Wh.get(this.get("coordinateSystem"));if(e&&e.dimensions)return e.dimensions[0]==="lng"&&e.dimensions[1]==="lat"},t.type="series.heatmap",t.dependencies=["grid","geo","calendar"],t.defaultOption={coordinateSystem:"cartesian2d",z:2,geoIndex:0,blurSize:30,pointSize:20,maxOpacity:1,minOpacity:0,select:{itemStyle:{borderColor:"#212121"}}},t}(ma);function ret(r){r.registerChartView(tet),r.registerSeriesModel(eet)}var aet=["itemStyle","borderWidth"],pI=[{xy:"x",wh:"width",index:0,posDesc:["left","right"]},{xy:"y",wh:"height",index:1,posDesc:["top","bottom"]}],db=new $s,iet=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){var n=this.group,o=e.getData(),s=this._data,l=e.coordinateSystem,u=l.getBaseAxis(),v=u.isHorizontal(),c=l.master.getRect(),d={ecSize:{width:i.getWidth(),height:i.getHeight()},seriesModel:e,coordSys:l,coordSysExtent:[[c.x,c.x+c.width],[c.y,c.y+c.height]],isHorizontal:v,valueDim:pI[+v],categoryDim:pI[1-+v]};o.diff(s).add(function(h){if(o.hasValue(h)){var g=yI(o,h),m=gI(o,h,g,d),x=mI(o,d,m);o.setItemGraphicEl(h,x),n.add(x),xI(x,d,m)}}).update(function(h,g){var m=s.getItemGraphicEl(g);if(!o.hasValue(h)){n.remove(m);return}var x=yI(o,h),b=gI(o,h,x,d),w=Sz(o,b);m&&w!==m.__pictorialShapeStr&&(n.remove(m),o.setItemGraphicEl(h,null),m=null),m?det(m,d,b):m=mI(o,d,b,!0),o.setItemGraphicEl(h,m),m.__pictorialSymbolMeta=b,n.add(m),xI(m,d,b)}).remove(function(h){var g=s.getItemGraphicEl(h);g&&_I(s,h,g.__pictorialSymbolMeta.animationModel,g)}).execute();var f=e.get("clip",!0)?Xh(e.coordinateSystem,!1,e):null;return f?n.setClipPath(f):n.removeClipPath(),this._data=o,this.group},t.prototype.remove=function(e,a){var i=this.group,n=this._data;e.get("animation")?n&&n.eachItemGraphicEl(function(o){_I(n,Ie(o).dataIndex,e,o)}):i.removeAll()},t.type="pictorialBar",t}(ca);function gI(r,t,e,a){var i=r.getItemLayout(t),n=e.get("symbolRepeat"),o=e.get("symbolClip"),s=e.get("symbolPosition")||"start",l=e.get("symbolRotate"),u=(l||0)*Math.PI/180||0,v=e.get("symbolPatternSize")||2,c=e.isAnimationEnabled(),d={dataIndex:t,layout:i,itemModel:e,symbolType:r.getItemVisual(t,"symbol")||"circle",style:r.getItemVisual(t,"style"),symbolClip:o,symbolRepeat:n,symbolRepeatDirection:e.get("symbolRepeatDirection"),symbolPatternSize:v,rotation:u,animationModel:c?e:null,hoverScale:c&&e.get(["emphasis","scale"]),z2:e.getShallow("z",!0)||0};net(e,n,i,a,d),oet(r,t,i,n,o,d.boundingLength,d.pxSign,v,a,d),set(e,d.symbolScale,u,a,d);var f=d.symbolSize,h=Mv(e.get("symbolOffset"),f);return uet(e,f,i,n,o,h,s,d.valueLineWidth,d.boundingLength,d.repeatCutLength,a,d),d}function net(r,t,e,a,i){var n=a.valueDim,o=r.get("symbolBoundingData"),s=a.coordSys.getOtherAxis(a.coordSys.getBaseAxis()),l=s.toGlobalCoord(s.dataToCoord(0)),u=1-+(e[n.wh]<=0),v;if(ht(o)){var c=[fb(s,o[0])-l,fb(s,o[1])-l];c[1]=0?1:-1:v>0?1:-1}function fb(r,t){return r.toGlobalCoord(r.dataToCoord(r.scale.parse(t)))}function oet(r,t,e,a,i,n,o,s,l,u){var v=l.valueDim,c=l.categoryDim,d=Math.abs(e[c.wh]),f=r.getItemVisual(t,"symbolSize"),h;ht(f)?h=f.slice():f==null?h=["100%","100%"]:h=[f,f],h[c.index]=Lt(h[c.index],d),h[v.index]=Lt(h[v.index],a?d:Math.abs(n)),u.symbolSize=h;var g=u.symbolScale=[h[0]/s,h[1]/s];g[v.index]*=(l.isHorizontal?-1:1)*o}function set(r,t,e,a,i){var n=r.get(aet)||0;n&&(db.attr({scaleX:t[0],scaleY:t[1],rotation:e}),db.updateTransform(),n/=db.getLineScale(),n*=t[a.valueDim.index]),i.valueLineWidth=n||0}function uet(r,t,e,a,i,n,o,s,l,u,v,c){var d=v.categoryDim,f=v.valueDim,h=c.pxSign,g=Math.max(t[f.index]+s,0),m=g;if(a){var x=Math.abs(l),b=si(r.get("symbolMargin"),"15%")+"",w=!1;b.lastIndexOf("!")===b.length-1&&(w=!0,b=b.slice(0,b.length-1));var S=Lt(b,t[f.index]),C=Math.max(g+S*2,0),T=w?0:S*2,k=D4(a),D=k?a:bI((x+T)/C),M=x-D*g;S=M/2/(w?D:Math.max(D-1,1)),C=g+S*2,T=w?0:S*2,!k&&a!=="fixed"&&(D=u?bI((Math.abs(u)+T)/C):0),m=D*C-T,c.repeatTimes=D,c.symbolMargin=S}var L=h*(m/2),I=c.pathPosition=[];I[d.index]=e[d.wh]/2,I[f.index]=o==="start"?L:o==="end"?l-L:l/2,n&&(I[0]+=n[0],I[1]+=n[1]);var P=c.bundlePosition=[];P[d.index]=e[d.xy],P[f.index]=e[f.xy];var E=c.barRectShape=lt({},e);E[f.wh]=h*Math.max(Math.abs(e[f.wh]),Math.abs(I[f.index]+L)),E[d.wh]=e[d.wh];var N=c.clipShape={};N[d.xy]=-e[d.xy],N[d.wh]=v.ecSize[d.wh],N[f.xy]=0,N[f.wh]=e[f.wh]}function mz(r){var t=r.symbolPatternSize,e=qa(r.symbolType,-t/2,-t/2,t,t);return e.attr({culling:!0}),e.type!=="image"&&e.setStyle({strokeNoScale:!0}),e}function _z(r,t,e,a){var i=r.__pictorialBundle,n=e.symbolSize,o=e.valueLineWidth,s=e.pathPosition,l=t.valueDim,u=e.repeatTimes||0,v=0,c=n[t.valueDim.index]+o+e.symbolMargin*2;for(UT(r,function(g){g.__pictorialAnimationIndex=v,g.__pictorialRepeatTimes=u,v0:x<0)&&(b=u-1-g),m[l.index]=c*(b-u/2+.5)+s[l.index],{x:m[0],y:m[1],scaleX:e.symbolScale[0],scaleY:e.symbolScale[1],rotation:e.rotation}}}function xz(r,t,e,a){var i=r.__pictorialBundle,n=r.__pictorialMainPath;n?Cc(n,null,{x:e.pathPosition[0],y:e.pathPosition[1],scaleX:e.symbolScale[0],scaleY:e.symbolScale[1],rotation:e.rotation},e,a):(n=r.__pictorialMainPath=mz(e),i.add(n),Cc(n,{x:e.pathPosition[0],y:e.pathPosition[1],scaleX:0,scaleY:0,rotation:e.rotation},{scaleX:e.symbolScale[0],scaleY:e.symbolScale[1]},e,a))}function bz(r,t,e){var a=lt({},t.barRectShape),i=r.__pictorialBarRect;i?Cc(i,null,{shape:a},t,e):(i=r.__pictorialBarRect=new Mr({z2:2,shape:a,silent:!0,style:{stroke:"transparent",fill:"transparent",lineWidth:0}}),i.disableMorphing=!0,r.add(i))}function wz(r,t,e,a){if(e.symbolClip){var i=r.__pictorialClipPath,n=lt({},e.clipShape),o=t.valueDim,s=e.animationModel,l=e.dataIndex;if(i)Gr(i,{shape:n},s,l);else{n[o.wh]=0,i=new Mr({shape:n}),r.__pictorialBundle.setClipPath(i),r.__pictorialClipPath=i;var u={};u[o.wh]=e.clipShape[o.wh],Dv[a?"updateProps":"initProps"](i,{shape:u},s,l)}}}function yI(r,t){var e=r.getItemModel(t);return e.getAnimationDelayParams=vet,e.isAnimationEnabled=cet,e}function vet(r){return{index:r.__pictorialAnimationIndex,count:r.__pictorialRepeatTimes}}function cet(){return this.parentModel.isAnimationEnabled()&&!!this.getShallow("animation")}function mI(r,t,e,a){var i=new Ae,n=new Ae;return i.add(n),i.__pictorialBundle=n,n.x=e.bundlePosition[0],n.y=e.bundlePosition[1],e.symbolRepeat?_z(i,t,e):xz(i,t,e),bz(i,e,a),wz(i,t,e,a),i.__pictorialShapeStr=Sz(r,e),i.__pictorialSymbolMeta=e,i}function det(r,t,e){var a=e.animationModel,i=e.dataIndex,n=r.__pictorialBundle;Gr(n,{x:e.bundlePosition[0],y:e.bundlePosition[1]},a,i),e.symbolRepeat?_z(r,t,e,!0):xz(r,t,e,!0),bz(r,e,!0),wz(r,t,e,!0)}function _I(r,t,e,a){var i=a.__pictorialBarRect;i&&i.removeTextContent();var n=[];UT(a,function(o){n.push(o)}),a.__pictorialMainPath&&n.push(a.__pictorialMainPath),a.__pictorialClipPath&&(e=null),R(n,function(o){Ol(o,{scaleX:0,scaleY:0},e,t,function(){a.parent&&a.parent.remove(a)})}),r.setItemGraphicEl(t,null)}function Sz(r,t){return[r.getItemVisual(t.dataIndex,"symbol")||"none",!!t.symbolRepeat,!!t.symbolClip].join(":")}function UT(r,t,e){R(r.__pictorialBundle.children(),function(a){a!==r.__pictorialBarRect&&t.call(e,a)})}function Cc(r,t,e,a,i,n){t&&r.attr(t),a.symbolClip&&!i?e&&r.attr(e):e&&Dv[i?"updateProps":"initProps"](r,e,a.animationModel,a.dataIndex,n)}function xI(r,t,e){var a=e.dataIndex,i=e.itemModel,n=i.getModel("emphasis"),o=n.getModel("itemStyle").getItemStyle(),s=i.getModel(["blur","itemStyle"]).getItemStyle(),l=i.getModel(["select","itemStyle"]).getItemStyle(),u=i.getShallow("cursor"),v=n.get("focus"),c=n.get("blurScope"),d=n.get("scale");UT(r,function(g){if(g instanceof ui){var m=g.style;g.useStyle(lt({image:m.image,x:m.x,y:m.y,width:m.width,height:m.height},e.style))}else g.useStyle(e.style);var x=g.ensureState("emphasis");x.style=o,d&&(x.scaleX=g.scaleX*1.1,x.scaleY=g.scaleY*1.1),g.ensureState("blur").style=s,g.ensureState("select").style=l,u&&(g.cursor=u),g.z2=e.z2});var f=t.valueDim.posDesc[+(e.boundingLength>0)],h=r.__pictorialBarRect;h.ignoreClip=!0,mi(h,ai(i),{labelFetcher:t.seriesModel,labelDataIndex:a,defaultText:jc(t.seriesModel.getData(),a),inheritColor:e.style.fill,defaultOpacity:e.style.opacity,defaultOutsidePosition:f}),Ia(r,v,c,n.get("disabled"))}function bI(r){var t=Math.round(r);return Math.abs(r-t)<1e-4?t:Math.ceil(r)}var fet=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.hasSymbolVisual=!0,e.defaultSymbol="roundRect",e}return t.prototype.getInitialData=function(e){return e.stack=null,r.prototype.getInitialData.apply(this,arguments)},t.type="series.pictorialBar",t.dependencies=["grid"],t.defaultOption=Ul(_h.defaultOption,{symbol:"circle",symbolSize:null,symbolRotate:null,symbolPosition:null,symbolOffset:null,symbolMargin:null,symbolRepeat:!1,symbolRepeatDirection:"end",symbolClip:!1,symbolBoundingData:null,symbolPatternSize:400,barGap:"-100%",clip:!1,progressive:0,emphasis:{scale:!1},select:{itemStyle:{borderColor:"#212121"}}}),t}(_h);function het(r){r.registerChartView(iet),r.registerSeriesModel(fet),r.registerLayout(r.PRIORITY.VISUAL.LAYOUT,We(oN,"pictorialBar")),r.registerLayout(r.PRIORITY.VISUAL.PROGRESSIVE_LAYOUT,sN("pictorialBar"))}var pet=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e._layers=[],e}return t.prototype.render=function(e,a,i){var n=e.getData(),o=this,s=this.group,l=e.getLayerSeries(),u=n.getLayout("layoutInfo"),v=u.rect,c=u.boundaryGap;s.x=0,s.y=v.y+c[0];function d(m){return m.name}var f=new Fs(this._layersSeries||[],l,d,d),h=[];f.add(Nt(g,this,"add")).update(Nt(g,this,"update")).remove(Nt(g,this,"remove")).execute();function g(m,x,b){var w=o._layers;if(m==="remove"){s.remove(w[x]);return}for(var S=[],C=[],T,k=l[x].indices,D=0;Dn&&(n=s),a.push(s)}for(var u=0;un&&(n=c)}return{y0:i,max:n}}function xet(r){r.registerChartView(pet),r.registerSeriesModel(yet),r.registerLayout(met),r.registerProcessor(Kh("themeRiver"))}var bet=2,wet=4,SI=function(r){tt(t,r);function t(e,a,i,n){var o=r.call(this)||this;o.z2=bet,o.textConfig={inside:!0},Ie(o).seriesIndex=a.seriesIndex;var s=new Rr({z2:wet,silent:e.getModel().get(["label","silent"])});return o.setTextContent(s),o.updateData(!0,e,a,i,n),o}return t.prototype.updateData=function(e,a,i,n,o){this.node=a,a.piece=this,i=i||this._seriesModel,n=n||this._ecModel;var s=this;Ie(s).dataIndex=a.dataIndex;var l=a.getModel(),u=l.getModel("emphasis"),v=a.getLayout(),c=lt({},v);c.label=null;var d=a.getVisual("style");d.lineJoin="bevel";var f=a.getVisual("decal");f&&(d.decal=Zc(f,o));var h=qu(l.getModel("itemStyle"),c,!0);lt(c,h),R(Fi,function(b){var w=s.ensureState(b),S=l.getModel([b,"itemStyle"]);w.style=S.getItemStyle();var C=qu(S,c);C&&(w.shape=C)}),e?(s.setShape(c),s.shape.r=v.r0,Ta(s,{shape:{r:v.r}},i,a.dataIndex)):(Gr(s,{shape:c},i),Co(s)),s.useStyle(d),this._updateLabel(i);var g=l.getShallow("cursor");g&&s.attr("cursor",g),this._seriesModel=i||this._seriesModel,this._ecModel=n||this._ecModel;var m=u.get("focus"),x=m==="relative"?eh(a.getAncestorsIndices(),a.getDescendantIndices()):m==="ancestor"?a.getAncestorsIndices():m==="descendant"?a.getDescendantIndices():m;Ia(this,x,u.get("blurScope"),u.get("disabled"))},t.prototype._updateLabel=function(e){var a=this,i=this.node.getModel(),n=i.getModel("label"),o=this.node.getLayout(),s=o.endAngle-o.startAngle,l=(o.startAngle+o.endAngle)/2,u=Math.cos(l),v=Math.sin(l),c=this,d=c.getTextContent(),f=this.node.dataIndex,h=n.get("minAngle")/180*Math.PI,g=n.get("show")&&!(h!=null&&Math.abs(s)N&&!nh(V-N)&&V0?(o.virtualPiece?o.virtualPiece.updateData(!1,b,e,a,i):(o.virtualPiece=new SI(b,e,a,i),v.add(o.virtualPiece)),w.piece.off("click"),o.virtualPiece.on("click",function(S){o._rootToNode(w.parentNode)})):o.virtualPiece&&(v.remove(o.virtualPiece),o.virtualPiece=null)}},t.prototype._initEvents=function(){var e=this;this.group.off("click"),this.group.on("click",function(a){var i=!1,n=e.seriesModel.getViewRoot();n.eachNode(function(o){if(!i&&o.piece&&o.piece===a.target){var s=o.getModel().get("nodeClick");if(s==="rootToNode")e._rootToNode(o);else if(s==="link"){var l=o.getModel(),u=l.get("link");if(u){var v=l.get("target",!0)||"_blank";om(u,v)}}i=!0}})})},t.prototype._rootToNode=function(e){e!==this.seriesModel.getViewRoot()&&this.api.dispatchAction({type:S2,from:this.uid,seriesId:this.seriesModel.id,targetNode:e})},t.prototype.containPoint=function(e,a){var i=a.getData(),n=i.getItemLayout(0);if(n){var o=e[0]-n.cx,s=e[1]-n.cy,l=Math.sqrt(o*o+s*s);return l<=n.r&&l>=n.r0}},t.type="sunburst",t}(ca),ket=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.ignoreStyleOnData=!0,e}return t.prototype.getInitialData=function(e,a){var i={name:e.name,children:e.data};Tz(i);var n=this._levelModels=pt(e.levels||[],function(l){return new ia(l,this,a)},this),o=kT.createTree(i,this,s);function s(l){l.wrapMethod("getItemModel",function(u,v){var c=o.getNodeByDataIndex(v),d=n[c.depth];return d&&(u.parentModel=d),u})}return o.data},t.prototype.optionUpdated=function(){this.resetViewRoot()},t.prototype.getDataParams=function(e){var a=r.prototype.getDataParams.apply(this,arguments),i=this.getData().tree.getNodeByDataIndex(e);return a.treePathInfo=N_(i,this),a},t.prototype.getLevelModel=function(e){return this._levelModels&&this._levelModels[e.depth]},t.prototype.getViewRoot=function(){return this._viewRoot},t.prototype.resetViewRoot=function(e){e?this._viewRoot=e:e=this._viewRoot;var a=this.getRawData().tree.root;(!e||e!==a&&!a.contains(e))&&(this._viewRoot=a)},t.prototype.enableAriaDecal=function(){MO(this)},t.type="series.sunburst",t.defaultOption={z:2,center:["50%","50%"],radius:[0,"75%"],clockwise:!0,startAngle:90,minAngle:0,stillShowZeroSum:!0,nodeClick:"rootToNode",renderLabelForZeroData:!1,label:{rotate:"radial",show:!0,opacity:1,align:"center",position:"inside",distance:5,silent:!0},itemStyle:{borderWidth:1,borderColor:"white",borderType:"solid",shadowBlur:0,shadowColor:"rgba(0, 0, 0, 0.2)",shadowOffsetX:0,shadowOffsetY:0,opacity:1},emphasis:{focus:"descendant"},blur:{itemStyle:{opacity:.2},label:{opacity:.1}},animationType:"expansion",animationDuration:1e3,animationDurationUpdate:500,data:[],sort:"desc"},t}(ma);function Tz(r){var t=0;R(r.children,function(a){Tz(a);var i=a.value;ht(i)&&(i=i[0]),t+=i});var e=r.value;ht(e)&&(e=e[0]),(e==null||isNaN(e))&&(e=t),e<0&&(e=0),ht(r.value)?r.value[0]=e:r.value=e}var AI=Math.PI/180;function Det(r,t,e){t.eachSeriesByType(r,function(a){var i=a.get("center"),n=a.get("radius");ht(n)||(n=[0,n]),ht(i)||(i=[i,i]);var o=e.getWidth(),s=e.getHeight(),l=Math.min(o,s),u=Lt(i[0],o),v=Lt(i[1],s),c=Lt(n[0],l/2),d=Lt(n[1],l/2),f=-a.get("startAngle")*AI,h=a.get("minAngle")*AI,g=a.getData().tree.root,m=a.getViewRoot(),x=m.depth,b=a.get("sort");b!=null&&Az(m,b);var w=0;R(m.children,function(V){!isNaN(V.getValue())&&w++});var S=m.getValue(),C=Math.PI/(S||w)*2,T=m.depth>0,k=m.height-(T?-1:1),D=(d-c)/(k||1),M=a.get("clockwise"),L=a.get("stillShowZeroSum"),I=M?1:-1,P=function(V,B){if(V){var z=B;if(V!==g){var H=V.getValue(),Y=S===0&&L?C:H*C;Y1;)o=o.parentNode;var s=i.getColorFromPalette(o.name||o.dataIndex+"",t);return a.depth>1&&Pt(s)&&(s=cw(s,(a.depth-1)/(n-1)*.5)),s}r.eachSeriesByType("sunburst",function(a){var i=a.getData(),n=i.tree;n.eachNode(function(o){var s=o.getModel(),l=s.getModel("itemStyle").getItemStyle();l.fill||(l.fill=e(o,a,n.root.height));var u=i.ensureUniqueItemVisual(o.dataIndex,"style");lt(u,l)})})}function Iet(r){r.registerChartView(Cet),r.registerSeriesModel(ket),r.registerLayout(We(Det,"sunburst")),r.registerProcessor(We(Kh,"sunburst")),r.registerVisual(Let),Aet(r)}var CI={color:"fill",borderColor:"stroke"},Ret={symbol:1,symbolSize:1,symbolKeepAspect:1,legendIcon:1,visualMeta:1,liftZ:1,decal:1},Es=Lr(),Pet=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.optionUpdated=function(){this.currentZLevel=this.get("zlevel",!0),this.currentZ=this.get("z",!0)},t.prototype.getInitialData=function(e,a){return Ys(null,this)},t.prototype.getDataParams=function(e,a,i){var n=r.prototype.getDataParams.call(this,e,a);return i&&(n.info=Es(i).info),n},t.type="series.custom",t.dependencies=["grid","polar","geo","singleAxis","calendar"],t.defaultOption={coordinateSystem:"cartesian2d",z:2,legendHoverLink:!0,clip:!1},t}(ma);function Eet(r,t){return t=t||[0,0],pt(["x","y"],function(e,a){var i=this.getAxis(e),n=t[a],o=r[a]/2;return i.type==="category"?i.getBandWidth():Math.abs(i.dataToCoord(n-o)-i.dataToCoord(n+o))},this)}function Net(r){var t=r.master.getRect();return{coordSys:{type:"cartesian2d",x:t.x,y:t.y,width:t.width,height:t.height},api:{coord:function(e){return r.dataToPoint(e)},size:Nt(Eet,r)}}}function Oet(r,t){return t=t||[0,0],pt([0,1],function(e){var a=t[e],i=r[e]/2,n=[],o=[];return n[e]=a-i,o[e]=a+i,n[1-e]=o[1-e]=t[1-e],Math.abs(this.dataToPoint(n)[e]-this.dataToPoint(o)[e])},this)}function zet(r){var t=r.getBoundingRect();return{coordSys:{type:"geo",x:t.x,y:t.y,width:t.width,height:t.height,zoom:r.getZoom()},api:{coord:function(e){return r.dataToPoint(e)},size:Nt(Oet,r)}}}function Bet(r,t){var e=this.getAxis(),a=t instanceof Array?t[0]:t,i=(r instanceof Array?r[0]:r)/2;return e.type==="category"?e.getBandWidth():Math.abs(e.dataToCoord(a-i)-e.dataToCoord(a+i))}function Vet(r){var t=r.getRect();return{coordSys:{type:"singleAxis",x:t.x,y:t.y,width:t.width,height:t.height},api:{coord:function(e){return r.dataToPoint(e)},size:Nt(Bet,r)}}}function Fet(r,t){return t=t||[0,0],pt(["Radius","Angle"],function(e,a){var i="get"+e+"Axis",n=this[i](),o=t[a],s=r[a]/2,l=n.type==="category"?n.getBandWidth():Math.abs(n.dataToCoord(o-s)-n.dataToCoord(o+s));return e==="Angle"&&(l=l*Math.PI/180),l},this)}function Get(r){var t=r.getRadiusAxis(),e=r.getAngleAxis(),a=t.getExtent();return a[0]>a[1]&&a.reverse(),{coordSys:{type:"polar",cx:r.cx,cy:r.cy,r:a[1],r0:a[0]},api:{coord:function(i){var n=t.dataToRadius(i[0]),o=e.dataToAngle(i[1]),s=r.coordToPoint([n,o]);return s.push(n,o*Math.PI/180),s},size:Nt(Fet,r)}}}function Het(r){var t=r.getRect(),e=r.getRangeInfo();return{coordSys:{type:"calendar",x:t.x,y:t.y,width:t.width,height:t.height,cellWidth:r.getCellWidth(),cellHeight:r.getCellHeight(),rangeInfo:{start:e.start,end:e.end,weeks:e.weeks,dayCount:e.allDay}},api:{coord:function(a,i){return r.dataToPoint(a,i)}}}}function Cz(r,t,e,a){return r&&(r.legacy||r.legacy!==!1&&!e&&!a&&t!=="tspan"&&(t==="text"||Vt(r,"text")))}function kz(r,t,e){var a=r,i,n,o;if(t==="text")o=a;else{o={},Vt(a,"text")&&(o.text=a.text),Vt(a,"rich")&&(o.rich=a.rich),Vt(a,"textFill")&&(o.fill=a.textFill),Vt(a,"textStroke")&&(o.stroke=a.textStroke),Vt(a,"fontFamily")&&(o.fontFamily=a.fontFamily),Vt(a,"fontSize")&&(o.fontSize=a.fontSize),Vt(a,"fontStyle")&&(o.fontStyle=a.fontStyle),Vt(a,"fontWeight")&&(o.fontWeight=a.fontWeight),n={type:"text",style:o,silent:!0},i={};var s=Vt(a,"textPosition");e?i.position=s?a.textPosition:"inside":s&&(i.position=a.textPosition),Vt(a,"textPosition")&&(i.position=a.textPosition),Vt(a,"textOffset")&&(i.offset=a.textOffset),Vt(a,"textRotation")&&(i.rotation=a.textRotation),Vt(a,"textDistance")&&(i.distance=a.textDistance)}return kI(o,r),R(o.rich,function(l){kI(l,l)}),{textConfig:i,textContent:n}}function kI(r,t){t&&(t.font=t.textFont||t.font,Vt(t,"textStrokeWidth")&&(r.lineWidth=t.textStrokeWidth),Vt(t,"textAlign")&&(r.align=t.textAlign),Vt(t,"textVerticalAlign")&&(r.verticalAlign=t.textVerticalAlign),Vt(t,"textLineHeight")&&(r.lineHeight=t.textLineHeight),Vt(t,"textWidth")&&(r.width=t.textWidth),Vt(t,"textHeight")&&(r.height=t.textHeight),Vt(t,"textBackgroundColor")&&(r.backgroundColor=t.textBackgroundColor),Vt(t,"textPadding")&&(r.padding=t.textPadding),Vt(t,"textBorderColor")&&(r.borderColor=t.textBorderColor),Vt(t,"textBorderWidth")&&(r.borderWidth=t.textBorderWidth),Vt(t,"textBorderRadius")&&(r.borderRadius=t.textBorderRadius),Vt(t,"textBoxShadowColor")&&(r.shadowColor=t.textBoxShadowColor),Vt(t,"textBoxShadowBlur")&&(r.shadowBlur=t.textBoxShadowBlur),Vt(t,"textBoxShadowOffsetX")&&(r.shadowOffsetX=t.textBoxShadowOffsetX),Vt(t,"textBoxShadowOffsetY")&&(r.shadowOffsetY=t.textBoxShadowOffsetY))}function DI(r,t,e){var a=r;a.textPosition=a.textPosition||e.position||"inside",e.offset!=null&&(a.textOffset=e.offset),e.rotation!=null&&(a.textRotation=e.rotation),e.distance!=null&&(a.textDistance=e.distance);var i=a.textPosition.indexOf("inside")>=0,n=r.fill||"#000";MI(a,t);var o=a.textFill==null;return i?o&&(a.textFill=e.insideFill||"#fff",!a.textStroke&&e.insideStroke&&(a.textStroke=e.insideStroke),!a.textStroke&&(a.textStroke=n),a.textStrokeWidth==null&&(a.textStrokeWidth=2)):(o&&(a.textFill=r.fill||e.outsideFill||"#000"),!a.textStroke&&e.outsideStroke&&(a.textStroke=e.outsideStroke)),a.text=t.text,a.rich=t.rich,R(t.rich,function(s){MI(s,s)}),a}function MI(r,t){t&&(Vt(t,"fill")&&(r.textFill=t.fill),Vt(t,"stroke")&&(r.textStroke=t.fill),Vt(t,"lineWidth")&&(r.textStrokeWidth=t.lineWidth),Vt(t,"font")&&(r.font=t.font),Vt(t,"fontStyle")&&(r.fontStyle=t.fontStyle),Vt(t,"fontWeight")&&(r.fontWeight=t.fontWeight),Vt(t,"fontSize")&&(r.fontSize=t.fontSize),Vt(t,"fontFamily")&&(r.fontFamily=t.fontFamily),Vt(t,"align")&&(r.textAlign=t.align),Vt(t,"verticalAlign")&&(r.textVerticalAlign=t.verticalAlign),Vt(t,"lineHeight")&&(r.textLineHeight=t.lineHeight),Vt(t,"width")&&(r.textWidth=t.width),Vt(t,"height")&&(r.textHeight=t.height),Vt(t,"backgroundColor")&&(r.textBackgroundColor=t.backgroundColor),Vt(t,"padding")&&(r.textPadding=t.padding),Vt(t,"borderColor")&&(r.textBorderColor=t.borderColor),Vt(t,"borderWidth")&&(r.textBorderWidth=t.borderWidth),Vt(t,"borderRadius")&&(r.textBorderRadius=t.borderRadius),Vt(t,"shadowColor")&&(r.textBoxShadowColor=t.shadowColor),Vt(t,"shadowBlur")&&(r.textBoxShadowBlur=t.shadowBlur),Vt(t,"shadowOffsetX")&&(r.textBoxShadowOffsetX=t.shadowOffsetX),Vt(t,"shadowOffsetY")&&(r.textBoxShadowOffsetY=t.shadowOffsetY),Vt(t,"textShadowColor")&&(r.textShadowColor=t.textShadowColor),Vt(t,"textShadowBlur")&&(r.textShadowBlur=t.textShadowBlur),Vt(t,"textShadowOffsetX")&&(r.textShadowOffsetX=t.textShadowOffsetX),Vt(t,"textShadowOffsetY")&&(r.textShadowOffsetY=t.textShadowOffsetY))}var Dz={position:["x","y"],scale:["scaleX","scaleY"],origin:["originX","originY"]},LI=xr(Dz);os(ls,function(r,t){return r[t]=1,r},{});ls.join(", ");var Cm=["","style","shape","extra"],Jc=Lr();function qT(r,t,e,a,i){var n=r+"Animation",o=sd(r,a,i)||{},s=Jc(t).userDuring;return o.duration>0&&(o.during=s?Nt(Yet,{el:t,userDuring:s}):null,o.setToFinal=!0,o.scope=r),lt(o,e[n]),o}function By(r,t,e,a){a=a||{};var i=a.dataIndex,n=a.isInit,o=a.clearStyle,s=e.isAnimationEnabled(),l=Jc(r),u=t.style;l.userDuring=t.during;var v={},c={};if(Xet(r,t,c),RI("shape",t,c),RI("extra",t,c),!n&&s&&(Zet(r,t,v),II("shape",r,t,v),II("extra",r,t,v),jet(r,t,u,v)),c.style=u,Wet(r,c,o),qet(r,t),s)if(n){var d={};R(Cm,function(h){var g=h?t[h]:t;g&&g.enterFrom&&(h&&(d[h]=d[h]||{}),lt(h?d[h]:d,g.enterFrom))});var f=qT("enter",r,t,e,i);f.duration>0&&r.animateFrom(d,f)}else Uet(r,t,i||0,e,v);Mz(r,t),u?r.dirty():r.markRedraw()}function Mz(r,t){for(var e=Jc(r).leaveToProps,a=0;a0&&r.animateFrom(i,n)}}function qet(r,t){Vt(t,"silent")&&(r.silent=t.silent),Vt(t,"ignore")&&(r.ignore=t.ignore),r instanceof jn&&Vt(t,"invisible")&&(r.invisible=t.invisible),r instanceof ur&&Vt(t,"autoBatch")&&(r.autoBatch=t.autoBatch)}var Vo={},$et={setTransform:function(r,t){return Vo.el[r]=t,this},getTransform:function(r){return Vo.el[r]},setShape:function(r,t){var e=Vo.el,a=e.shape||(e.shape={});return a[r]=t,e.dirtyShape&&e.dirtyShape(),this},getShape:function(r){var t=Vo.el.shape;if(t)return t[r]},setStyle:function(r,t){var e=Vo.el,a=e.style;return a&&(a[r]=t,e.dirtyStyle&&e.dirtyStyle()),this},getStyle:function(r){var t=Vo.el.style;if(t)return t[r]},setExtra:function(r,t){var e=Vo.el.extra||(Vo.el.extra={});return e[r]=t,this},getExtra:function(r){var t=Vo.el.extra;if(t)return t[r]}};function Yet(){var r=this,t=r.el;if(t){var e=Jc(t).userDuring,a=r.userDuring;if(e!==a){r.el=r.userDuring=null;return}Vo.el=t,a($et)}}function II(r,t,e,a){var i=e[r];if(i){var n=t[r],o;if(n){var s=e.transition,l=i.transition;if(l)if(!o&&(o=a[r]={}),uv(l))lt(o,n);else for(var u=la(l),v=0;v=0){!o&&(o=a[r]={});for(var f=xr(n),v=0;v=0)){var d=r.getAnimationStyleProps(),f=d?d.style:null;if(f){!n&&(n=a.style={});for(var h=xr(e),u=0;u=0?t.getStore().get(B,O):void 0}var z=t.get(V.name,O),H=V&&V.ordinalMeta;return H?H.categories[z]:z}function T(N,O){O==null&&(O=u);var V=t.getItemVisual(O,"style"),B=V&&V.fill,z=V&&V.opacity,H=b(O,Tl).getItemStyle();B!=null&&(H.fill=B),z!=null&&(H.opacity=z);var Y={inheritColor:Pt(B)?B:"#000"},$=w(O,Tl),Z=ya($,null,Y,!1,!0);Z.text=$.getShallow("show")?Be(r.getFormattedLabel(O,Tl),jc(t,O)):null;var Q=im($,Y,!1);return M(N,H),H=DI(H,Z,Q),N&&D(H,N),H.legacy=!0,H}function k(N,O){O==null&&(O=u);var V=b(O,Ns).getItemStyle(),B=w(O,Ns),z=ya(B,null,null,!0,!0);z.text=B.getShallow("show")?as(r.getFormattedLabel(O,Ns),r.getFormattedLabel(O,Tl),jc(t,O)):null;var H=im(B,null,!0);return M(N,V),V=DI(V,z,H),N&&D(V,N),V.legacy=!0,V}function D(N,O){for(var V in O)Vt(O,V)&&(N[V]=O[V])}function M(N,O){N&&(N.textFill&&(O.textFill=N.textFill),N.textPosition&&(O.textPosition=N.textPosition))}function L(N,O){if(O==null&&(O=u),Vt(CI,N)){var V=t.getItemVisual(O,"style");return V?V[CI[N]]:null}if(Vt(Ret,N))return t.getItemVisual(O,N)}function I(N){if(n.type==="cartesian2d"){var O=n.getBaseAxis();return Y$(ve({axis:O},N))}}function P(){return e.getCurrentSeriesIndices()}function E(N){return zS(N,e)}}function ort(r){var t={};return R(r.dimensions,function(e){var a=r.getDimensionInfo(e);if(!a.isExtraCoord){var i=a.coordDim,n=t[i]=t[i]||[];n[a.coordDimIndex]=r.getDimensionIndex(e)}}),t}function yb(r,t,e,a,i,n,o){if(!a){n.remove(t);return}var s=jT(r,t,e,a,i,n);return s&&o.setItemGraphicEl(e,s),s&&Ia(s,a.focus,a.blurScope,a.emphasisDisabled),s}function jT(r,t,e,a,i,n){var o=-1,s=t;t&&Pz(t,a,i)&&(o=ir(n.childrenRef(),t),t=null);var l=!t,u=t;u?u.clearStates():(u=ZT(a),s&&rrt(s,u)),a.morph===!1?u.disableMorphing=!0:u.disableMorphing&&(u.disableMorphing=!1),Cn.normal.cfg=Cn.normal.conOpt=Cn.emphasis.cfg=Cn.emphasis.conOpt=Cn.blur.cfg=Cn.blur.conOpt=Cn.select.cfg=Cn.select.conOpt=null,Cn.isLegacy=!1,lrt(u,e,a,i,l,Cn),srt(u,e,a,i,l),XT(r,u,e,a,Cn,i,l),Vt(a,"info")&&(Es(u).info=a.info);for(var v=0;v=0?n.replaceAt(u,o):n.add(u),u}function Pz(r,t,e){var a=Es(r),i=t.type,n=t.shape,o=t.style;return e.isUniversalTransitionEnabled()||i!=null&&i!==a.customGraphicType||i==="path"&&frt(n)&&Ez(n)!==a.customPathData||i==="image"&&Vt(o,"image")&&o.image!==a.customImagePath}function srt(r,t,e,a,i){var n=e.clipPath;if(n===!1)r&&r.getClipPath()&&r.removeClipPath();else if(n){var o=r.getClipPath();o&&Pz(o,n,a)&&(o=null),o||(o=ZT(n),r.setClipPath(o)),XT(null,o,t,n,null,a,i)}}function lrt(r,t,e,a,i,n){if(!r.isGroup){EI(e,null,n),EI(e,Ns,n);var o=n.normal.conOpt,s=n.emphasis.conOpt,l=n.blur.conOpt,u=n.select.conOpt;if(o!=null||s!=null||u!=null||l!=null){var v=r.getTextContent();if(o===!1)v&&r.removeTextContent();else{o=n.normal.conOpt=o||{type:"text"},v?v.clearStates():(v=ZT(o),r.setTextContent(v)),XT(null,v,t,o,null,a,i);for(var c=o&&o.style,d=0;d=v;f--){var h=t.childAt(f);vrt(t,h,i)}}}function vrt(r,t,e){t&&B_(t,Es(r).option,e)}function crt(r){new Fs(r.oldChildren,r.newChildren,NI,NI,r).add(OI).update(OI).remove(drt).execute()}function NI(r,t){var e=r&&r.name;return e??trt+t}function OI(r,t){var e=this.context,a=r!=null?e.newChildren[r]:null,i=t!=null?e.oldChildren[t]:null;jT(e.api,i,e.dataIndex,a,e.seriesModel,e.group)}function drt(r){var t=this.context,e=t.oldChildren[r];e&&B_(e,Es(e).option,t.seriesModel)}function Ez(r){return r&&(r.pathData||r.d)}function frt(r){return r&&(Vt(r,"pathData")||Vt(r,"d"))}function hrt(r){r.registerChartView(art),r.registerSeriesModel(Pet)}var Ru=Lr(),zI=be,mb=Nt,QT=function(){function r(){this._dragging=!1,this.animationThreshold=15}return r.prototype.render=function(t,e,a,i){var n=e.get("value"),o=e.get("status");if(this._axisModel=t,this._axisPointerModel=e,this._api=a,!(!i&&this._lastValue===n&&this._lastStatus===o)){this._lastValue=n,this._lastStatus=o;var s=this._group,l=this._handle;if(!o||o==="hide"){s&&s.hide(),l&&l.hide();return}s&&s.show(),l&&l.show();var u={};this.makeElOption(u,n,t,e,a);var v=u.graphicKey;v!==this._lastGraphicKey&&this.clear(a),this._lastGraphicKey=v;var c=this._moveAnimation=this.determineAnimation(t,e);if(!s)s=this._group=new Ae,this.createPointerEl(s,u,t,e),this.createLabelEl(s,u,t,e),a.getZr().add(s);else{var d=We(BI,e,c);this.updatePointerEl(s,u,d),this.updateLabelEl(s,u,d,e)}FI(s,e,!0),this._renderHandle(n)}},r.prototype.remove=function(t){this.clear(t)},r.prototype.dispose=function(t){this.clear(t)},r.prototype.determineAnimation=function(t,e){var a=e.get("animation"),i=t.axis,n=i.type==="category",o=e.get("snap");if(!o&&!n)return!1;if(a==="auto"||a==null){var s=this.animationThreshold;if(n&&i.getBandWidth()>s)return!0;if(o){var l=xT(t).seriesDataCount,u=i.getExtent();return Math.abs(u[0]-u[1])/l>s}return!1}return a===!0},r.prototype.makeElOption=function(t,e,a,i,n){},r.prototype.createPointerEl=function(t,e,a,i){var n=e.pointer;if(n){var o=Ru(t).pointerEl=new Dv[n.type](zI(e.pointer));t.add(o)}},r.prototype.createLabelEl=function(t,e,a,i){if(e.label){var n=Ru(t).labelEl=new Rr(zI(e.label));t.add(n),VI(n,i)}},r.prototype.updatePointerEl=function(t,e,a){var i=Ru(t).pointerEl;i&&e.pointer&&(i.setStyle(e.pointer.style),a(i,{shape:e.pointer.shape}))},r.prototype.updateLabelEl=function(t,e,a,i){var n=Ru(t).labelEl;n&&(n.setStyle(e.label.style),a(n,{x:e.label.x,y:e.label.y}),VI(n,i))},r.prototype._renderHandle=function(t){if(!(this._dragging||!this.updateHandleTransform)){var e=this._axisPointerModel,a=this._api.getZr(),i=this._handle,n=e.getModel("handle"),o=e.get("status");if(!n.get("show")||!o||o==="hide"){i&&a.remove(i),this._handle=null;return}var s;this._handle||(s=!0,i=this._handle=Hh(n.get("icon"),{cursor:"move",draggable:!0,onmousemove:function(u){Os(u.event)},onmousedown:mb(this._onHandleDragMove,this,0,0),drift:mb(this._onHandleDragMove,this),ondragend:mb(this._onHandleDragEnd,this)}),a.add(i)),FI(i,e,!1),i.setStyle(n.getItemStyle(null,["color","borderColor","borderWidth","opacity","shadowColor","shadowBlur","shadowOffsetX","shadowOffsetY"]));var l=n.get("size");ht(l)||(l=[l,l]),i.scaleX=l[0]/2,i.scaleY=l[1]/2,hd(this,"_doDispatchAxisPointer",n.get("throttle")||0,"fixRate"),this._moveHandleToValue(t,s)}},r.prototype._moveHandleToValue=function(t,e){BI(this._axisPointerModel,!e&&this._moveAnimation,this._handle,_b(this.getHandleTransform(t,this._axisModel,this._axisPointerModel)))},r.prototype._onHandleDragMove=function(t,e){var a=this._handle;if(a){this._dragging=!0;var i=this.updateHandleTransform(_b(a),[t,e],this._axisModel,this._axisPointerModel);this._payloadInfo=i,a.stopAnimation(),a.attr(_b(i)),Ru(a).lastProp=null,this._doDispatchAxisPointer()}},r.prototype._doDispatchAxisPointer=function(){var t=this._handle;if(t){var e=this._payloadInfo,a=this._axisModel;this._api.dispatchAction({type:"updateAxisPointer",x:e.cursorPoint[0],y:e.cursorPoint[1],tooltipOption:e.tooltipOption,axesInfo:[{axisDim:a.axis.dim,axisIndex:a.componentIndex}]})}},r.prototype._onHandleDragEnd=function(){this._dragging=!1;var t=this._handle;if(t){var e=this._axisPointerModel.get("value");this._moveHandleToValue(e),this._api.dispatchAction({type:"hideTip"})}},r.prototype.clear=function(t){this._lastValue=null,this._lastStatus=null;var e=t.getZr(),a=this._group,i=this._handle;e&&a&&(this._lastGraphicKey=null,a&&e.remove(a),i&&e.remove(i),this._group=null,this._handle=null,this._payloadInfo=null),fh(this,"_doDispatchAxisPointer")},r.prototype.doClear=function(){},r.prototype.buildLabel=function(t,e,a){return a=a||0,{x:t[a],y:t[1-a],width:e[a],height:e[1-a]}},r}();function BI(r,t,e,a){Nz(Ru(e).lastProp,a)||(Ru(e).lastProp=a,t?Gr(e,a,r):(e.stopAnimation(),e.attr(a)))}function Nz(r,t){if(pe(r)&&pe(t)){var e=!0;return R(t,function(a,i){e=e&&Nz(r[i],a)}),!!e}else return r===t}function VI(r,t){r[t.get(["label","show"])?"show":"hide"]()}function _b(r){return{x:r.x||0,y:r.y||0,rotation:r.rotation||0}}function FI(r,t,e){var a=t.get("z"),i=t.get("zlevel");r&&r.traverse(function(n){n.type!=="group"&&(a!=null&&(n.z=a),i!=null&&(n.zlevel=i),n.silent=e)})}function JT(r){var t=r.get("type"),e=r.getModel(t+"Style"),a;return t==="line"?(a=e.getLineStyle(),a.fill=null):t==="shadow"&&(a=e.getAreaStyle(),a.stroke=null),a}function Oz(r,t,e,a,i){var n=e.get("value"),o=zz(n,t.axis,t.ecModel,e.get("seriesDataIndices"),{precision:e.get(["label","precision"]),formatter:e.get(["label","formatter"])}),s=e.getModel("label"),l=vd(s.get("padding")||0),u=s.getFont(),v=Nh(o,u),c=i.position,d=v.width+l[1]+l[3],f=v.height+l[0]+l[2],h=i.align;h==="right"&&(c[0]-=d),h==="center"&&(c[0]-=d/2);var g=i.verticalAlign;g==="bottom"&&(c[1]-=f),g==="middle"&&(c[1]-=f/2),prt(c,d,f,a);var m=s.get("backgroundColor");(!m||m==="auto")&&(m=t.get(["axisLine","lineStyle","color"])),r.label={x:c[0],y:c[1],style:ya(s,{text:o,font:u,fill:s.getTextColor(),padding:l,backgroundColor:m}),z2:10}}function prt(r,t,e,a){var i=a.getWidth(),n=a.getHeight();r[0]=Math.min(r[0]+t,i)-t,r[1]=Math.min(r[1]+e,n)-e,r[0]=Math.max(r[0],0),r[1]=Math.max(r[1],0)}function zz(r,t,e,a,i){r=t.scale.parse(r);var n=t.scale.getLabel({value:r},{precision:i.precision}),o=i.formatter;if(o){var s={value:uT(t,{value:r}),axisDimension:t.dim,axisIndex:t.index,seriesData:[]};R(a,function(l){var u=e.getSeriesByIndex(l.seriesIndex),v=l.dataIndexInside,c=u&&u.getDataParams(v);c&&s.seriesData.push(c)}),Pt(o)?n=o.replace("{value}",n):le(o)&&(n=o(s))}return n}function tA(r,t,e){var a=pn();return Cv(a,a,e.rotation),ss(a,a,e.position),wo([r.dataToCoord(t),(e.labelOffset||0)+(e.labelDirection||1)*(e.labelMargin||0)],a)}function Bz(r,t,e,a,i,n){var o=zi.innerTextLayout(e.rotation,0,e.labelDirection);e.labelMargin=i.get(["label","margin"]),Oz(t,a,i,n,{position:tA(a.axis,r,e),align:o.textAlign,verticalAlign:o.textVerticalAlign})}function eA(r,t,e){return e=e||0,{x1:r[e],y1:r[1-e],x2:t[e],y2:t[1-e]}}function Vz(r,t,e){return e=e||0,{x:r[e],y:r[1-e],width:t[e],height:t[1-e]}}function GI(r,t,e,a,i,n){return{cx:r,cy:t,r0:e,r:a,startAngle:i,endAngle:n,clockwise:!0}}var grt=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.makeElOption=function(e,a,i,n,o){var s=i.axis,l=s.grid,u=n.get("type"),v=HI(l,s).getOtherAxis(s).getGlobalExtent(),c=s.toGlobalCoord(s.dataToCoord(a,!0));if(u&&u!=="none"){var d=JT(n),f=yrt[u](s,c,v);f.style=d,e.graphicKey=f.type,e.pointer=f}var h=n2(l.model,i);Bz(a,e,h,i,n,o)},t.prototype.getHandleTransform=function(e,a,i){var n=n2(a.axis.grid.model,a,{labelInside:!1});n.labelMargin=i.get(["handle","margin"]);var o=tA(a.axis,e,n);return{x:o[0],y:o[1],rotation:n.rotation+(n.labelDirection<0?Math.PI:0)}},t.prototype.updateHandleTransform=function(e,a,i,n){var o=i.axis,s=o.grid,l=o.getGlobalExtent(!0),u=HI(s,o).getOtherAxis(o).getGlobalExtent(),v=o.dim==="x"?0:1,c=[e.x,e.y];c[v]+=a[v],c[v]=Math.min(l[1],c[v]),c[v]=Math.max(l[0],c[v]);var d=(u[1]+u[0])/2,f=[d,d];f[v]=c[v];var h=[{verticalAlign:"middle"},{align:"center"}];return{x:c[0],y:c[1],rotation:e.rotation,cursorPoint:f,tooltipOption:h[v]}},t}(QT);function HI(r,t){var e={};return e[t.dim+"AxisIndex"]=t.index,r.getCartesian(e)}var yrt={line:function(r,t,e){var a=eA([t,e[0]],[t,e[1]],WI(r));return{type:"Line",subPixelOptimize:!0,shape:a}},shadow:function(r,t,e){var a=Math.max(1,r.getBandWidth()),i=e[1]-e[0];return{type:"Rect",shape:Vz([t-a/2,e[0]],[a,i],WI(r))}}};function WI(r){return r.dim==="x"?0:1}var mrt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="axisPointer",t.defaultOption={show:"auto",z:50,type:"line",snap:!1,triggerTooltip:!0,triggerEmphasis:!0,value:null,status:null,link:[],animation:null,animationDurationUpdate:200,lineStyle:{color:"#B9BEC9",width:1,type:"dashed"},shadowStyle:{color:"rgba(210,219,238,0.2)"},label:{show:!0,formatter:null,precision:"auto",margin:3,color:"#fff",padding:[5,7,5,7],backgroundColor:"auto",borderColor:null,borderWidth:0,borderRadius:3},handle:{show:!1,icon:"M10.7,11.9v-1.3H9.3v1.3c-4.9,0.3-8.8,4.4-8.8,9.4c0,5,3.9,9.1,8.8,9.4h1.3c4.9-0.3,8.8-4.4,8.8-9.4C19.5,16.3,15.6,12.2,10.7,11.9z M13.3,24.4H6.7v-1.2h6.6z M13.3,22H6.7v-1.2h6.6z M13.3,19.6H6.7v-1.2h6.6z",size:45,margin:50,color:"#333",shadowBlur:3,shadowColor:"#aaa",shadowOffsetX:0,shadowOffsetY:2,throttle:40}},t}(_r),Ms=Lr(),_rt=R;function Fz(r,t,e){if(!dr.node){var a=t.getZr();Ms(a).records||(Ms(a).records={}),xrt(a,t);var i=Ms(a).records[r]||(Ms(a).records[r]={});i.handler=e}}function xrt(r,t){if(Ms(r).initialized)return;Ms(r).initialized=!0,e("click",We(UI,"click")),e("mousemove",We(UI,"mousemove")),e("globalout",wrt);function e(a,i){r.on(a,function(n){var o=Srt(t);_rt(Ms(r).records,function(s){s&&i(s,n,o.dispatchAction)}),brt(o.pendings,t)})}}function brt(r,t){var e=r.showTip.length,a=r.hideTip.length,i;e?i=r.showTip[e-1]:a&&(i=r.hideTip[a-1]),i&&(i.dispatchAction=null,t.dispatchAction(i))}function wrt(r,t,e){r.handler("leave",null,e)}function UI(r,t,e,a){t.handler(r,e,a)}function Srt(r){var t={showTip:[],hideTip:[]},e=function(a){var i=t[a.type];i?i.push(a):(a.dispatchAction=e,r.dispatchAction(a))};return{dispatchAction:e,pendings:t}}function C2(r,t){if(!dr.node){var e=t.getZr(),a=(Ms(e).records||{})[r];a&&(Ms(e).records[r]=null)}}var Trt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){var n=a.getComponent("tooltip"),o=e.get("triggerOn")||n&&n.get("triggerOn")||"mousemove|click";Fz("axisPointer",i,function(s,l,u){o!=="none"&&(s==="leave"||o.indexOf(s)>=0)&&u({type:"updateAxisPointer",currTrigger:s,x:l&&l.offsetX,y:l&&l.offsetY})})},t.prototype.remove=function(e,a){C2("axisPointer",a)},t.prototype.dispose=function(e,a){C2("axisPointer",a)},t.type="axisPointer",t}(Ma);function Gz(r,t){var e=[],a=r.seriesIndex,i;if(a==null||!(i=t.getSeriesByIndex(a)))return{point:[]};var n=i.getData(),o=gv(n,r);if(o==null||o<0||ht(o))return{point:[]};var s=n.getItemGraphicEl(o),l=i.coordinateSystem;if(i.getTooltipPosition)e=i.getTooltipPosition(o)||[];else if(l&&l.dataToPoint)if(r.isStacked){var u=l.getBaseAxis(),v=l.getOtherAxis(u),c=v.dim,d=u.dim,f=c==="x"||c==="radius"?1:0,h=n.mapDimension(d),g=[];g[f]=n.get(h,o),g[1-f]=n.get(n.getCalculationInfo("stackResultDimension"),o),e=l.dataToPoint(g)||[]}else e=l.dataToPoint(n.getValues(pt(l.dimensions,function(x){return n.mapDimension(x)}),o))||[];else if(s){var m=s.getBoundingRect().clone();m.applyTransform(s.transform),e=[m.x+m.width/2,m.y+m.height/2]}return{point:e,el:s}}var qI=Lr();function Art(r,t,e){var a=r.currTrigger,i=[r.x,r.y],n=r,o=r.dispatchAction||Nt(e.dispatchAction,e),s=t.getComponent("axisPointer").coordSysAxesInfo;if(s){Vy(i)&&(i=Gz({seriesIndex:n.seriesIndex,dataIndex:n.dataIndex},t).point);var l=Vy(i),u=n.axesInfo,v=s.axesInfo,c=a==="leave"||Vy(i),d={},f={},h={list:[],map:{}},g={showPointer:We(krt,f),showTooltip:We(Drt,h)};R(s.coordSysMap,function(x,b){var w=l||x.containPoint(i);R(s.coordSysAxesInfo[b],function(S,C){var T=S.axis,k=Rrt(u,S);if(!c&&w&&(!u||k)){var D=k&&k.value;D==null&&!l&&(D=T.pointToData(i)),D!=null&&$I(S,D,g,!1,d)}})});var m={};return R(v,function(x,b){var w=x.linkGroup;w&&!f[b]&&R(w.axesInfo,function(S,C){var T=f[C];if(S!==x&&T){var k=T.value;w.mapper&&(k=x.axis.scale.parse(w.mapper(k,YI(S),YI(x)))),m[x.key]=k}})}),R(m,function(x,b){$I(v[b],x,g,!0,d)}),Mrt(f,v,d),Lrt(h,i,r,o),Irt(v,o,e),d}}function $I(r,t,e,a,i){var n=r.axis;if(!(n.scale.isBlank()||!n.containData(t))){if(!r.involveSeries){e.showPointer(r,t);return}var o=Crt(t,r),s=o.payloadBatch,l=o.snapToValue;s[0]&&i.seriesIndex==null&<(i,s[0]),!a&&r.snap&&n.containData(l)&&l!=null&&(t=l),e.showPointer(r,t,s),e.showTooltip(r,o,l)}}function Crt(r,t){var e=t.axis,a=e.dim,i=r,n=[],o=Number.MAX_VALUE,s=-1;return R(t.seriesModels,function(l,u){var v=l.getData().mapDimensionsAll(a),c,d;if(l.getAxisTooltipData){var f=l.getAxisTooltipData(v,r,e);d=f.dataIndices,c=f.nestestValue}else{if(d=l.getData().indicesOfNearest(v[0],r,e.type==="category"?.5:null),!d.length)return;c=l.getData().get(v[0],d[0])}if(!(c==null||!isFinite(c))){var h=r-c,g=Math.abs(h);g<=o&&((g=0&&s<0)&&(o=g,s=h,i=c,n.length=0),R(d,function(m){n.push({seriesIndex:l.seriesIndex,dataIndexInside:m,dataIndex:l.getData().getRawIndex(m)})}))}}),{payloadBatch:n,snapToValue:i}}function krt(r,t,e,a){r[t.key]={value:e,payloadBatch:a}}function Drt(r,t,e,a){var i=e.payloadBatch,n=t.axis,o=n.model,s=t.axisPointerModel;if(!(!t.triggerTooltip||!i.length)){var l=t.coordSys.model,u=xh(l),v=r.map[u];v||(v=r.map[u]={coordSysId:l.id,coordSysIndex:l.componentIndex,coordSysType:l.type,coordSysMainType:l.mainType,dataByAxis:[]},r.list.push(v)),v.dataByAxis.push({axisDim:n.dim,axisIndex:o.componentIndex,axisType:o.type,axisId:o.id,value:a,valueLabelOpt:{precision:s.get(["label","precision"]),formatter:s.get(["label","formatter"])},seriesDataIndices:i.slice()})}}function Mrt(r,t,e){var a=e.axesInfo=[];R(t,function(i,n){var o=i.axisPointerModel.option,s=r[n];s?(!i.useHandle&&(o.status="show"),o.value=s.value,o.seriesDataIndices=(s.payloadBatch||[]).slice()):!i.useHandle&&(o.status="hide"),o.status==="show"&&a.push({axisDim:i.axis.dim,axisIndex:i.axis.model.componentIndex,value:o.value})})}function Lrt(r,t,e,a){if(Vy(t)||!r.list.length){a({type:"hideTip"});return}var i=((r.list[0].dataByAxis[0]||{}).seriesDataIndices||[])[0]||{};a({type:"showTip",escapeConnect:!0,x:t[0],y:t[1],tooltipOption:e.tooltipOption,position:e.position,dataIndexInside:i.dataIndexInside,dataIndex:i.dataIndex,seriesIndex:i.seriesIndex,dataByCoordSys:r.list})}function Irt(r,t,e){var a=e.getZr(),i="axisPointerLastHighlights",n=qI(a)[i]||{},o=qI(a)[i]={};R(r,function(u,v){var c=u.axisPointerModel.option;c.status==="show"&&u.triggerEmphasis&&R(c.seriesDataIndices,function(d){var f=d.seriesIndex+" | "+d.dataIndex;o[f]=d})});var s=[],l=[];R(n,function(u,v){!o[v]&&l.push(u)}),R(o,function(u,v){!n[v]&&s.push(u)}),l.length&&e.dispatchAction({type:"downplay",escapeConnect:!0,notBlur:!0,batch:l}),s.length&&e.dispatchAction({type:"highlight",escapeConnect:!0,notBlur:!0,batch:s})}function Rrt(r,t){for(var e=0;e<(r||[]).length;e++){var a=r[e];if(t.axis.dim===a.axisDim&&t.axis.model.componentIndex===a.axisIndex)return a}}function YI(r){var t=r.axis.model,e={},a=e.axisDim=r.axis.dim;return e.axisIndex=e[a+"AxisIndex"]=t.componentIndex,e.axisName=e[a+"AxisName"]=t.name,e.axisId=e[a+"AxisId"]=t.id,e}function Vy(r){return!r||r[0]==null||isNaN(r[0])||r[1]==null||isNaN(r[1])}function ep(r){Rv.registerAxisPointerClass("CartesianAxisPointer",grt),r.registerComponentModel(mrt),r.registerComponentView(Trt),r.registerPreprocessor(function(t){if(t){(!t.axisPointer||t.axisPointer.length===0)&&(t.axisPointer={});var e=t.axisPointer.link;e&&!ht(e)&&(t.axisPointer.link=[e])}}),r.registerProcessor(r.PRIORITY.PROCESSOR.STATISTIC,function(t,e){t.getComponent("axisPointer").coordSysAxesInfo=FX(t,e)}),r.registerAction({type:"updateAxisPointer",event:"updateAxisPointer",update:":updateAxisPointer"},Art)}function Prt(r){sr(pO),sr(ep)}var Ert=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.makeElOption=function(e,a,i,n,o){var s=i.axis;s.dim==="angle"&&(this.animationThreshold=Math.PI/18);var l=s.polar,u=l.getOtherAxis(s),v=u.getExtent(),c=s.dataToCoord(a),d=n.get("type");if(d&&d!=="none"){var f=JT(n),h=Ort[d](s,l,c,v);h.style=f,e.graphicKey=h.type,e.pointer=h}var g=n.get(["label","margin"]),m=Nrt(a,i,n,l,g);Oz(e,i,n,o,m)},t}(QT);function Nrt(r,t,e,a,i){var n=t.axis,o=n.dataToCoord(r),s=a.getAngleAxis().getExtent()[0];s=s/180*Math.PI;var l=a.getRadiusAxis().getExtent(),u,v,c;if(n.dim==="radius"){var d=pn();Cv(d,d,s),ss(d,d,[a.cx,a.cy]),u=wo([o,-i],d);var f=t.getModel("axisLabel").get("rotate")||0,h=zi.innerTextLayout(s,f*Math.PI/180,-1);v=h.textAlign,c=h.textVerticalAlign}else{var g=l[1];u=a.coordToPoint([g+i,o]);var m=a.cx,x=a.cy;v=Math.abs(u[0]-m)/g<.3?"center":u[0]>m?"left":"right",c=Math.abs(u[1]-x)/g<.3?"middle":u[1]>x?"top":"bottom"}return{position:u,align:v,verticalAlign:c}}var Ort={line:function(r,t,e,a){return r.dim==="angle"?{type:"Line",shape:eA(t.coordToPoint([a[0],e]),t.coordToPoint([a[1],e]))}:{type:"Circle",shape:{cx:t.cx,cy:t.cy,r:e}}},shadow:function(r,t,e,a){var i=Math.max(1,r.getBandWidth()),n=Math.PI/180;return r.dim==="angle"?{type:"Sector",shape:GI(t.cx,t.cy,a[0],a[1],(-e-i/2)*n,(-e+i/2)*n)}:{type:"Sector",shape:GI(t.cx,t.cy,e-i/2,e+i/2,0,Math.PI*2)}}},zrt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.findAxisModel=function(e){var a,i=this.ecModel;return i.eachComponent(e,function(n){n.getCoordSysModel()===this&&(a=n)},this),a},t.type="polar",t.dependencies=["radiusAxis","angleAxis"],t.defaultOption={z:0,center:["50%","50%"],radius:"80%"},t}(_r),rA=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.getCoordSysModel=function(){return this.getReferringComponents("polar",Ua).models[0]},t.type="polarAxis",t}(_r);$a(rA,$h);var Brt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="angleAxis",t}(rA),Vrt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="radiusAxis",t}(rA),aA=function(r){tt(t,r);function t(e,a){return r.call(this,"radius",e,a)||this}return t.prototype.pointToData=function(e,a){return this.polar.pointToData(e,a)[this.dim==="radius"?0:1]},t}(Do);aA.prototype.dataToRadius=Do.prototype.dataToCoord;aA.prototype.radiusToData=Do.prototype.coordToData;var Frt=Lr(),iA=function(r){tt(t,r);function t(e,a){return r.call(this,"angle",e,a||[0,360])||this}return t.prototype.pointToData=function(e,a){return this.polar.pointToData(e,a)[this.dim==="radius"?0:1]},t.prototype.calculateCategoryInterval=function(){var e=this,a=e.getLabelModel(),i=e.scale,n=i.getExtent(),o=i.count();if(n[1]-n[0]<1)return 0;var s=n[0],l=e.dataToCoord(s+1)-e.dataToCoord(s),u=Math.abs(l),v=Nh(s==null?"":s+"",a.getFont(),"center","top"),c=Math.max(v.height,7),d=c/u;isNaN(d)&&(d=1/0);var f=Math.max(0,Math.floor(d)),h=Frt(e.model),g=h.lastAutoInterval,m=h.lastTickCount;return g!=null&&m!=null&&Math.abs(g-f)<=1&&Math.abs(m-o)<=1&&g>f?f=g:(h.lastTickCount=o,h.lastAutoInterval=f),f},t}(Do);iA.prototype.dataToAngle=Do.prototype.dataToCoord;iA.prototype.angleToData=Do.prototype.coordToData;var Hz=["radius","angle"],Grt=function(){function r(t){this.dimensions=Hz,this.type="polar",this.cx=0,this.cy=0,this._radiusAxis=new aA,this._angleAxis=new iA,this.axisPointerEnabled=!0,this.name=t||"",this._radiusAxis.polar=this._angleAxis.polar=this}return r.prototype.containPoint=function(t){var e=this.pointToCoord(t);return this._radiusAxis.contain(e[0])&&this._angleAxis.contain(e[1])},r.prototype.containData=function(t){return this._radiusAxis.containData(t[0])&&this._angleAxis.containData(t[1])},r.prototype.getAxis=function(t){var e="_"+t+"Axis";return this[e]},r.prototype.getAxes=function(){return[this._radiusAxis,this._angleAxis]},r.prototype.getAxesByScale=function(t){var e=[],a=this._angleAxis,i=this._radiusAxis;return a.scale.type===t&&e.push(a),i.scale.type===t&&e.push(i),e},r.prototype.getAngleAxis=function(){return this._angleAxis},r.prototype.getRadiusAxis=function(){return this._radiusAxis},r.prototype.getOtherAxis=function(t){var e=this._angleAxis;return t===e?this._radiusAxis:e},r.prototype.getBaseAxis=function(){return this.getAxesByScale("ordinal")[0]||this.getAxesByScale("time")[0]||this.getAngleAxis()},r.prototype.getTooltipAxes=function(t){var e=t!=null&&t!=="auto"?this.getAxis(t):this.getBaseAxis();return{baseAxes:[e],otherAxes:[this.getOtherAxis(e)]}},r.prototype.dataToPoint=function(t,e){return this.coordToPoint([this._radiusAxis.dataToRadius(t[0],e),this._angleAxis.dataToAngle(t[1],e)])},r.prototype.pointToData=function(t,e){var a=this.pointToCoord(t);return[this._radiusAxis.radiusToData(a[0],e),this._angleAxis.angleToData(a[1],e)]},r.prototype.pointToCoord=function(t){var e=t[0]-this.cx,a=t[1]-this.cy,i=this.getAngleAxis(),n=i.getExtent(),o=Math.min(n[0],n[1]),s=Math.max(n[0],n[1]);i.inverse?o=s-360:s=o+360;var l=Math.sqrt(e*e+a*a);e/=l,a/=l;for(var u=Math.atan2(-a,e)/Math.PI*180,v=us;)u+=v*360;return[l,u]},r.prototype.coordToPoint=function(t){var e=t[0],a=t[1]/180*Math.PI,i=Math.cos(a)*e+this.cx,n=-Math.sin(a)*e+this.cy;return[i,n]},r.prototype.getArea=function(){var t=this.getAngleAxis(),e=this.getRadiusAxis(),a=e.getExtent().slice();a[0]>a[1]&&a.reverse();var i=t.getExtent(),n=Math.PI/180,o=1e-4;return{cx:this.cx,cy:this.cy,r0:a[0],r:a[1],startAngle:-i[0]*n,endAngle:-i[1]*n,clockwise:t.inverse,contain:function(s,l){var u=s-this.cx,v=l-this.cy,c=u*u+v*v,d=this.r,f=this.r0;return d!==f&&c-o<=d*d&&c+o>=f*f}}},r.prototype.convertToPixel=function(t,e,a){var i=ZI(e);return i===this?this.dataToPoint(a):null},r.prototype.convertFromPixel=function(t,e,a){var i=ZI(e);return i===this?this.pointToData(a):null},r}();function ZI(r){var t=r.seriesModel,e=r.polarModel;return e&&e.coordinateSystem||t&&t.coordinateSystem}function Hrt(r,t,e){var a=t.get("center"),i=e.getWidth(),n=e.getHeight();r.cx=Lt(a[0],i),r.cy=Lt(a[1],n);var o=r.getRadiusAxis(),s=Math.min(i,n)/2,l=t.get("radius");l==null?l=[0,"100%"]:ht(l)||(l=[0,l]);var u=[Lt(l[0],s),Lt(l[1],s)];o.inverse?o.setExtent(u[1],u[0]):o.setExtent(u[0],u[1])}function Wrt(r,t){var e=this,a=e.getAngleAxis(),i=e.getRadiusAxis();if(a.scale.setExtent(1/0,-1/0),i.scale.setExtent(1/0,-1/0),r.eachSeries(function(s){if(s.coordinateSystem===e){var l=s.getData();R(pm(l,"radius"),function(u){i.scale.unionExtentFromData(l,u)}),R(pm(l,"angle"),function(u){a.scale.unionExtentFromData(l,u)})}}),Xc(a.scale,a.model),Xc(i.scale,i.model),a.type==="category"&&!a.onBand){var n=a.getExtent(),o=360/a.scale.count();a.inverse?n[1]+=o:n[1]-=o,a.setExtent(n[0],n[1])}}function Urt(r){return r.mainType==="angleAxis"}function XI(r,t){var e;if(r.type=t.get("type"),r.scale=I_(t),r.onBand=t.get("boundaryGap")&&r.type==="category",r.inverse=t.get("inverse"),Urt(t)){r.inverse=r.inverse!==t.get("clockwise");var a=t.get("startAngle"),i=(e=t.get("endAngle"))!==null&&e!==void 0?e:a+(r.inverse?-360:360);r.setExtent(a,i)}t.axis=r,r.model=t}var qrt={dimensions:Hz,create:function(r,t){var e=[];return r.eachComponent("polar",function(a,i){var n=new Grt(i+"");n.update=Wrt;var o=n.getRadiusAxis(),s=n.getAngleAxis(),l=a.findAxisModel("radiusAxis"),u=a.findAxisModel("angleAxis");XI(o,l),XI(s,u),Hrt(n,a,t),e.push(n),a.coordinateSystem=n,n.model=a}),r.eachSeries(function(a){if(a.get("coordinateSystem")==="polar"){var i=a.getReferringComponents("polar",Ua).models[0];a.coordinateSystem=i.coordinateSystem}}),e}},$rt=["axisLine","axisLabel","axisTick","minorTick","splitLine","minorSplitLine","splitArea"];function sy(r,t,e){t[1]>t[0]&&(t=t.slice().reverse());var a=r.coordToPoint([t[0],e]),i=r.coordToPoint([t[1],e]);return{x1:a[0],y1:a[1],x2:i[0],y2:i[1]}}function ly(r){var t=r.getRadiusAxis();return t.inverse?0:1}function jI(r){var t=r[0],e=r[r.length-1];t&&e&&Math.abs(Math.abs(t.coord-e.coord)-360)<1e-4&&r.pop()}var Yrt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.axisPointerClass="PolarAxisPointer",e}return t.prototype.render=function(e,a){if(this.group.removeAll(),!!e.get("show")){var i=e.axis,n=i.polar,o=n.getRadiusAxis().getExtent(),s=i.getTicksCoords(),l=i.getMinorTicksCoords(),u=pt(i.getViewLabels(),function(v){v=be(v);var c=i.scale,d=c.type==="ordinal"?c.getRawOrdinalNumber(v.tickValue):v.tickValue;return v.coord=i.dataToCoord(d),v});jI(u),jI(s),R($rt,function(v){e.get([v,"show"])&&(!i.scale.isBlank()||v==="axisLine")&&Zrt[v](this.group,e,n,s,l,o,u)},this)}},t.type="angleAxis",t}(Rv),Zrt={axisLine:function(r,t,e,a,i,n){var o=t.getModel(["axisLine","lineStyle"]),s=e.getAngleAxis(),l=Math.PI/180,u=s.getExtent(),v=ly(e),c=v?0:1,d,f=Math.abs(u[1]-u[0])===360?"Circle":"Arc";n[c]===0?d=new Dv[f]({shape:{cx:e.cx,cy:e.cy,r:n[v],startAngle:-u[0]*l,endAngle:-u[1]*l,clockwise:s.inverse},style:o.getLineStyle(),z2:1,silent:!0}):d=new Bh({shape:{cx:e.cx,cy:e.cy,r:n[v],r0:n[c]},style:o.getLineStyle(),z2:1,silent:!0}),d.style.fill=null,r.add(d)},axisTick:function(r,t,e,a,i,n){var o=t.getModel("axisTick"),s=(o.get("inside")?-1:1)*o.get("length"),l=n[ly(e)],u=pt(a,function(v){return new Ja({shape:sy(e,[l,l+s],v.coord)})});r.add(Vn(u,{style:ve(o.getModel("lineStyle").getLineStyle(),{stroke:t.get(["axisLine","lineStyle","color"])})}))},minorTick:function(r,t,e,a,i,n){if(i.length){for(var o=t.getModel("axisTick"),s=t.getModel("minorTick"),l=(o.get("inside")?-1:1)*s.get("length"),u=n[ly(e)],v=[],c=0;cx?"left":"right",S=Math.abs(m[1]-b)/g<.3?"middle":m[1]>b?"top":"bottom";if(s&&s[h]){var C=s[h];pe(C)&&C.textStyle&&(f=new ia(C.textStyle,l,l.ecModel))}var T=new Rr({silent:zi.isLabelSilent(t),style:ya(f,{x:m[0],y:m[1],fill:f.getTextColor()||t.get(["axisLine","lineStyle","color"]),text:c.formattedLabel,align:w,verticalAlign:S})});if(r.add(T),v){var k=zi.makeAxisEventDataBase(t);k.targetType="axisLabel",k.value=c.rawLabel,Ie(T).eventData=k}},this)},splitLine:function(r,t,e,a,i,n){var o=t.getModel("splitLine"),s=o.getModel("lineStyle"),l=s.get("color"),u=0;l=l instanceof Array?l:[l];for(var v=[],c=0;c=0?"p":"n",O=M;C&&(a[v][E]||(a[v][E]={p:M,n:M}),O=a[v][E][N]);var V=void 0,B=void 0,z=void 0,H=void 0;if(h.dim==="radius"){var Y=h.dataToCoord(P)-M,$=l.dataToCoord(E);Math.abs(Y)=H})}}})}function eat(r){var t={};R(r,function(a,i){var n=a.getData(),o=a.coordinateSystem,s=o.getBaseAxis(),l=Uz(o,s),u=s.getExtent(),v=s.type==="category"?s.getBandWidth():Math.abs(u[1]-u[0])/n.count(),c=t[l]||{bandWidth:v,remainedWidth:v,autoWidthCount:0,categoryGap:"20%",gap:"30%",stacks:{}},d=c.stacks;t[l]=c;var f=Wz(a);d[f]||c.autoWidthCount++,d[f]=d[f]||{width:0,maxWidth:0};var h=Lt(a.get("barWidth"),v),g=Lt(a.get("barMaxWidth"),v),m=a.get("barGap"),x=a.get("barCategoryGap");h&&!d[f].width&&(h=Math.min(c.remainedWidth,h),d[f].width=h,c.remainedWidth-=h),g&&(d[f].maxWidth=g),m!=null&&(c.gap=m),x!=null&&(c.categoryGap=x)});var e={};return R(t,function(a,i){e[i]={};var n=a.stacks,o=a.bandWidth,s=Lt(a.categoryGap,o),l=Lt(a.gap,1),u=a.remainedWidth,v=a.autoWidthCount,c=(u-s)/(v+(v-1)*l);c=Math.max(c,0),R(n,function(g,m){var x=g.maxWidth;x&&x=e.y&&t[1]<=e.y+e.height:a.contain(a.toLocalCoord(t[1]))&&t[0]>=e.y&&t[0]<=e.y+e.height},r.prototype.pointToData=function(t){var e=this.getAxis();return[e.coordToData(e.toLocalCoord(t[e.orient==="horizontal"?0:1]))]},r.prototype.dataToPoint=function(t){var e=this.getAxis(),a=this.getRect(),i=[],n=e.orient==="horizontal"?0:1;return t instanceof Array&&(t=t[0]),i[n]=e.toGlobalCoord(e.dataToCoord(+t)),i[1-n]=n===0?a.y+a.height/2:a.x+a.width/2,i},r.prototype.convertToPixel=function(t,e,a){var i=KI(e);return i===this?this.dataToPoint(a):null},r.prototype.convertFromPixel=function(t,e,a){var i=KI(e);return i===this?this.pointToData(a):null},r}();function KI(r){var t=r.seriesModel,e=r.singleAxisModel;return e&&e.coordinateSystem||t&&t.coordinateSystem}function dat(r,t){var e=[];return r.eachComponent("singleAxis",function(a,i){var n=new cat(a,r,t);n.name="single_"+i,n.resize(a,t),a.coordinateSystem=n,e.push(n)}),r.eachSeries(function(a){if(a.get("coordinateSystem")==="singleAxis"){var i=a.getReferringComponents("singleAxis",Ua).models[0];a.coordinateSystem=i&&i.coordinateSystem}}),e}var fat={create:dat,dimensions:qz},QI=["x","y"],hat=["width","height"],pat=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.makeElOption=function(e,a,i,n,o){var s=i.axis,l=s.coordinateSystem,u=xb(l,1-Mm(s)),v=l.dataToPoint(a)[0],c=n.get("type");if(c&&c!=="none"){var d=JT(n),f=gat[c](s,v,u);f.style=d,e.graphicKey=f.type,e.pointer=f}var h=k2(i);Bz(a,e,h,i,n,o)},t.prototype.getHandleTransform=function(e,a,i){var n=k2(a,{labelInside:!1});n.labelMargin=i.get(["handle","margin"]);var o=tA(a.axis,e,n);return{x:o[0],y:o[1],rotation:n.rotation+(n.labelDirection<0?Math.PI:0)}},t.prototype.updateHandleTransform=function(e,a,i,n){var o=i.axis,s=o.coordinateSystem,l=Mm(o),u=xb(s,l),v=[e.x,e.y];v[l]+=a[l],v[l]=Math.min(u[1],v[l]),v[l]=Math.max(u[0],v[l]);var c=xb(s,1-l),d=(c[1]+c[0])/2,f=[d,d];return f[l]=v[l],{x:v[0],y:v[1],rotation:e.rotation,cursorPoint:f,tooltipOption:{verticalAlign:"middle"}}},t}(QT),gat={line:function(r,t,e){var a=eA([t,e[0]],[t,e[1]],Mm(r));return{type:"Line",subPixelOptimize:!0,shape:a}},shadow:function(r,t,e){var a=r.getBandWidth(),i=e[1]-e[0];return{type:"Rect",shape:Vz([t-a/2,e[0]],[a,i],Mm(r))}}};function Mm(r){return r.isHorizontal()?0:1}function xb(r,t){var e=r.getRect();return[e[QI[t]],e[QI[t]]+e[hat[t]]]}var yat=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="single",t}(Ma);function mat(r){sr(ep),Rv.registerAxisPointerClass("SingleAxisPointer",pat),r.registerComponentView(yat),r.registerComponentView(lat),r.registerComponentModel(Fy),Kc(r,"single",Fy,Fy.defaultOption),r.registerCoordinateSystem("single",fat)}var _at=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.init=function(e,a,i){var n=cd(e);r.prototype.init.apply(this,arguments),JI(e,n)},t.prototype.mergeOption=function(e){r.prototype.mergeOption.apply(this,arguments),JI(this.option,e)},t.prototype.getCellSize=function(){return this.option.cellSize},t.type="calendar",t.defaultOption={z:2,left:80,top:60,cellSize:20,orient:"horizontal",splitLine:{show:!0,lineStyle:{color:"#000",width:1,type:"solid"}},itemStyle:{color:"#fff",borderWidth:1,borderColor:"#ccc"},dayLabel:{show:!0,firstDay:0,position:"start",margin:"50%",color:"#000"},monthLabel:{show:!0,position:"start",margin:5,align:"center",formatter:null,color:"#000"},yearLabel:{show:!0,position:null,margin:30,formatter:null,color:"#ccc",fontFamily:"sans-serif",fontWeight:"bolder",fontSize:20}},t}(_r);function JI(r,t){var e=r.cellSize,a;ht(e)?a=e:a=r.cellSize=[e,e],a.length===1&&(a[1]=a[0]);var i=pt([0,1],function(n){return zW(t,n)&&(a[n]="auto"),a[n]!=null&&a[n]!=="auto"});zl(r,t,{type:"box",ignoreSize:i})}var xat=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){var n=this.group;n.removeAll();var o=e.coordinateSystem,s=o.getRangeInfo(),l=o.getOrient(),u=a.getLocaleModel();this._renderDayRect(e,s,n),this._renderLines(e,s,l,n),this._renderYearText(e,s,l,n),this._renderMonthText(e,u,l,n),this._renderWeekText(e,u,s,l,n)},t.prototype._renderDayRect=function(e,a,i){for(var n=e.coordinateSystem,o=e.getModel("itemStyle").getItemStyle(),s=n.getCellWidth(),l=n.getCellHeight(),u=a.start.time;u<=a.end.time;u=n.getNextNDay(u,1).time){var v=n.dataToRect([u],!1).tl,c=new Mr({shape:{x:v[0],y:v[1],width:s,height:l},cursor:"default",style:o});i.add(c)}},t.prototype._renderLines=function(e,a,i,n){var o=this,s=e.coordinateSystem,l=e.getModel(["splitLine","lineStyle"]).getLineStyle(),u=e.get(["splitLine","show"]),v=l.lineWidth;this._tlpoints=[],this._blpoints=[],this._firstDayOfMonth=[],this._firstDayPoints=[];for(var c=a.start,d=0;c.time<=a.end.time;d++){h(c.formatedDate),d===0&&(c=s.getDateInfo(a.start.y+"-"+a.start.m));var f=c.date;f.setMonth(f.getMonth()+1),c=s.getDateInfo(f)}h(s.getNextNDay(a.end.time,1).formatedDate);function h(g){o._firstDayOfMonth.push(s.getDateInfo(g)),o._firstDayPoints.push(s.dataToRect([g],!1).tl);var m=o._getLinePointsOfOneWeek(e,g,i);o._tlpoints.push(m[0]),o._blpoints.push(m[m.length-1]),u&&o._drawSplitline(m,l,n)}u&&this._drawSplitline(o._getEdgesPoints(o._tlpoints,v,i),l,n),u&&this._drawSplitline(o._getEdgesPoints(o._blpoints,v,i),l,n)},t.prototype._getEdgesPoints=function(e,a,i){var n=[e[0].slice(),e[e.length-1].slice()],o=i==="horizontal"?0:1;return n[0][o]=n[0][o]-a/2,n[1][o]=n[1][o]+a/2,n},t.prototype._drawSplitline=function(e,a,i){var n=new Ui({z2:20,shape:{points:e},style:a});i.add(n)},t.prototype._getLinePointsOfOneWeek=function(e,a,i){for(var n=e.coordinateSystem,o=n.getDateInfo(a),s=[],l=0;l<7;l++){var u=n.getNextNDay(o.time,l),v=n.dataToRect([u.time],!1);s[2*u.day]=v.tl,s[2*u.day+1]=v[i==="horizontal"?"bl":"tr"]}return s},t.prototype._formatterLabel=function(e,a){return Pt(e)&&e?EW(e,a):le(e)?e(a):a.nameMap},t.prototype._yearTextPositionControl=function(e,a,i,n,o){var s=a[0],l=a[1],u=["center","bottom"];n==="bottom"?(l+=o,u=["center","top"]):n==="left"?s-=o:n==="right"?(s+=o,u=["center","top"]):l-=o;var v=0;return(n==="left"||n==="right")&&(v=Math.PI/2),{rotation:v,x:s,y:l,style:{align:u[0],verticalAlign:u[1]}}},t.prototype._renderYearText=function(e,a,i,n){var o=e.getModel("yearLabel");if(o.get("show")){var s=o.get("margin"),l=o.get("position");l||(l=i!=="horizontal"?"top":"left");var u=[this._tlpoints[this._tlpoints.length-1],this._blpoints[0]],v=(u[0][0]+u[1][0])/2,c=(u[0][1]+u[1][1])/2,d=i==="horizontal"?0:1,f={top:[v,u[d][1]],bottom:[v,u[1-d][1]],left:[u[1-d][0],c],right:[u[d][0],c]},h=a.start.y;+a.end.y>+a.start.y&&(h=h+"-"+a.end.y);var g=o.get("formatter"),m={start:a.start.y,end:a.end.y,nameMap:h},x=this._formatterLabel(g,m),b=new Rr({z2:30,style:ya(o,{text:x}),silent:o.get("silent")});b.attr(this._yearTextPositionControl(b,f[l],i,l,s)),n.add(b)}},t.prototype._monthTextPositionControl=function(e,a,i,n,o){var s="left",l="top",u=e[0],v=e[1];return i==="horizontal"?(v=v+o,a&&(s="center"),n==="start"&&(l="bottom")):(u=u+o,a&&(l="middle"),n==="start"&&(s="right")),{x:u,y:v,align:s,verticalAlign:l}},t.prototype._renderMonthText=function(e,a,i,n){var o=e.getModel("monthLabel");if(o.get("show")){var s=o.get("nameMap"),l=o.get("margin"),u=o.get("position"),v=o.get("align"),c=[this._tlpoints,this._blpoints];(!s||Pt(s))&&(s&&(a=Nw(s)||a),s=a.get(["time","monthAbbr"])||[]);var d=u==="start"?0:1,f=i==="horizontal"?0:1;l=u==="start"?-l:l;for(var h=v==="center",g=o.get("silent"),m=0;m=i.start.time&&a.times.end.time&&e.reverse(),e},r.prototype._getRangeInfo=function(t){var e=[this.getDateInfo(t[0]),this.getDateInfo(t[1])],a;e[0].time>e[1].time&&(a=!0,e.reverse());var i=Math.floor(e[1].time/bb)-Math.floor(e[0].time/bb)+1,n=new Date(e[0].time),o=n.getDate(),s=e[1].date.getDate();n.setDate(o+i-1);var l=n.getDate();if(l!==s)for(var u=n.getTime()-e[1].time>0?1:-1;(l=n.getDate())!==s&&(n.getTime()-e[1].time)*u>0;)i-=u,n.setDate(l-u);var v=Math.floor((i+e[0].day+6)/7),c=a?-v+1:v-1;return a&&e.reverse(),{range:[e[0].formatedDate,e[1].formatedDate],start:e[0],end:e[1],allDay:i,weeks:v,nthWeek:c,fweek:e[0].day,lweek:e[1].day}},r.prototype._getDateByWeeksAndDay=function(t,e,a){var i=this._getRangeInfo(a);if(t>i.weeks||t===0&&ei.lweek)return null;var n=(t-1)*7-i.fweek+e,o=new Date(i.start.time);return o.setDate(+i.start.d+n),this.getDateInfo(o)},r.create=function(t,e){var a=[];return t.eachComponent("calendar",function(i){var n=new r(i);a.push(n),i.coordinateSystem=n}),t.eachSeries(function(i){i.get("coordinateSystem")==="calendar"&&(i.coordinateSystem=a[i.get("calendarIndex")||0])}),a},r.dimensions=["time","value"],r}();function tR(r){var t=r.calendarModel,e=r.seriesModel,a=t?t.coordinateSystem:e?e.coordinateSystem:null;return a}function wat(r){r.registerComponentModel(_at),r.registerComponentView(xat),r.registerCoordinateSystem("calendar",bat)}function Sat(r,t){var e=r.existing;if(t.id=r.keyInfo.id,!t.type&&e&&(t.type=e.type),t.parentId==null){var a=t.parentOption;a?t.parentId=a.id:e&&(t.parentId=e.parentId)}t.parentOption=null}function eR(r,t){var e;return R(t,function(a){r[a]!=null&&r[a]!=="auto"&&(e=!0)}),e}function Tat(r,t,e){var a=lt({},e),i=r[t],n=e.$action||"merge";n==="merge"?i?(Ze(i,a,!0),zl(i,a,{ignoreSize:!0}),z5(e,i),uy(e,i),uy(e,i,"shape"),uy(e,i,"style"),uy(e,i,"extra"),e.clipPath=i.clipPath):r[t]=a:n==="replace"?r[t]=a:n==="remove"&&i&&(r[t]=null)}var $z=["transition","enterFrom","leaveTo"],Aat=$z.concat(["enterAnimation","updateAnimation","leaveAnimation"]);function uy(r,t,e){if(e&&(!r[e]&&t[e]&&(r[e]={}),r=r[e],t=t[e]),!(!r||!t))for(var a=e?$z:Aat,i=0;i=0;v--){var c=i[v],d=Za(c.id,null),f=d!=null?o.get(d):null;if(f){var h=f.parent,x=En(h),b=h===n?{width:s,height:l}:{width:x.width,height:x.height},w={},S=w_(f,c,b,null,{hv:c.hv,boundingMode:c.bounding},w);if(!En(f).isNew&&S){for(var C=c.transition,T={},k=0;k=0)?T[D]=M:f[D]=M}Gr(f,T,e,0)}else f.attr(w)}}},t.prototype._clear=function(){var e=this,a=this._elMap;a.each(function(i){Gy(i,En(i).option,a,e._lastGraphicModel)}),this._elMap=Wt()},t.prototype.dispose=function(){this._clear()},t.type="graphic",t}(Ma);function D2(r){var t=Vt(rR,r)?rR[r]:OS(r),e=new t({});return En(e).type=r,e}function aR(r,t,e,a){var i=D2(e);return t.add(i),a.set(r,i),En(i).id=r,En(i).isNew=!0,i}function Gy(r,t,e,a){var i=r&&r.parent;i&&(r.type==="group"&&r.traverse(function(n){Gy(n,t,e,a)}),B_(r,t,a),e.removeKey(En(r).id))}function iR(r,t,e,a){r.isGroup||R([["cursor",jn.prototype.cursor],["zlevel",a||0],["z",e||0],["z2",0]],function(i){var n=i[0];Vt(t,n)?r[n]=Be(t[n],i[1]):r[n]==null&&(r[n]=i[1])}),R(xr(t),function(i){if(i.indexOf("on")===0){var n=t[i];r[i]=le(n)?n:null}}),Vt(t,"draggable")&&(r.draggable=t.draggable),t.name!=null&&(r.name=t.name),t.id!=null&&(r.id=t.id)}function Mat(r){return r=lt({},r),R(["id","parentId","$action","hv","bounding","textContent","clipPath"].concat(O5),function(t){delete r[t]}),r}function Lat(r,t,e){var a=Ie(r).eventData;!r.silent&&!r.ignore&&!a&&(a=Ie(r).eventData={componentType:"graphic",componentIndex:t.componentIndex,name:r.name}),a&&(a.info=e.info)}function Iat(r){r.registerComponentModel(kat),r.registerComponentView(Dat),r.registerPreprocessor(function(t){var e=t.graphic;ht(e)?!e[0]||!e[0].elements?t.graphic=[{elements:e}]:t.graphic=[t.graphic[0]]:e&&!e.elements&&(t.graphic=[{elements:[e]}])})}var nR=["x","y","radius","angle","single"],Rat=["cartesian2d","polar","singleAxis"];function Pat(r){var t=r.get("coordinateSystem");return ir(Rat,t)>=0}function Al(r){return r+"Axis"}function Eat(r,t){var e=Wt(),a=[],i=Wt();r.eachComponent({mainType:"dataZoom",query:t},function(v){i.get(v.uid)||s(v)});var n;do n=!1,r.eachComponent("dataZoom",o);while(n);function o(v){!i.get(v.uid)&&l(v)&&(s(v),n=!0)}function s(v){i.set(v.uid,!0),a.push(v),u(v)}function l(v){var c=!1;return v.eachTargetAxis(function(d,f){var h=e.get(d);h&&h[f]&&(c=!0)}),c}function u(v){v.eachTargetAxis(function(c,d){(e.get(c)||e.set(c,[]))[d]=!0})}return a}function Yz(r){var t=r.ecModel,e={infoList:[],infoMap:Wt()};return r.eachTargetAxis(function(a,i){var n=t.getComponent(Al(a),i);if(n){var o=n.getCoordSysModel();if(o){var s=o.uid,l=e.infoMap.get(s);l||(l={model:o,axisModels:[]},e.infoList.push(l),e.infoMap.set(s,l)),l.axisModels.push(n)}}}),e}var wb=function(){function r(){this.indexList=[],this.indexMap=[]}return r.prototype.add=function(t){this.indexMap[t]||(this.indexList.push(t),this.indexMap[t]=!0)},r}(),Ch=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e._autoThrottle=!0,e._noTarget=!0,e._rangePropMode=["percent","percent"],e}return t.prototype.init=function(e,a,i){var n=oR(e);this.settledOption=n,this.mergeDefaultAndTheme(e,i),this._doInit(n)},t.prototype.mergeOption=function(e){var a=oR(e);Ze(this.option,e,!0),Ze(this.settledOption,a,!0),this._doInit(a)},t.prototype._doInit=function(e){var a=this.option;this._setDefaultThrottle(e),this._updateRangeUse(e);var i=this.settledOption;R([["start","startValue"],["end","endValue"]],function(n,o){this._rangePropMode[o]==="value"&&(a[n[0]]=i[n[0]]=null)},this),this._resetTarget()},t.prototype._resetTarget=function(){var e=this.get("orient",!0),a=this._targetAxisInfoMap=Wt(),i=this._fillSpecifiedTargetAxis(a);i?this._orient=e||this._makeAutoOrientByTargetAxis():(this._orient=e||"horizontal",this._fillAutoTargetAxisByOrient(a,this._orient)),this._noTarget=!0,a.each(function(n){n.indexList.length&&(this._noTarget=!1)},this)},t.prototype._fillSpecifiedTargetAxis=function(e){var a=!1;return R(nR,function(i){var n=this.getReferringComponents(Al(i),S9);if(n.specified){a=!0;var o=new wb;R(n.models,function(s){o.add(s.componentIndex)}),e.set(i,o)}},this),a},t.prototype._fillAutoTargetAxisByOrient=function(e,a){var i=this.ecModel,n=!0;if(n){var o=a==="vertical"?"y":"x",s=i.findComponents({mainType:o+"Axis"});l(s,o)}if(n){var s=i.findComponents({mainType:"singleAxis",filter:function(v){return v.get("orient",!0)===a}});l(s,"single")}function l(u,v){var c=u[0];if(c){var d=new wb;if(d.add(c.componentIndex),e.set(v,d),n=!1,v==="x"||v==="y"){var f=c.getReferringComponents("grid",Ua).models[0];f&&R(u,function(h){c.componentIndex!==h.componentIndex&&f===h.getReferringComponents("grid",Ua).models[0]&&d.add(h.componentIndex)})}}}n&&R(nR,function(u){if(n){var v=i.findComponents({mainType:Al(u),filter:function(d){return d.get("type",!0)==="category"}});if(v[0]){var c=new wb;c.add(v[0].componentIndex),e.set(u,c),n=!1}}},this)},t.prototype._makeAutoOrientByTargetAxis=function(){var e;return this.eachTargetAxis(function(a){!e&&(e=a)},this),e==="y"?"vertical":"horizontal"},t.prototype._setDefaultThrottle=function(e){if(e.hasOwnProperty("throttle")&&(this._autoThrottle=!1),this._autoThrottle){var a=this.ecModel.option;this.option.throttle=a.animation&&a.animationDurationUpdate>0?100:20}},t.prototype._updateRangeUse=function(e){var a=this._rangePropMode,i=this.get("rangeMode");R([["start","startValue"],["end","endValue"]],function(n,o){var s=e[n[0]]!=null,l=e[n[1]]!=null;s&&!l?a[o]="percent":!s&&l?a[o]="value":i?a[o]=i[o]:s&&(a[o]="percent")})},t.prototype.noTarget=function(){return this._noTarget},t.prototype.getFirstTargetAxisModel=function(){var e;return this.eachTargetAxis(function(a,i){e==null&&(e=this.ecModel.getComponent(Al(a),i))},this),e},t.prototype.eachTargetAxis=function(e,a){this._targetAxisInfoMap.each(function(i,n){R(i.indexList,function(o){e.call(a,n,o)})})},t.prototype.getAxisProxy=function(e,a){var i=this.getAxisModel(e,a);if(i)return i.__dzAxisProxy},t.prototype.getAxisModel=function(e,a){var i=this._targetAxisInfoMap.get(e);if(i&&i.indexMap[a])return this.ecModel.getComponent(Al(e),a)},t.prototype.setRawRange=function(e){var a=this.option,i=this.settledOption;R([["start","startValue"],["end","endValue"]],function(n){(e[n[0]]!=null||e[n[1]]!=null)&&(a[n[0]]=i[n[0]]=e[n[0]],a[n[1]]=i[n[1]]=e[n[1]])},this),this._updateRangeUse(e)},t.prototype.setCalculatedRange=function(e){var a=this.option;R(["start","startValue","end","endValue"],function(i){a[i]=e[i]})},t.prototype.getPercentRange=function(){var e=this.findRepresentativeAxisProxy();if(e)return e.getDataPercentWindow()},t.prototype.getValueRange=function(e,a){if(e==null&&a==null){var i=this.findRepresentativeAxisProxy();if(i)return i.getDataValueWindow()}else return this.getAxisProxy(e,a).getDataValueWindow()},t.prototype.findRepresentativeAxisProxy=function(e){if(e)return e.__dzAxisProxy;for(var a,i=this._targetAxisInfoMap.keys(),n=0;no[1];if(w&&!S&&!C)return!0;w&&(m=!0),S&&(h=!0),C&&(g=!0)}return m&&h&&g})}else dc(v,function(f){if(n==="empty")l.setData(u=u.map(f,function(g){return s(g)?g:NaN}));else{var h={};h[f]=o,u.selectRange(h)}});dc(v,function(f){u.setApproximateExtent(o,f)})}});function s(l){return l>=o[0]&&l<=o[1]}},r.prototype._updateMinMaxSpan=function(){var t=this._minMaxSpan={},e=this._dataZoomModel,a=this._dataExtent;dc(["min","max"],function(i){var n=e.get(i+"Span"),o=e.get(i+"ValueSpan");o!=null&&(o=this.getAxisModel().axis.scale.parse(o)),o!=null?n=ra(a[0]+o,a,[0,100],!0):n!=null&&(o=ra(n,[0,100],a,!0)-a[0]),t[i+"Span"]=n,t[i+"ValueSpan"]=o},this)},r.prototype._setAxisModel=function(){var t=this.getAxisModel(),e=this._percentWindow,a=this._valueWindow;if(e){var i=A4(a,[0,500]);i=Math.min(i,20);var n=t.axis.scale.rawExtentInfo;e[0]!==0&&n.setDeterminedMinMax("min",+a[0].toFixed(i)),e[1]!==100&&n.setDeterminedMinMax("max",+a[1].toFixed(i)),n.freeze()}},r}();function Bat(r,t,e){var a=[1/0,-1/0];dc(e,function(o){pY(a,o.getData(),t)});var i=r.getAxisModel(),n=cN(i.axis.scale,i,a).calculate();return[n.min,n.max]}var Vat={getTargetSeries:function(r){function t(i){r.eachComponent("dataZoom",function(n){n.eachTargetAxis(function(o,s){var l=r.getComponent(Al(o),s);i(o,s,l,n)})})}t(function(i,n,o,s){o.__dzAxisProxy=null});var e=[];t(function(i,n,o,s){o.__dzAxisProxy||(o.__dzAxisProxy=new zat(i,n,s,r),e.push(o.__dzAxisProxy))});var a=Wt();return R(e,function(i){R(i.getTargetSeriesModels(),function(n){a.set(n.uid,n)})}),a},overallReset:function(r,t){r.eachComponent("dataZoom",function(e){e.eachTargetAxis(function(a,i){e.getAxisProxy(a,i).reset(e)}),e.eachTargetAxis(function(a,i){e.getAxisProxy(a,i).filterData(e,t)})}),r.eachComponent("dataZoom",function(e){var a=e.findRepresentativeAxisProxy();if(a){var i=a.getDataPercentWindow(),n=a.getDataValueWindow();e.setCalculatedRange({start:i[0],end:i[1],startValue:n[0],endValue:n[1]})}})}};function Fat(r){r.registerAction("dataZoom",function(t,e){var a=Eat(e,t);R(a,function(i){i.setRawRange({start:t.start,end:t.end,startValue:t.startValue,endValue:t.endValue})})})}var lR=!1;function oA(r){lR||(lR=!0,r.registerProcessor(r.PRIORITY.PROCESSOR.FILTER,Vat),Fat(r),r.registerSubTypeDefaulter("dataZoom",function(){return"slider"}))}function Gat(r){r.registerComponentModel(Nat),r.registerComponentView(Oat),oA(r)}var zn=function(){function r(){}return r}(),Zz={};function fc(r,t){Zz[r]=t}function Xz(r){return Zz[r]}var Hat=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.optionUpdated=function(){r.prototype.optionUpdated.apply(this,arguments);var e=this.ecModel;R(this.option.feature,function(a,i){var n=Xz(i);n&&(n.getDefaultOption&&(n.defaultOption=n.getDefaultOption(e)),Ze(a,n.defaultOption))})},t.type="toolbox",t.layoutMode={type:"box",ignoreSize:!0},t.defaultOption={show:!0,z:6,orient:"horizontal",left:"right",top:"top",backgroundColor:"transparent",borderColor:"#ccc",borderRadius:0,borderWidth:0,padding:5,itemSize:15,itemGap:8,showTitle:!0,iconStyle:{borderColor:"#666",color:"none"},emphasis:{iconStyle:{borderColor:"#3E98C5"}},tooltip:{show:!1,position:"bottom"}},t}(_r);function Wat(r,t,e){var a=t.getBoxLayoutParams(),i=t.get("padding"),n={width:e.getWidth(),height:e.getHeight()},o=Xa(a,n,i);sv(t.get("orient"),r,t.get("itemGap"),o.width,o.height),w_(r,a,n,i)}function jz(r,t){var e=vd(t.get("padding")),a=t.getItemStyle(["color","opacity"]);return a.fill=t.get("backgroundColor"),r=new Mr({shape:{x:r.x-e[3],y:r.y-e[0],width:r.width+e[1]+e[3],height:r.height+e[0]+e[2],r:t.get("borderRadius")},style:a,silent:!0,z2:-1}),r}var Uat=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.render=function(e,a,i,n){var o=this.group;if(o.removeAll(),!e.get("show"))return;var s=+e.get("itemSize"),l=e.get("orient")==="vertical",u=e.get("feature")||{},v=this._features||(this._features={}),c=[];R(u,function(h,g){c.push(g)}),new Fs(this._featureNames||[],c).add(d).update(d).remove(We(d,null)).execute(),this._featureNames=c;function d(h,g){var m=c[h],x=c[g],b=u[m],w=new ia(b,e,e.ecModel),S;if(n&&n.newTitle!=null&&n.featureName===m&&(b.title=n.newTitle),m&&!x){if(qat(m))S={onclick:w.option.onclick,featureName:m};else{var C=Xz(m);if(!C)return;S=new C}v[m]=S}else if(S=v[x],!S)return;S.uid=ud("toolbox-feature"),S.model=w,S.ecModel=a,S.api=i;var T=S instanceof zn;if(!m&&x){T&&S.dispose&&S.dispose(a,i);return}if(!w.get("show")||T&&S.unusable){T&&S.remove&&S.remove(a,i);return}f(w,S,m),w.setIconStatus=function(k,D){var M=this.option,L=this.iconPaths;M.iconStatus=M.iconStatus||{},M.iconStatus[k]=D,L[k]&&(D==="emphasis"?Bs:Vs)(L[k])},S instanceof zn&&S.render&&S.render(w,a,i,n)}function f(h,g,m){var x=h.getModel("iconStyle"),b=h.getModel(["emphasis","iconStyle"]),w=g instanceof zn&&g.getIcons?g.getIcons():h.get("icon"),S=h.get("title")||{},C,T;Pt(w)?(C={},C[m]=w):C=w,Pt(S)?(T={},T[m]=S):T=S;var k=h.iconPaths={};R(C,function(D,M){var L=Hh(D,{},{x:-s/2,y:-s/2,width:s,height:s});L.setStyle(x.getItemStyle());var I=L.ensureState("emphasis");I.style=b.getItemStyle();var P=new Rr({style:{text:T[M],align:b.get("textAlign"),borderRadius:b.get("textBorderRadius"),padding:b.get("textPadding"),fill:null,font:zS({fontStyle:b.get("textFontStyle"),fontFamily:b.get("textFontFamily"),fontSize:b.get("textFontSize"),fontWeight:b.get("textFontWeight")},a)},ignore:!0});L.setTextContent(P),kv({el:L,componentModel:e,itemName:M,formatterParamsExtra:{title:T[M]}}),L.__title=T[M],L.on("mouseover",function(){var E=b.getItemStyle(),N=l?e.get("right")==null&&e.get("left")!=="right"?"right":"left":e.get("bottom")==null&&e.get("top")!=="bottom"?"bottom":"top";P.setStyle({fill:b.get("textFill")||E.fill||E.stroke||"#000",backgroundColor:b.get("textBackgroundColor")}),L.setTextConfig({position:b.get("textPosition")||N}),P.ignore=!e.get("showTitle"),i.enterEmphasis(this)}).on("mouseout",function(){h.get(["iconStatus",M])!=="emphasis"&&i.leaveEmphasis(this),P.hide()}),(h.get(["iconStatus",M])==="emphasis"?Bs:Vs)(L),o.add(L),L.on("click",Nt(g.onclick,g,a,i,M)),k[M]=L})}Wat(o,e,i),o.add(jz(o.getBoundingRect(),e)),l||o.eachChild(function(h){var g=h.__title,m=h.ensureState("emphasis"),x=m.textConfig||(m.textConfig={}),b=h.getTextContent(),w=b&&b.ensureState("emphasis");if(w&&!le(w)&&g){var S=w.style||(w.style={}),C=Nh(g,Rr.makeFont(S)),T=h.x+o.x,k=h.y+o.y+s,D=!1;k+C.height>i.getHeight()&&(x.position="top",D=!0);var M=D?-5-C.height:s+10;T+C.width/2>i.getWidth()?(x.position=["100%",M],S.align="right"):T-C.width/2<0&&(x.position=[0,M],S.align="left")}})},t.prototype.updateView=function(e,a,i,n){R(this._features,function(o){o instanceof zn&&o.updateView&&o.updateView(o.model,a,i,n)})},t.prototype.remove=function(e,a){R(this._features,function(i){i instanceof zn&&i.remove&&i.remove(e,a)}),this.group.removeAll()},t.prototype.dispose=function(e,a){R(this._features,function(i){i instanceof zn&&i.dispose&&i.dispose(e,a)})},t.type="toolbox",t}(Ma);function qat(r){return r.indexOf("my")===0}var $at=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.onclick=function(e,a){var i=this.model,n=i.get("name")||e.get("title.0.text")||"echarts",o=a.getZr().painter.getType()==="svg",s=o?"svg":i.get("type",!0)||"png",l=a.getConnectedDataURL({type:s,backgroundColor:i.get("backgroundColor",!0)||e.get("backgroundColor")||"#fff",connectedBackgroundColor:i.get("connectedBackgroundColor"),excludeComponents:i.get("excludeComponents"),pixelRatio:i.get("pixelRatio")}),u=dr.browser;if(typeof MouseEvent=="function"&&(u.newEdge||!u.ie&&!u.edge)){var v=document.createElement("a");v.download=n+"."+s,v.target="_blank",v.href=l;var c=new MouseEvent("click",{view:document.defaultView,bubbles:!0,cancelable:!1});v.dispatchEvent(c)}else if(window.navigator.msSaveOrOpenBlob||o){var d=l.split(","),f=d[0].indexOf("base64")>-1,h=o?decodeURIComponent(d[1]):d[1];f&&(h=window.atob(h));var g=n+"."+s;if(window.navigator.msSaveOrOpenBlob){for(var m=h.length,x=new Uint8Array(m);m--;)x[m]=h.charCodeAt(m);var b=new Blob([x]);window.navigator.msSaveOrOpenBlob(b,g)}else{var w=document.createElement("iframe");document.body.appendChild(w);var S=w.contentWindow,C=S.document;C.open("image/svg+xml","replace"),C.write(h),C.close(),S.focus(),C.execCommand("SaveAs",!0,g),document.body.removeChild(w)}}else{var T=i.get("lang"),k='',D=window.open();D.document.write(k),D.document.title=n}},t.getDefaultOption=function(e){var a={show:!0,icon:"M4.7,22.9L29.3,45.5L54.7,23.4M4.6,43.6L4.6,58L53.8,58L53.8,43.6M29.2,45.1L29.2,0",title:e.getLocaleModel().get(["toolbox","saveAsImage","title"]),type:"png",connectedBackgroundColor:"#fff",name:"",excludeComponents:["toolbox"],lang:e.getLocaleModel().get(["toolbox","saveAsImage","lang"])};return a},t}(zn),uR="__ec_magicType_stack__",Yat=[["line","bar"],["stack"]],Zat=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.getIcons=function(){var e=this.model,a=e.get("icon"),i={};return R(e.get("type"),function(n){a[n]&&(i[n]=a[n])}),i},t.getDefaultOption=function(e){var a={show:!0,type:[],icon:{line:"M4.1,28.9h7.1l9.3-22l7.4,38l9.7-19.7l3,12.8h14.9M4.1,58h51.4",bar:"M6.7,22.9h10V48h-10V22.9zM24.9,13h10v35h-10V13zM43.2,2h10v46h-10V2zM3.1,58h53.7",stack:"M8.2,38.4l-8.4,4.1l30.6,15.3L60,42.5l-8.1-4.1l-21.5,11L8.2,38.4z M51.9,30l-8.1,4.2l-13.4,6.9l-13.9-6.9L8.2,30l-8.4,4.2l8.4,4.2l22.2,11l21.5-11l8.1-4.2L51.9,30z M51.9,21.7l-8.1,4.2L35.7,30l-5.3,2.8L24.9,30l-8.4-4.1l-8.3-4.2l-8.4,4.2L8.2,30l8.3,4.2l13.9,6.9l13.4-6.9l8.1-4.2l8.1-4.1L51.9,21.7zM30.4,2.2L-0.2,17.5l8.4,4.1l8.3,4.2l8.4,4.2l5.5,2.7l5.3-2.7l8.1-4.2l8.1-4.2l8.1-4.1L30.4,2.2z"},title:e.getLocaleModel().get(["toolbox","magicType","title"]),option:{},seriesIndex:{}};return a},t.prototype.onclick=function(e,a,i){var n=this.model,o=n.get(["seriesIndex",i]);if(vR[i]){var s={series:[]},l=function(c){var d=c.subType,f=c.id,h=vR[i](d,f,c,n);h&&(ve(h,c.option),s.series.push(h));var g=c.coordinateSystem;if(g&&g.type==="cartesian2d"&&(i==="line"||i==="bar")){var m=g.getAxesByScale("ordinal")[0];if(m){var x=m.dim,b=x+"Axis",w=c.getReferringComponents(b,Ua).models[0],S=w.componentIndex;s[b]=s[b]||[];for(var C=0;C<=S;C++)s[b][S]=s[b][S]||{};s[b][S].boundaryGap=i==="bar"}}};R(Yat,function(c){ir(c,i)>=0&&R(c,function(d){n.setIconStatus(d,"normal")})}),n.setIconStatus(i,"emphasis"),e.eachComponent({mainType:"series",query:o==null?null:{seriesIndex:o}},l);var u,v=i;i==="stack"&&(u=Ze({stack:n.option.title.tiled,tiled:n.option.title.stack},n.option.title),n.get(["iconStatus",i])!=="emphasis"&&(v="tiled")),a.dispatchAction({type:"changeMagicType",currentType:v,newOption:s,newTitle:u,featureName:"magicType"})}},t}(zn),vR={line:function(r,t,e,a){if(r==="bar")return Ze({id:t,type:"line",data:e.get("data"),stack:e.get("stack"),markPoint:e.get("markPoint"),markLine:e.get("markLine")},a.get(["option","line"])||{},!0)},bar:function(r,t,e,a){if(r==="line")return Ze({id:t,type:"bar",data:e.get("data"),stack:e.get("stack"),markPoint:e.get("markPoint"),markLine:e.get("markLine")},a.get(["option","bar"])||{},!0)},stack:function(r,t,e,a){var i=e.get("stack")===uR;if(r==="line"||r==="bar")return a.setIconStatus("stack",i?"normal":"emphasis"),Ze({id:t,stack:i?"":uR},a.get(["option","stack"])||{},!0)}};ds({type:"changeMagicType",event:"magicTypeChanged",update:"prepareAndUpdate"},function(r,t){t.mergeOption(r.newOption)});var V_=new Array(60).join("-"),td=" ";function Xat(r){var t={},e=[],a=[];return r.eachRawSeries(function(i){var n=i.coordinateSystem;if(n&&(n.type==="cartesian2d"||n.type==="polar")){var o=n.getBaseAxis();if(o.type==="category"){var s=o.dim+"_"+o.index;t[s]||(t[s]={categoryAxis:o,valueAxis:n.getOtherAxis(o),series:[]},a.push({axisDim:o.dim,axisIndex:o.index})),t[s].series.push(i)}else e.push(i)}else e.push(i)}),{seriesGroupByCategoryAxis:t,other:e,meta:a}}function jat(r){var t=[];return R(r,function(e,a){var i=e.categoryAxis,n=e.valueAxis,o=n.dim,s=[" "].concat(pt(e.series,function(f){return f.name})),l=[i.model.getCategories()];R(e.series,function(f){var h=f.getRawData();l.push(f.getRawData().mapArray(h.mapDimension(o),function(g){return g}))});for(var u=[s.join(td)],v=0;v=0)return!0}var M2=new RegExp("["+td+"]+","g");function tit(r){for(var t=r.split(/\n+/g),e=Lm(t.shift()).split(M2),a=[],i=pt(e,function(l){return{name:l,data:[]}}),n=0;n=0;n--){var o=e[n];if(o[i])break}if(n<0){var s=r.queryComponents({mainType:"dataZoom",subType:"select",id:i})[0];if(s){var l=s.getPercentRange();e[0][i]={dataZoomId:i,start:l[0],end:l[1]}}}}),e.push(t)}function oit(r){var t=sA(r),e=t[t.length-1];t.length>1&&t.pop();var a={};return Kz(e,function(i,n){for(var o=t.length-1;o>=0;o--)if(i=t[o][n],i){a[n]=i;break}}),a}function sit(r){Qz(r).snapshots=null}function lit(r){return sA(r).length}function sA(r){var t=Qz(r);return t.snapshots||(t.snapshots=[{}]),t.snapshots}var uit=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.onclick=function(e,a){sit(e),a.dispatchAction({type:"restore",from:this.uid})},t.getDefaultOption=function(e){var a={show:!0,icon:"M3.8,33.4 M47,18.9h9.8V8.7 M56.3,20.1 C52.1,9,40.5,0.6,26.8,2.1C12.6,3.7,1.6,16.2,2.1,30.6 M13,41.1H3.1v10.2 M3.7,39.9c4.2,11.1,15.8,19.5,29.5,18 c14.2-1.6,25.2-14.1,24.7-28.5",title:e.getLocaleModel().get(["toolbox","restore","title"])};return a},t}(zn);ds({type:"restore",event:"restore",update:"prepareAndUpdate"},function(r,t){t.resetOption("recreate")});var vit=["grid","xAxis","yAxis","geo","graph","polar","radiusAxis","angleAxis","bmap"],lA=function(){function r(t,e,a){var i=this;this._targetInfoList=[];var n=cR(e,t);R(cit,function(o,s){(!a||!a.include||ir(a.include,s)>=0)&&o(n,i._targetInfoList)})}return r.prototype.setOutputRanges=function(t,e){return this.matchOutputRanges(t,e,function(a,i,n){if((a.coordRanges||(a.coordRanges=[])).push(i),!a.coordRange){a.coordRange=i;var o=Sb[a.brushType](0,n,i);a.__rangeOffset={offset:pR[a.brushType](o.values,a.range,[1,1]),xyMinMax:o.xyMinMax}}}),t},r.prototype.matchOutputRanges=function(t,e,a){R(t,function(i){var n=this.findTargetInfo(i,e);n&&n!==!0&&R(n.coordSyses,function(o){var s=Sb[i.brushType](1,o,i.range,!0);a(i,s.values,o,e)})},this)},r.prototype.setInputRanges=function(t,e){R(t,function(a){var i=this.findTargetInfo(a,e);if(a.range=a.range||[],i&&i!==!0){a.panelId=i.panelId;var n=Sb[a.brushType](0,i.coordSys,a.coordRange),o=a.__rangeOffset;a.range=o?pR[a.brushType](n.values,o.offset,dit(n.xyMinMax,o.xyMinMax)):n.values}},this)},r.prototype.makePanelOpts=function(t,e){return pt(this._targetInfoList,function(a){var i=a.getPanelRect();return{panelId:a.panelId,defaultBrushType:e?e(a):null,clipPath:oz(i),isTargetByCursor:lz(i,t,a.coordSysModel),getLinearBrushOtherExtent:sz(i)}})},r.prototype.controlSeries=function(t,e,a){var i=this.findTargetInfo(t,a);return i===!0||i&&ir(i.coordSyses,e.coordinateSystem)>=0},r.prototype.findTargetInfo=function(t,e){for(var a=this._targetInfoList,i=cR(e,t),n=0;nr[1]&&r.reverse(),r}function cR(r,t){return Ff(r,t,{includeMainTypes:vit})}var cit={grid:function(r,t){var e=r.xAxisModels,a=r.yAxisModels,i=r.gridModels,n=Wt(),o={},s={};!e&&!a&&!i||(R(e,function(l){var u=l.axis.grid.model;n.set(u.id,u),o[u.id]=!0}),R(a,function(l){var u=l.axis.grid.model;n.set(u.id,u),s[u.id]=!0}),R(i,function(l){n.set(l.id,l),o[l.id]=!0,s[l.id]=!0}),n.each(function(l){var u=l.coordinateSystem,v=[];R(u.getCartesians(),function(c,d){(ir(e,c.getAxis("x").model)>=0||ir(a,c.getAxis("y").model)>=0)&&v.push(c)}),t.push({panelId:"grid--"+l.id,gridModel:l,coordSysModel:l,coordSys:v[0],coordSyses:v,getPanelRect:fR.grid,xAxisDeclared:o[l.id],yAxisDeclared:s[l.id]})}))},geo:function(r,t){R(r.geoModels,function(e){var a=e.coordinateSystem;t.push({panelId:"geo--"+e.id,geoModel:e,coordSysModel:e,coordSys:a,coordSyses:[a],getPanelRect:fR.geo})})}},dR=[function(r,t){var e=r.xAxisModel,a=r.yAxisModel,i=r.gridModel;return!i&&e&&(i=e.axis.grid.model),!i&&a&&(i=a.axis.grid.model),i&&i===t.gridModel},function(r,t){var e=r.geoModel;return e&&e===t.geoModel}],fR={grid:function(){return this.coordSys.master.getRect().clone()},geo:function(){var r=this.coordSys,t=r.getBoundingRect().clone();return t.applyTransform(ov(r)),t}},Sb={lineX:We(hR,0),lineY:We(hR,1),rect:function(r,t,e,a){var i=r?t.pointToData([e[0][0],e[1][0]],a):t.dataToPoint([e[0][0],e[1][0]],a),n=r?t.pointToData([e[0][1],e[1][1]],a):t.dataToPoint([e[0][1],e[1][1]],a),o=[L2([i[0],n[0]]),L2([i[1],n[1]])];return{values:o,xyMinMax:o}},polygon:function(r,t,e,a){var i=[[1/0,-1/0],[1/0,-1/0]],n=pt(e,function(o){var s=r?t.pointToData(o,a):t.dataToPoint(o,a);return i[0][0]=Math.min(i[0][0],s[0]),i[1][0]=Math.min(i[1][0],s[1]),i[0][1]=Math.max(i[0][1],s[0]),i[1][1]=Math.max(i[1][1],s[1]),s});return{values:n,xyMinMax:i}}};function hR(r,t,e,a){var i=e.getAxis(["x","y"][r]),n=L2(pt([0,1],function(s){return t?i.coordToData(i.toLocalCoord(a[s]),!0):i.toGlobalCoord(i.dataToCoord(a[s]))})),o=[];return o[r]=n,o[1-r]=[NaN,NaN],{values:n,xyMinMax:o}}var pR={lineX:We(gR,0),lineY:We(gR,1),rect:function(r,t,e){return[[r[0][0]-e[0]*t[0][0],r[0][1]-e[0]*t[0][1]],[r[1][0]-e[1]*t[1][0],r[1][1]-e[1]*t[1][1]]]},polygon:function(r,t,e){return pt(r,function(a,i){return[a[0]-e[0]*t[i][0],a[1]-e[1]*t[i][1]]})}};function gR(r,t,e,a){return[t[0]-a[r]*e[0],t[1]-a[r]*e[1]]}function dit(r,t){var e=yR(r),a=yR(t),i=[e[0]/a[0],e[1]/a[1]];return isNaN(i[0])&&(i[0]=1),isNaN(i[1])&&(i[1]=1),i}function yR(r){return r?[r[0][1]-r[0][0],r[1][1]-r[1][0]]:[NaN,NaN]}var I2=R,fit=m9("toolbox-dataZoom_"),hit=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.render=function(e,a,i,n){this._brushController||(this._brushController=new NT(i.getZr()),this._brushController.on("brush",Nt(this._onBrush,this)).mount()),yit(e,a,this,n,i),git(e,a)},t.prototype.onclick=function(e,a,i){pit[i].call(this)},t.prototype.remove=function(e,a){this._brushController&&this._brushController.unmount()},t.prototype.dispose=function(e,a){this._brushController&&this._brushController.dispose()},t.prototype._onBrush=function(e){var a=e.areas;if(!e.isEnd||!a.length)return;var i={},n=this.ecModel;this._brushController.updateCovers([]);var o=new lA(uA(this.model),n,{include:["grid"]});o.matchOutputRanges(a,n,function(u,v,c){if(c.type==="cartesian2d"){var d=u.brushType;d==="rect"?(s("x",c,v[0]),s("y",c,v[1])):s({lineX:"x",lineY:"y"}[d],c,v)}}),nit(n,i),this._dispatchZoomAction(i);function s(u,v,c){var d=v.getAxis(u),f=d.model,h=l(u,f,n),g=h.findRepresentativeAxisProxy(f).getMinMaxSpan();(g.minValueSpan!=null||g.maxValueSpan!=null)&&(c=Pv(0,c.slice(),d.scale.getExtent(),0,g.minValueSpan,g.maxValueSpan)),h&&(i[h.id]={dataZoomId:h.id,startValue:c[0],endValue:c[1]})}function l(u,v,c){var d;return c.eachComponent({mainType:"dataZoom",subType:"select"},function(f){var h=f.getAxisModel(u,v.componentIndex);h&&(d=f)}),d}},t.prototype._dispatchZoomAction=function(e){var a=[];I2(e,function(i,n){a.push(be(i))}),a.length&&this.api.dispatchAction({type:"dataZoom",from:this.uid,batch:a})},t.getDefaultOption=function(e){var a={show:!0,filterMode:"filter",icon:{zoom:"M0,13.5h26.9 M13.5,26.9V0 M32.1,13.5H58V58H13.5 V32.1",back:"M22,1.4L9.9,13.5l12.3,12.3 M10.3,13.5H54.9v44.6 H10.3v-26"},title:e.getLocaleModel().get(["toolbox","dataZoom","title"]),brushStyle:{borderWidth:0,color:"rgba(210,219,238,0.2)"}};return a},t}(zn),pit={zoom:function(){var r=!this._isZoomActive;this.api.dispatchAction({type:"takeGlobalCursor",key:"dataZoomSelect",dataZoomSelectActive:r})},back:function(){this._dispatchZoomAction(oit(this.ecModel))}};function uA(r){var t={xAxisIndex:r.get("xAxisIndex",!0),yAxisIndex:r.get("yAxisIndex",!0),xAxisId:r.get("xAxisId",!0),yAxisId:r.get("yAxisId",!0)};return t.xAxisIndex==null&&t.xAxisId==null&&(t.xAxisIndex="all"),t.yAxisIndex==null&&t.yAxisId==null&&(t.yAxisIndex="all"),t}function git(r,t){r.setIconStatus("back",lit(t)>1?"emphasis":"normal")}function yit(r,t,e,a,i){var n=e._isZoomActive;a&&a.type==="takeGlobalCursor"&&(n=a.key==="dataZoomSelect"?a.dataZoomSelectActive:!1),e._isZoomActive=n,r.setIconStatus("zoom",n?"emphasis":"normal");var o=new lA(uA(r),t,{include:["grid"]}),s=o.makePanelOpts(i,function(l){return l.xAxisDeclared&&!l.yAxisDeclared?"lineX":!l.xAxisDeclared&&l.yAxisDeclared?"lineY":"rect"});e._brushController.setPanels(s).enableBrush(n&&s.length?{brushType:"auto",brushStyle:r.getModel("brushStyle").getItemStyle()}:!1)}WW("dataZoom",function(r){var t=r.getComponent("toolbox",0),e=["feature","dataZoom"];if(!t||t.get(e)==null)return;var a=t.getModel(e),i=[],n=uA(a),o=Ff(r,n);I2(o.xAxisModels,function(l){return s(l,"xAxis","xAxisIndex")}),I2(o.yAxisModels,function(l){return s(l,"yAxis","yAxisIndex")});function s(l,u,v){var c=l.componentIndex,d={type:"select",$fromToolbox:!0,filterMode:a.get("filterMode",!0)||"filter",id:fit+u+c};d[v]=c,i.push(d)}return i});function mit(r){r.registerComponentModel(Hat),r.registerComponentView(Uat),fc("saveAsImage",$at),fc("magicType",Zat),fc("dataView",ait),fc("dataZoom",hit),fc("restore",uit),sr(Gat)}var _it=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="tooltip",t.dependencies=["axisPointer"],t.defaultOption={z:60,show:!0,showContent:!0,trigger:"item",triggerOn:"mousemove|click",alwaysShowContent:!1,displayMode:"single",renderMode:"auto",confine:null,showDelay:0,hideDelay:100,transitionDuration:.4,enterable:!1,backgroundColor:"#fff",shadowBlur:10,shadowColor:"rgba(0, 0, 0, .2)",shadowOffsetX:1,shadowOffsetY:2,borderRadius:4,borderWidth:1,padding:null,extraCssText:"",axisPointer:{type:"line",axis:"auto",animation:"auto",animationDurationUpdate:200,animationEasingUpdate:"exponentialOut",crossStyle:{color:"#999",width:1,type:"dashed",textStyle:{}}},textStyle:{color:"#666",fontSize:14}},t}(_r);function Jz(r){var t=r.get("confine");return t!=null?!!t:r.get("renderMode")==="richText"}function tB(r){if(dr.domSupported){for(var t=document.documentElement.style,e=0,a=r.length;e-1?(s+="top:50%",l+="translateY(-50%) rotate("+(u=n==="left"?-225:-45)+"deg)"):(s+="left:50%",l+="translateX(-50%) rotate("+(u=n==="top"?225:45)+"deg)");var v=u*Math.PI/180,c=o+i,d=c*Math.abs(Math.cos(v))+c*Math.abs(Math.sin(v)),f=Math.round(((d-Math.SQRT2*i)/2+Math.SQRT2*i-(d-c)/2)*100)/100;s+=";"+n+":-"+f+"px";var h=t+" solid "+i+"px;",g=["position:absolute;width:"+o+"px;height:"+o+"px;z-index:-1;",s+";"+l+";","border-bottom:"+h,"border-right:"+h,"background-color:"+a+";"];return'
'}function Cit(r,t){var e="cubic-bezier(0.23,1,0.32,1)",a=" "+r/2+"s "+e,i="opacity"+a+",visibility"+a;return t||(a=" "+r+"s "+e,i+=dr.transformSupported?","+vA+a:",left"+a+",top"+a),wit+":"+i}function mR(r,t,e){var a=r.toFixed(0)+"px",i=t.toFixed(0)+"px";if(!dr.transformSupported)return e?"top:"+i+";left:"+a+";":[["top",i],["left",a]];var n=dr.transform3dSupported,o="translate"+(n?"3d":"")+"("+a+","+i+(n?",0":"")+")";return e?"top:0;left:0;"+vA+":"+o+";":[["top",0],["left",0],[eB,o]]}function kit(r){var t=[],e=r.get("fontSize"),a=r.getTextColor();a&&t.push("color:"+a),t.push("font:"+r.getFont());var i=Be(r.get("lineHeight"),Math.round(e*3/2));e&&t.push("line-height:"+i+"px");var n=r.get("textShadowColor"),o=r.get("textShadowBlur")||0,s=r.get("textShadowOffsetX")||0,l=r.get("textShadowOffsetY")||0;return n&&o&&t.push("text-shadow:"+s+"px "+l+"px "+o+"px "+n),R(["decoration","align"],function(u){var v=r.get(u);v&&t.push("text-"+u+":"+v)}),t.join(";")}function Dit(r,t,e){var a=[],i=r.get("transitionDuration"),n=r.get("backgroundColor"),o=r.get("shadowBlur"),s=r.get("shadowColor"),l=r.get("shadowOffsetX"),u=r.get("shadowOffsetY"),v=r.getModel("textStyle"),c=h3(r,"html"),d=l+"px "+u+"px "+o+"px "+s;return a.push("box-shadow:"+d),t&&i&&a.push(Cit(i,e)),n&&a.push("background-color:"+n),R(["width","color","radius"],function(f){var h="border-"+f,g=E5(h),m=r.get(g);m!=null&&a.push(h+":"+m+(f==="color"?"":"px"))}),a.push(kit(v)),c!=null&&a.push("padding:"+vd(c).join("px ")+"px"),a.join(";")+";"}function _R(r,t,e,a,i){var n=t&&t.painter;if(e){var o=n&&n.getViewportRoot();o&&jG(r,o,e,a,i)}else{r[0]=a,r[1]=i;var s=n&&n.getViewportRootOffset();s&&(r[0]+=s.offsetLeft,r[1]+=s.offsetTop)}r[2]=r[0]/t.getWidth(),r[3]=r[1]/t.getHeight()}var Mit=function(){function r(t,e){if(this._show=!1,this._styleCoord=[0,0,0,0],this._enterable=!0,this._alwaysShowContent=!1,this._firstShow=!0,this._longHide=!0,dr.wxa)return null;var a=document.createElement("div");a.domBelongToZr=!0,this.el=a;var i=this._zr=t.getZr(),n=e.appendTo,o=n&&(Pt(n)?document.querySelector(n):Uc(n)?n:le(n)&&n(t.getDom()));_R(this._styleCoord,i,o,t.getWidth()/2,t.getHeight()/2),(o||t.getDom()).appendChild(a),this._api=t,this._container=o;var s=this;a.onmouseenter=function(){s._enterable&&(clearTimeout(s._hideTimeout),s._show=!0),s._inContent=!0},a.onmousemove=function(l){if(l=l||window.event,!s._enterable){var u=i.handler,v=i.painter.getViewportRoot();Mn(v,l,!0),u.dispatch("mousemove",l)}},a.onmouseleave=function(){s._inContent=!1,s._enterable&&s._show&&s.hideLater(s._hideDelay)}}return r.prototype.update=function(t){if(!this._container){var e=this._api.getDom(),a=bit(e,"position"),i=e.style;i.position!=="absolute"&&a!=="absolute"&&(i.position="relative")}var n=t.get("alwaysShowContent");n&&this._moveIfResized(),this._alwaysShowContent=n,this.el.className=t.get("className")||""},r.prototype.show=function(t,e){clearTimeout(this._hideTimeout),clearTimeout(this._longHideTimeout);var a=this.el,i=a.style,n=this._styleCoord;a.innerHTML?i.cssText=Sit+Dit(t,!this._firstShow,this._longHide)+mR(n[0],n[1],!0)+("border-color:"+_v(e)+";")+(t.get("extraCssText")||"")+(";pointer-events:"+(this._enterable?"auto":"none")):i.display="none",this._show=!0,this._firstShow=!1,this._longHide=!1},r.prototype.setContent=function(t,e,a,i,n){var o=this.el;if(t==null){o.innerHTML="";return}var s="";if(Pt(n)&&a.get("trigger")==="item"&&!Jz(a)&&(s=Ait(a,i,n)),Pt(t))o.innerHTML=t+s;else if(t){o.innerHTML="",ht(t)||(t=[t]);for(var l=0;l=0?this._tryShow(n,o):i==="leave"&&this._hide(o))},this))},t.prototype._keepShow=function(){var e=this._tooltipModel,a=this._ecModel,i=this._api,n=e.get("triggerOn");if(this._lastX!=null&&this._lastY!=null&&n!=="none"&&n!=="click"){var o=this;clearTimeout(this._refreshUpdateTimeout),this._refreshUpdateTimeout=setTimeout(function(){!i.isDisposed()&&o.manuallyShowTip(e,a,i,{x:o._lastX,y:o._lastY,dataByCoordSys:o._lastDataByCoordSys})})}},t.prototype.manuallyShowTip=function(e,a,i,n){if(!(n.from===this.uid||dr.node||!i.getDom())){var o=wR(n,i);this._ticket="";var s=n.dataByCoordSys,l=Oit(n,a,i);if(l){var u=l.el.getBoundingRect().clone();u.applyTransform(l.el.transform),this._tryShow({offsetX:u.x+u.width/2,offsetY:u.y+u.height/2,target:l.el,position:n.position,positionDefault:"bottom"},o)}else if(n.tooltip&&n.x!=null&&n.y!=null){var v=Iit;v.x=n.x,v.y=n.y,v.update(),Ie(v).tooltipConfig={name:null,option:n.tooltip},this._tryShow({offsetX:n.x,offsetY:n.y,target:v},o)}else if(s)this._tryShow({offsetX:n.x,offsetY:n.y,position:n.position,dataByCoordSys:s,tooltipOption:n.tooltipOption},o);else if(n.seriesIndex!=null){if(this._manuallyAxisShowTip(e,a,i,n))return;var c=Gz(n,a),d=c.point[0],f=c.point[1];d!=null&&f!=null&&this._tryShow({offsetX:d,offsetY:f,target:c.el,position:n.position,positionDefault:"bottom"},o)}else n.x!=null&&n.y!=null&&(i.dispatchAction({type:"updateAxisPointer",x:n.x,y:n.y}),this._tryShow({offsetX:n.x,offsetY:n.y,position:n.position,target:i.getZr().findHover(n.x,n.y).target},o))}},t.prototype.manuallyHideTip=function(e,a,i,n){var o=this._tooltipContent;this._tooltipModel&&o.hideLater(this._tooltipModel.get("hideDelay")),this._lastX=this._lastY=this._lastDataByCoordSys=null,n.from!==this.uid&&this._hide(wR(n,i))},t.prototype._manuallyAxisShowTip=function(e,a,i,n){var o=n.seriesIndex,s=n.dataIndex,l=a.getComponent("axisPointer").coordSysAxesInfo;if(!(o==null||s==null||l==null)){var u=a.getSeriesByIndex(o);if(u){var v=u.getData(),c=cf([v.getItemModel(s),u,(u.coordinateSystem||{}).model],this._tooltipModel);if(c.get("trigger")==="axis")return i.dispatchAction({type:"updateAxisPointer",seriesIndex:o,dataIndex:s,position:n.position}),!0}}},t.prototype._tryShow=function(e,a){var i=e.target,n=this._tooltipModel;if(n){this._lastX=e.offsetX,this._lastY=e.offsetY;var o=e.dataByCoordSys;if(o&&o.length)this._showAxisTooltip(o,e);else if(i){var s=Ie(i);if(s.ssrType==="legend")return;this._lastDataByCoordSys=null;var l,u;Hu(i,function(v){if(Ie(v).dataIndex!=null)return l=v,!0;if(Ie(v).tooltipConfig!=null)return u=v,!0},!0),l?this._showSeriesItemTooltip(e,l,a):u?this._showComponentItemTooltip(e,u,a):this._hide(a)}else this._lastDataByCoordSys=null,this._hide(a)}},t.prototype._showOrMove=function(e,a){var i=e.get("showDelay");a=Nt(a,this),clearTimeout(this._showTimout),i>0?this._showTimout=setTimeout(a,i):a()},t.prototype._showAxisTooltip=function(e,a){var i=this._ecModel,n=this._tooltipModel,o=[a.offsetX,a.offsetY],s=cf([a.tooltipOption],n),l=this._renderMode,u=[],v=ii("section",{blocks:[],noHeader:!0}),c=[],d=new sx;R(e,function(b){R(b.dataByAxis,function(w){var S=i.getComponent(w.axisDim+"Axis",w.axisIndex),C=w.value;if(!(!S||C==null)){var T=zz(C,S.axis,i,w.seriesDataIndices,w.valueLabelOpt),k=ii("section",{header:T,noHeader:!_o(T),sortBlocks:!0,blocks:[]});v.blocks.push(k),R(w.seriesDataIndices,function(D){var M=i.getSeriesByIndex(D.seriesIndex),L=D.dataIndexInside,I=M.getDataParams(L);if(!(I.dataIndex<0)){I.axisDim=w.axisDim,I.axisIndex=w.axisIndex,I.axisType=w.axisType,I.axisId=w.axisId,I.axisValue=uT(S.axis,{value:C}),I.axisValueLabel=T,I.marker=d.makeTooltipMarker("item",_v(I.color),l);var P=lD(M.formatTooltip(L,!0,null)),E=P.frag;if(E){var N=cf([M],n).get("valueFormatter");k.blocks.push(N?lt({valueFormatter:N},E):E)}P.text&&c.push(P.text),u.push(I)}})}})}),v.blocks.reverse(),c.reverse();var f=a.position,h=s.get("order"),g=hD(v,d,l,h,i.get("useUTC"),s.get("textStyle"));g&&c.unshift(g);var m=l==="richText"?` + +`:"
",x=c.join(m);this._showOrMove(s,function(){this._updateContentNotChangedOnAxis(e,u)?this._updatePosition(s,f,o[0],o[1],this._tooltipContent,u):this._showTooltipContent(s,x,u,Math.random()+"",o[0],o[1],f,null,d)})},t.prototype._showSeriesItemTooltip=function(e,a,i){var n=this._ecModel,o=Ie(a),s=o.seriesIndex,l=n.getSeriesByIndex(s),u=o.dataModel||l,v=o.dataIndex,c=o.dataType,d=u.getData(c),f=this._renderMode,h=e.positionDefault,g=cf([d.getItemModel(v),u,l&&(l.coordinateSystem||{}).model],this._tooltipModel,h?{position:h}:null),m=g.get("trigger");if(!(m!=null&&m!=="item")){var x=u.getDataParams(v,c),b=new sx;x.marker=b.makeTooltipMarker("item",_v(x.color),f);var w=lD(u.formatTooltip(v,!1,c)),S=g.get("order"),C=g.get("valueFormatter"),T=w.frag,k=T?hD(C?lt({valueFormatter:C},T):T,b,f,S,n.get("useUTC"),g.get("textStyle")):w.text,D="item_"+u.name+"_"+v;this._showOrMove(g,function(){this._showTooltipContent(g,k,x,D,e.offsetX,e.offsetY,e.position,e.target,b)}),i({type:"showTip",dataIndexInside:v,dataIndex:d.getRawIndex(v),seriesIndex:s,from:this.uid})}},t.prototype._showComponentItemTooltip=function(e,a,i){var n=this._renderMode==="html",o=Ie(a),s=o.tooltipConfig,l=s.option||{},u=l.encodeHTMLContent;if(Pt(l)){var v=l;l={content:v,formatter:v},u=!0}u&&n&&l.content&&(l=be(l),l.content=Ni(l.content));var c=[l],d=this._ecModel.getComponent(o.componentMainType,o.componentIndex);d&&c.push(d),c.push({formatter:l.content});var f=e.positionDefault,h=cf(c,this._tooltipModel,f?{position:f}:null),g=h.get("content"),m=Math.random()+"",x=new sx;this._showOrMove(h,function(){var b=be(h.get("formatterParams")||{});this._showTooltipContent(h,g,b,m,e.offsetX,e.offsetY,e.position,a,x)}),i({type:"showTip",from:this.uid})},t.prototype._showTooltipContent=function(e,a,i,n,o,s,l,u,v){if(this._ticket="",!(!e.get("showContent")||!e.get("show"))){var c=this._tooltipContent;c.setEnterable(e.get("enterable"));var d=e.get("formatter");l=l||e.get("position");var f=a,h=this._getNearestPoint([o,s],i,e.get("trigger"),e.get("borderColor")),g=h.color;if(d)if(Pt(d)){var m=e.ecModel.get("useUTC"),x=ht(i)?i[0]:i,b=x&&x.axisType&&x.axisType.indexOf("time")>=0;f=d,b&&(f=y_(x.axisValue,f,m)),f=N5(f,i,!0)}else if(le(d)){var w=Nt(function(S,C){S===this._ticket&&(c.setContent(C,v,e,g,l),this._updatePosition(e,l,o,s,c,i,u))},this);this._ticket=n,f=d(i,n,w)}else f=d;c.setContent(f,v,e,g,l),c.show(e,g),this._updatePosition(e,l,o,s,c,i,u)}},t.prototype._getNearestPoint=function(e,a,i,n){if(i==="axis"||ht(a))return{color:n||(this._renderMode==="html"?"#fff":"none")};if(!ht(a))return{color:n||a.color||a.borderColor}},t.prototype._updatePosition=function(e,a,i,n,o,s,l){var u=this._api.getWidth(),v=this._api.getHeight();a=a||e.get("position");var c=o.getSize(),d=e.get("align"),f=e.get("verticalAlign"),h=l&&l.getBoundingRect().clone();if(l&&h.applyTransform(l.transform),le(a)&&(a=a([i,n],s,o.el,h,{viewSize:[u,v],contentSize:c.slice()})),ht(a))i=Lt(a[0],u),n=Lt(a[1],v);else if(pe(a)){var g=a;g.width=c[0],g.height=c[1];var m=Xa(g,{width:u,height:v});i=m.x,n=m.y,d=null,f=null}else if(Pt(a)&&l){var x=Nit(a,h,c,e.get("borderWidth"));i=x[0],n=x[1]}else{var x=Pit(i,n,o,u,v,d?null:20,f?null:20);i=x[0],n=x[1]}if(d&&(i-=SR(d)?c[0]/2:d==="right"?c[0]:0),f&&(n-=SR(f)?c[1]/2:f==="bottom"?c[1]:0),Jz(e)){var x=Eit(i,n,o,u,v);i=x[0],n=x[1]}o.moveTo(i,n)},t.prototype._updateContentNotChangedOnAxis=function(e,a){var i=this._lastDataByCoordSys,n=this._cbParamsList,o=!!i&&i.length===e.length;return o&&R(i,function(s,l){var u=s.dataByAxis||[],v=e[l]||{},c=v.dataByAxis||[];o=o&&u.length===c.length,o&&R(u,function(d,f){var h=c[f]||{},g=d.seriesDataIndices||[],m=h.seriesDataIndices||[];o=o&&d.value===h.value&&d.axisType===h.axisType&&d.axisId===h.axisId&&g.length===m.length,o&&R(g,function(x,b){var w=m[b];o=o&&x.seriesIndex===w.seriesIndex&&x.dataIndex===w.dataIndex}),n&&R(d.seriesDataIndices,function(x){var b=x.seriesIndex,w=a[b],S=n[b];w&&S&&S.data!==w.data&&(o=!1)})})}),this._lastDataByCoordSys=e,this._cbParamsList=a,!!o},t.prototype._hide=function(e){this._lastDataByCoordSys=null,e({type:"hideTip",from:this.uid})},t.prototype.dispose=function(e,a){dr.node||!a.getDom()||(fh(this,"_updatePosition"),this._tooltipContent.dispose(),C2("itemTooltip",a))},t.type="tooltip",t}(Ma);function cf(r,t,e){var a=t.ecModel,i;e?(i=new ia(e,a,a),i=new ia(t.option,i,a)):i=t;for(var n=r.length-1;n>=0;n--){var o=r[n];o&&(o instanceof ia&&(o=o.get("tooltip",!0)),Pt(o)&&(o={formatter:o}),o&&(i=new ia(o,i,a)))}return i}function wR(r,t){return r.dispatchAction||Nt(t.dispatchAction,t)}function Pit(r,t,e,a,i,n,o){var s=e.getSize(),l=s[0],u=s[1];return n!=null&&(r+l+n+2>a?r-=l+n:r+=n),o!=null&&(t+u+o>i?t-=u+o:t+=o),[r,t]}function Eit(r,t,e,a,i){var n=e.getSize(),o=n[0],s=n[1];return r=Math.min(r+o,a)-o,t=Math.min(t+s,i)-s,r=Math.max(r,0),t=Math.max(t,0),[r,t]}function Nit(r,t,e,a){var i=e[0],n=e[1],o=Math.ceil(Math.SQRT2*a)+8,s=0,l=0,u=t.width,v=t.height;switch(r){case"inside":s=t.x+u/2-i/2,l=t.y+v/2-n/2;break;case"top":s=t.x+u/2-i/2,l=t.y-n-o;break;case"bottom":s=t.x+u/2-i/2,l=t.y+v+o;break;case"left":s=t.x-i-o,l=t.y+v/2-n/2;break;case"right":s=t.x+u+o,l=t.y+v/2-n/2}return[s,l]}function SR(r){return r==="center"||r==="middle"}function Oit(r,t,e){var a=TS(r).queryOptionMap,i=a.keys()[0];if(!(!i||i==="series")){var n=Oh(t,i,a.get(i),{useDefault:!1,enableAll:!1,enableNone:!1}),o=n.models[0];if(o){var s=e.getViewOfComponentModel(o),l;if(s.group.traverse(function(u){var v=Ie(u).tooltipConfig;if(v&&v.name===r.name)return l=u,!0}),l)return{componentMainType:i,componentIndex:o.componentIndex,el:l}}}}function zit(r){sr(ep),r.registerComponentModel(_it),r.registerComponentView(Rit),r.registerAction({type:"showTip",event:"showTip",update:"tooltip:manuallyShowTip"},Fa),r.registerAction({type:"hideTip",event:"hideTip",update:"tooltip:manuallyHideTip"},Fa)}var Bit=["rect","polygon","keep","clear"];function Vit(r,t){var e=la(r?r.brush:[]);if(e.length){var a=[];R(e,function(l){var u=l.hasOwnProperty("toolbox")?l.toolbox:[];u instanceof Array&&(a=a.concat(u))});var i=r&&r.toolbox;ht(i)&&(i=i[0]),i||(i={feature:{}},r.toolbox=[i]);var n=i.feature||(i.feature={}),o=n.brush||(n.brush={}),s=o.type||(o.type=[]);s.push.apply(s,a),Fit(s),t&&!s.length&&s.push.apply(s,Bit)}}function Fit(r){var t={};R(r,function(e){t[e]=1}),r.length=0,R(t,function(e,a){r.push(a)})}var TR=R;function AR(r){if(r){for(var t in r)if(r.hasOwnProperty(t))return!0}}function R2(r,t,e){var a={};return TR(t,function(n){var o=a[n]=i();TR(r[n],function(s,l){if(ei.isValidType(l)){var u={type:l,visual:s};e&&e(u,n),o[l]=new ei(u),l==="opacity"&&(u=be(u),u.type="colorAlpha",o.__hidden.__alphaForOpacity=new ei(u))}})}),a;function i(){var n=function(){};n.prototype.__hidden=n.prototype;var o=new n;return o}}function aB(r,t,e){var a;R(e,function(i){t.hasOwnProperty(i)&&AR(t[i])&&(a=!0)}),a&&R(e,function(i){t.hasOwnProperty(i)&&AR(t[i])?r[i]=be(t[i]):delete r[i]})}function Git(r,t,e,a,i,n){var o={};R(r,function(c){var d=ei.prepareVisualTypes(t[c]);o[c]=d});var s;function l(c){return JS(e,s,c)}function u(c,d){T3(e,s,c,d)}e.each(v);function v(c,d){s=c;var f=e.getRawDataItem(s);if(!(f&&f.visualMap===!1))for(var h=a.call(i,c),g=t[h],m=o[h],x=0,b=m.length;xt[0][1]&&(t[0][1]=n[0]),n[1]t[1][1]&&(t[1][1]=n[1])}return t&&LR(t)}};function LR(r){return new er(r[0][0],r[1][0],r[0][1]-r[0][0],r[1][1]-r[1][0])}var Xit=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.init=function(e,a){this.ecModel=e,this.api=a,this.model,(this._brushController=new NT(a.getZr())).on("brush",Nt(this._onBrush,this)).mount()},t.prototype.render=function(e,a,i,n){this.model=e,this._updateController(e,a,i,n)},t.prototype.updateTransform=function(e,a,i,n){iB(a),this._updateController(e,a,i,n)},t.prototype.updateVisual=function(e,a,i,n){this.updateTransform(e,a,i,n)},t.prototype.updateView=function(e,a,i,n){this._updateController(e,a,i,n)},t.prototype._updateController=function(e,a,i,n){(!n||n.$from!==e.id)&&this._brushController.setPanels(e.brushTargetManager.makePanelOpts(i)).enableBrush(e.brushOption).updateCovers(e.areas.slice())},t.prototype.dispose=function(){this._brushController.dispose()},t.prototype._onBrush=function(e){var a=this.model.id,i=this.model.brushTargetManager.setOutputRanges(e.areas,this.ecModel);(!e.isEnd||e.removeOnClick)&&this.api.dispatchAction({type:"brush",brushId:a,areas:be(i),$from:a}),e.isEnd&&this.api.dispatchAction({type:"brushEnd",brushId:a,areas:be(i),$from:a})},t.type="brush",t}(Ma),jit="#ddd",Kit=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.areas=[],e.brushOption={},e}return t.prototype.optionUpdated=function(e,a){var i=this.option;!a&&aB(i,e,["inBrush","outOfBrush"]);var n=i.inBrush=i.inBrush||{};i.outOfBrush=i.outOfBrush||{color:jit},n.hasOwnProperty("liftZ")||(n.liftZ=5)},t.prototype.setAreas=function(e){e&&(this.areas=pt(e,function(a){return IR(this.option,a)},this))},t.prototype.setBrushOption=function(e){this.brushOption=IR(this.option,e),this.brushType=this.brushOption.brushType},t.type="brush",t.dependencies=["geo","grid","xAxis","yAxis","parallel","series"],t.defaultOption={seriesIndex:"all",brushType:"rect",brushMode:"single",transformable:!0,brushStyle:{borderWidth:1,color:"rgba(210,219,238,0.3)",borderColor:"#D2DBEE"},throttleType:"fixRate",throttleDelay:0,removeOnClick:!0,z:1e4},t}(_r);function IR(r,t){return Ze({brushType:r.brushType,brushMode:r.brushMode,transformable:r.transformable,brushStyle:new ia(r.brushStyle).getItemStyle(),removeOnClick:r.removeOnClick,z:r.z},t,!0)}var Qit=["rect","polygon","lineX","lineY","keep","clear"],Jit=function(r){tt(t,r);function t(){return r!==null&&r.apply(this,arguments)||this}return t.prototype.render=function(e,a,i){var n,o,s;a.eachComponent({mainType:"brush"},function(l){n=l.brushType,o=l.brushOption.brushMode||"single",s=s||!!l.areas.length}),this._brushType=n,this._brushMode=o,R(e.get("type",!0),function(l){e.setIconStatus(l,(l==="keep"?o==="multiple":l==="clear"?s:l===n)?"emphasis":"normal")})},t.prototype.updateView=function(e,a,i){this.render(e,a,i)},t.prototype.getIcons=function(){var e=this.model,a=e.get("icon",!0),i={};return R(e.get("type",!0),function(n){a[n]&&(i[n]=a[n])}),i},t.prototype.onclick=function(e,a,i){var n=this._brushType,o=this._brushMode;i==="clear"?(a.dispatchAction({type:"axisAreaSelect",intervals:[]}),a.dispatchAction({type:"brush",command:"clear",areas:[]})):a.dispatchAction({type:"takeGlobalCursor",key:"brush",brushOption:{brushType:i==="keep"?n:n===i?!1:i,brushMode:i==="keep"?o==="multiple"?"single":"multiple":o}})},t.getDefaultOption=function(e){var a={show:!0,type:Qit.slice(),icon:{rect:"M7.3,34.7 M0.4,10V-0.2h9.8 M89.6,10V-0.2h-9.8 M0.4,60v10.2h9.8 M89.6,60v10.2h-9.8 M12.3,22.4V10.5h13.1 M33.6,10.5h7.8 M49.1,10.5h7.8 M77.5,22.4V10.5h-13 M12.3,31.1v8.2 M77.7,31.1v8.2 M12.3,47.6v11.9h13.1 M33.6,59.5h7.6 M49.1,59.5 h7.7 M77.5,47.6v11.9h-13",polygon:"M55.2,34.9c1.7,0,3.1,1.4,3.1,3.1s-1.4,3.1-3.1,3.1 s-3.1-1.4-3.1-3.1S53.5,34.9,55.2,34.9z M50.4,51c1.7,0,3.1,1.4,3.1,3.1c0,1.7-1.4,3.1-3.1,3.1c-1.7,0-3.1-1.4-3.1-3.1 C47.3,52.4,48.7,51,50.4,51z M55.6,37.1l1.5-7.8 M60.1,13.5l1.6-8.7l-7.8,4 M59,19l-1,5.3 M24,16.1l6.4,4.9l6.4-3.3 M48.5,11.6 l-5.9,3.1 M19.1,12.8L9.7,5.1l1.1,7.7 M13.4,29.8l1,7.3l6.6,1.6 M11.6,18.4l1,6.1 M32.8,41.9 M26.6,40.4 M27.3,40.2l6.1,1.6 M49.9,52.1l-5.6-7.6l-4.9-1.2",lineX:"M15.2,30 M19.7,15.6V1.9H29 M34.8,1.9H40.4 M55.3,15.6V1.9H45.9 M19.7,44.4V58.1H29 M34.8,58.1H40.4 M55.3,44.4 V58.1H45.9 M12.5,20.3l-9.4,9.6l9.6,9.8 M3.1,29.9h16.5 M62.5,20.3l9.4,9.6L62.3,39.7 M71.9,29.9H55.4",lineY:"M38.8,7.7 M52.7,12h13.2v9 M65.9,26.6V32 M52.7,46.3h13.2v-9 M24.9,12H11.8v9 M11.8,26.6V32 M24.9,46.3H11.8v-9 M48.2,5.1l-9.3-9l-9.4,9.2 M38.9-3.9V12 M48.2,53.3l-9.3,9l-9.4-9.2 M38.9,62.3V46.4",keep:"M4,10.5V1h10.3 M20.7,1h6.1 M33,1h6.1 M55.4,10.5V1H45.2 M4,17.3v6.6 M55.6,17.3v6.6 M4,30.5V40h10.3 M20.7,40 h6.1 M33,40h6.1 M55.4,30.5V40H45.2 M21,18.9h62.9v48.6H21V18.9z",clear:"M22,14.7l30.9,31 M52.9,14.7L22,45.7 M4.7,16.8V4.2h13.1 M26,4.2h7.8 M41.6,4.2h7.8 M70.3,16.8V4.2H57.2 M4.7,25.9v8.6 M70.3,25.9v8.6 M4.7,43.2v12.6h13.1 M26,55.8h7.8 M41.6,55.8h7.8 M70.3,43.2v12.6H57.2"},title:e.getLocaleModel().get(["toolbox","brush","title"])};return a},t}(zn);function tnt(r){r.registerComponentView(Xit),r.registerComponentModel(Kit),r.registerPreprocessor(Vit),r.registerVisual(r.PRIORITY.VISUAL.BRUSH,Uit),r.registerAction({type:"brush",event:"brush",update:"updateVisual"},function(t,e){e.eachComponent({mainType:"brush",query:t},function(a){a.setAreas(t.areas)})}),r.registerAction({type:"brushSelect",event:"brushSelected",update:"none"},Fa),r.registerAction({type:"brushEnd",event:"brushEnd",update:"none"},Fa),fc("brush",Jit)}var ent=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.layoutMode={type:"box",ignoreSize:!0},e}return t.type="title",t.defaultOption={z:6,show:!0,text:"",target:"blank",subtext:"",subtarget:"blank",left:0,top:0,backgroundColor:"rgba(0,0,0,0)",borderColor:"#ccc",borderWidth:0,padding:5,itemGap:10,textStyle:{fontSize:18,fontWeight:"bold",color:"#464646"},subtextStyle:{fontSize:12,color:"#6E7079"}},t}(_r),rnt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.render=function(e,a,i){if(this.group.removeAll(),!!e.get("show")){var n=this.group,o=e.getModel("textStyle"),s=e.getModel("subtextStyle"),l=e.get("textAlign"),u=Be(e.get("textBaseline"),e.get("textVerticalAlign")),v=new Rr({style:ya(o,{text:e.get("text"),fill:o.getTextColor()},{disableBox:!0}),z2:10}),c=v.getBoundingRect(),d=e.get("subtext"),f=new Rr({style:ya(s,{text:d,fill:s.getTextColor(),y:c.height+e.get("itemGap"),verticalAlign:"top"},{disableBox:!0}),z2:10}),h=e.get("link"),g=e.get("sublink"),m=e.get("triggerEvent",!0);v.silent=!h&&!m,f.silent=!g&&!m,h&&v.on("click",function(){om(h,"_"+e.get("target"))}),g&&f.on("click",function(){om(g,"_"+e.get("subtarget"))}),Ie(v).eventData=Ie(f).eventData=m?{componentType:"title",componentIndex:e.componentIndex}:null,n.add(v),d&&n.add(f);var x=n.getBoundingRect(),b=e.getBoxLayoutParams();b.width=x.width,b.height=x.height;var w=Xa(b,{width:i.getWidth(),height:i.getHeight()},e.get("padding"));l||(l=e.get("left")||e.get("right"),l==="middle"&&(l="center"),l==="right"?w.x+=w.width:l==="center"&&(w.x+=w.width/2)),u||(u=e.get("top")||e.get("bottom"),u==="center"&&(u="middle"),u==="bottom"?w.y+=w.height:u==="middle"&&(w.y+=w.height/2),u=u||"top"),n.x=w.x,n.y=w.y,n.markRedraw();var S={align:l,verticalAlign:u};v.setStyle(S),f.setStyle(S),x=n.getBoundingRect();var C=w.margin,T=e.getItemStyle(["color","opacity"]);T.fill=e.get("backgroundColor");var k=new Mr({shape:{x:x.x-C[3],y:x.y-C[0],width:x.width+C[1]+C[3],height:x.height+C[0]+C[2],r:e.get("borderRadius")},style:T,subPixelOptimize:!0,silent:!0});n.add(k)}},t.type="title",t}(Ma);function ant(r){r.registerComponentModel(ent),r.registerComponentView(rnt)}var RR=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.layoutMode="box",e}return t.prototype.init=function(e,a,i){this.mergeDefaultAndTheme(e,i),this._initData()},t.prototype.mergeOption=function(e){r.prototype.mergeOption.apply(this,arguments),this._initData()},t.prototype.setCurrentIndex=function(e){e==null&&(e=this.option.currentIndex);var a=this._data.count();this.option.loop?e=(e%a+a)%a:(e>=a&&(e=a-1),e<0&&(e=0)),this.option.currentIndex=e},t.prototype.getCurrentIndex=function(){return this.option.currentIndex},t.prototype.isIndexMax=function(){return this.getCurrentIndex()>=this._data.count()-1},t.prototype.setPlayState=function(e){this.option.autoPlay=!!e},t.prototype.getPlayState=function(){return!!this.option.autoPlay},t.prototype._initData=function(){var e=this.option,a=e.data||[],i=e.axisType,n=this._names=[],o;i==="category"?(o=[],R(a,function(u,v){var c=Za(nd(u),""),d;pe(u)?(d=be(u),d.value=v):d=v,o.push(d),n.push(c)})):o=a;var s={category:"ordinal",time:"time",value:"number"}[i]||"number",l=this._data=new Oi([{name:"value",type:s}],this);l.initData(o,n)},t.prototype.getData=function(){return this._data},t.prototype.getCategories=function(){if(this.get("axisType")==="category")return this._names.slice()},t.type="timeline",t.defaultOption={z:4,show:!0,axisType:"time",realtime:!0,left:"20%",top:null,right:"20%",bottom:0,width:null,height:40,padding:5,controlPosition:"left",autoPlay:!1,rewind:!1,loop:!0,playInterval:2e3,currentIndex:0,itemStyle:{},label:{color:"#000"},data:[]},t}(_r),nB=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="timeline.slider",t.defaultOption=Ul(RR.defaultOption,{backgroundColor:"rgba(0,0,0,0)",borderColor:"#ccc",borderWidth:0,orient:"horizontal",inverse:!1,tooltip:{trigger:"item"},symbol:"circle",symbolSize:12,lineStyle:{show:!0,width:2,color:"#DAE1F5"},label:{position:"auto",show:!0,interval:"auto",rotate:0,color:"#A4B1D7"},itemStyle:{color:"#A4B1D7",borderWidth:1},checkpointStyle:{symbol:"circle",symbolSize:15,color:"#316bf3",borderColor:"#fff",borderWidth:2,shadowBlur:2,shadowOffsetX:1,shadowOffsetY:1,shadowColor:"rgba(0, 0, 0, 0.3)",animation:!0,animationDuration:300,animationEasing:"quinticInOut"},controlStyle:{show:!0,showPlayBtn:!0,showPrevBtn:!0,showNextBtn:!0,itemSize:24,itemGap:12,position:"left",playIcon:"path://M31.6,53C17.5,53,6,41.5,6,27.4S17.5,1.8,31.6,1.8C45.7,1.8,57.2,13.3,57.2,27.4S45.7,53,31.6,53z M31.6,3.3 C18.4,3.3,7.5,14.1,7.5,27.4c0,13.3,10.8,24.1,24.1,24.1C44.9,51.5,55.7,40.7,55.7,27.4C55.7,14.1,44.9,3.3,31.6,3.3z M24.9,21.3 c0-2.2,1.6-3.1,3.5-2l10.5,6.1c1.899,1.1,1.899,2.9,0,4l-10.5,6.1c-1.9,1.1-3.5,0.2-3.5-2V21.3z",stopIcon:"path://M30.9,53.2C16.8,53.2,5.3,41.7,5.3,27.6S16.8,2,30.9,2C45,2,56.4,13.5,56.4,27.6S45,53.2,30.9,53.2z M30.9,3.5C17.6,3.5,6.8,14.4,6.8,27.6c0,13.3,10.8,24.1,24.101,24.1C44.2,51.7,55,40.9,55,27.6C54.9,14.4,44.1,3.5,30.9,3.5z M36.9,35.8c0,0.601-0.4,1-0.9,1h-1.3c-0.5,0-0.9-0.399-0.9-1V19.5c0-0.6,0.4-1,0.9-1H36c0.5,0,0.9,0.4,0.9,1V35.8z M27.8,35.8 c0,0.601-0.4,1-0.9,1h-1.3c-0.5,0-0.9-0.399-0.9-1V19.5c0-0.6,0.4-1,0.9-1H27c0.5,0,0.9,0.4,0.9,1L27.8,35.8L27.8,35.8z",nextIcon:"M2,18.5A1.52,1.52,0,0,1,.92,18a1.49,1.49,0,0,1,0-2.12L7.81,9.36,1,3.11A1.5,1.5,0,1,1,3,.89l8,7.34a1.48,1.48,0,0,1,.49,1.09,1.51,1.51,0,0,1-.46,1.1L3,18.08A1.5,1.5,0,0,1,2,18.5Z",prevIcon:"M10,.5A1.52,1.52,0,0,1,11.08,1a1.49,1.49,0,0,1,0,2.12L4.19,9.64,11,15.89a1.5,1.5,0,1,1-2,2.22L1,10.77A1.48,1.48,0,0,1,.5,9.68,1.51,1.51,0,0,1,1,8.58L9,.92A1.5,1.5,0,0,1,10,.5Z",prevBtnSize:18,nextBtnSize:18,color:"#A4B1D7",borderColor:"#A4B1D7",borderWidth:1},emphasis:{label:{show:!0,color:"#6f778d"},itemStyle:{color:"#316BF3"},controlStyle:{color:"#316BF3",borderColor:"#316BF3",borderWidth:2}},progress:{lineStyle:{color:"#316BF3"},itemStyle:{color:"#316BF3"},label:{color:"#6f778d"}},data:[]}),t}(RR);$a(nB,T_.prototype);var int=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="timeline",t}(Ma),nnt=function(r){tt(t,r);function t(e,a,i,n){var o=r.call(this,e,a,i)||this;return o.type=n||"value",o}return t.prototype.getLabelModel=function(){return this.model.getModel("label")},t.prototype.isHorizontal=function(){return this.model.get("orient")==="horizontal"},t}(Do),Ab=Math.PI,PR=Lr(),ont=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.init=function(e,a){this.api=a},t.prototype.render=function(e,a,i){if(this.model=e,this.api=i,this.ecModel=a,this.group.removeAll(),e.get("show",!0)){var n=this._layout(e,i),o=this._createGroup("_mainGroup"),s=this._createGroup("_labelGroup"),l=this._axis=this._createAxis(n,e);e.formatTooltip=function(u){var v=l.scale.getLabel({value:u});return ii("nameValue",{noName:!0,value:v})},R(["AxisLine","AxisTick","Control","CurrentPointer"],function(u){this["_render"+u](n,o,l,e)},this),this._renderAxisLabel(n,s,l,e),this._position(n,e)}this._doPlayStop(),this._updateTicksStatus()},t.prototype.remove=function(){this._clearTimer(),this.group.removeAll()},t.prototype.dispose=function(){this._clearTimer()},t.prototype._layout=function(e,a){var i=e.get(["label","position"]),n=e.get("orient"),o=lnt(e,a),s;i==null||i==="auto"?s=n==="horizontal"?o.y+o.height/2=0||s==="+"?"left":"right"},u={horizontal:s>=0||s==="+"?"top":"bottom",vertical:"middle"},v={horizontal:0,vertical:Ab/2},c=n==="vertical"?o.height:o.width,d=e.getModel("controlStyle"),f=d.get("show",!0),h=f?d.get("itemSize"):0,g=f?d.get("itemGap"):0,m=h+g,x=e.get(["label","rotate"])||0;x=x*Ab/180;var b,w,S,C=d.get("position",!0),T=f&&d.get("showPlayBtn",!0),k=f&&d.get("showPrevBtn",!0),D=f&&d.get("showNextBtn",!0),M=0,L=c;C==="left"||C==="bottom"?(T&&(b=[0,0],M+=m),k&&(w=[M,0],M+=m),D&&(S=[L-h,0],L-=m)):(T&&(b=[L-h,0],L-=m),k&&(w=[0,0],M+=m),D&&(S=[L-h,0],L-=m));var I=[M,L];return e.get("inverse")&&I.reverse(),{viewRect:o,mainLength:c,orient:n,rotation:v[n],labelRotation:x,labelPosOpt:s,labelAlign:e.get(["label","align"])||l[n],labelBaseline:e.get(["label","verticalAlign"])||e.get(["label","baseline"])||u[n],playPosition:b,prevBtnPosition:w,nextBtnPosition:S,axisExtent:I,controlSize:h,controlGap:g}},t.prototype._position=function(e,a){var i=this._mainGroup,n=this._labelGroup,o=e.viewRect;if(e.orient==="vertical"){var s=pn(),l=o.x,u=o.y+o.height;ss(s,s,[-l,-u]),Cv(s,s,-Ab/2),ss(s,s,[l,u]),o=o.clone(),o.applyTransform(s)}var v=b(o),c=b(i.getBoundingRect()),d=b(n.getBoundingRect()),f=[i.x,i.y],h=[n.x,n.y];h[0]=f[0]=v[0][0];var g=e.labelPosOpt;if(g==null||Pt(g)){var m=g==="+"?0:1;w(f,c,v,1,m),w(h,d,v,1,1-m)}else{var m=g>=0?0:1;w(f,c,v,1,m),h[1]=f[1]+g}i.setPosition(f),n.setPosition(h),i.rotation=n.rotation=e.rotation,x(i),x(n);function x(S){S.originX=v[0][0]-S.x,S.originY=v[1][0]-S.y}function b(S){return[[S.x,S.x+S.width],[S.y,S.y+S.height]]}function w(S,C,T,k,D){S[k]+=T[k][D]-C[k][D]}},t.prototype._createAxis=function(e,a){var i=a.getData(),n=a.get("axisType"),o=snt(a,n);o.getTicks=function(){return i.mapArray(["value"],function(u){return{value:u}})};var s=i.getDataExtent("value");o.setExtent(s[0],s[1]),o.calcNiceTicks();var l=new nnt("value",o,e.axisExtent,n);return l.model=a,l},t.prototype._createGroup=function(e){var a=this[e]=new Ae;return this.group.add(a),a},t.prototype._renderAxisLine=function(e,a,i,n){var o=i.getExtent();if(n.get(["lineStyle","show"])){var s=new Ja({shape:{x1:o[0],y1:0,x2:o[1],y2:0},style:lt({lineCap:"round"},n.getModel("lineStyle").getLineStyle()),silent:!0,z2:1});a.add(s);var l=this._progressLine=new Ja({shape:{x1:o[0],x2:this._currentPointer?this._currentPointer.x:o[0],y1:0,y2:0},style:ve({lineCap:"round",lineWidth:s.style.lineWidth},n.getModel(["progress","lineStyle"]).getLineStyle()),silent:!0,z2:1});a.add(l)}},t.prototype._renderAxisTick=function(e,a,i,n){var o=this,s=n.getData(),l=i.scale.getTicks();this._tickSymbols=[],R(l,function(u){var v=i.dataToCoord(u.value),c=s.getItemModel(u.value),d=c.getModel("itemStyle"),f=c.getModel(["emphasis","itemStyle"]),h=c.getModel(["progress","itemStyle"]),g={x:v,y:0,onclick:Nt(o._changeTimeline,o,u.value)},m=ER(c,d,a,g);m.ensureState("emphasis").style=f.getItemStyle(),m.ensureState("progress").style=h.getItemStyle(),nv(m);var x=Ie(m);c.get("tooltip")?(x.dataIndex=u.value,x.dataModel=n):x.dataIndex=x.dataModel=null,o._tickSymbols.push(m)})},t.prototype._renderAxisLabel=function(e,a,i,n){var o=this,s=i.getLabelModel();if(s.get("show")){var l=n.getData(),u=i.getViewLabels();this._tickLabels=[],R(u,function(v){var c=v.tickValue,d=l.getItemModel(c),f=d.getModel("label"),h=d.getModel(["emphasis","label"]),g=d.getModel(["progress","label"]),m=i.dataToCoord(v.tickValue),x=new Rr({x:m,y:0,rotation:e.labelRotation-e.rotation,onclick:Nt(o._changeTimeline,o,c),silent:!1,style:ya(f,{text:v.formattedLabel,align:e.labelAlign,verticalAlign:e.labelBaseline})});x.ensureState("emphasis").style=ya(h),x.ensureState("progress").style=ya(g),a.add(x),nv(x),PR(x).dataIndex=c,o._tickLabels.push(x)})}},t.prototype._renderControl=function(e,a,i,n){var o=e.controlSize,s=e.rotation,l=n.getModel("controlStyle").getItemStyle(),u=n.getModel(["emphasis","controlStyle"]).getItemStyle(),v=n.getPlayState(),c=n.get("inverse",!0);d(e.nextBtnPosition,"next",Nt(this._changeTimeline,this,c?"-":"+")),d(e.prevBtnPosition,"prev",Nt(this._changeTimeline,this,c?"+":"-")),d(e.playPosition,v?"stop":"play",Nt(this._handlePlayClick,this,!v),!0);function d(f,h,g,m){if(f){var x=Ao(Be(n.get(["controlStyle",h+"BtnSize"]),o),o),b=[0,-x/2,x,x],w=unt(n,h+"Icon",b,{x:f[0],y:f[1],originX:o/2,originY:0,rotation:m?-s:0,rectHover:!0,style:l,onclick:g});w.ensureState("emphasis").style=u,a.add(w),nv(w)}}},t.prototype._renderCurrentPointer=function(e,a,i,n){var o=n.getData(),s=n.getCurrentIndex(),l=o.getItemModel(s).getModel("checkpointStyle"),u=this,v={onCreate:function(c){c.draggable=!0,c.drift=Nt(u._handlePointerDrag,u),c.ondragend=Nt(u._handlePointerDragend,u),NR(c,u._progressLine,s,i,n,!0)},onUpdate:function(c){NR(c,u._progressLine,s,i,n)}};this._currentPointer=ER(l,l,this._mainGroup,{},this._currentPointer,v)},t.prototype._handlePlayClick=function(e){this._clearTimer(),this.api.dispatchAction({type:"timelinePlayChange",playState:e,from:this.uid})},t.prototype._handlePointerDrag=function(e,a,i){this._clearTimer(),this._pointerChangeTimeline([i.offsetX,i.offsetY])},t.prototype._handlePointerDragend=function(e){this._pointerChangeTimeline([e.offsetX,e.offsetY],!0)},t.prototype._pointerChangeTimeline=function(e,a){var i=this._toAxisCoord(e)[0],n=this._axis,o=Un(n.getExtent().slice());i>o[1]&&(i=o[1]),i=0&&(o[n]=+o[n].toFixed(d)),[o,c]}var Cb={min:We(dy,"min"),max:We(dy,"max"),average:We(dy,"average"),median:We(dy,"median")};function kh(r,t){if(t){var e=r.getData(),a=r.coordinateSystem,i=a&&a.dimensions;if(!pnt(t)&&!ht(t.coord)&&ht(i)){var n=oB(t,e,a,r);if(t=be(t),t.type&&Cb[t.type]&&n.baseAxis&&n.valueAxis){var o=ir(i,n.baseAxis.dim),s=ir(i,n.valueAxis.dim),l=Cb[t.type](e,n.baseDataDim,n.valueDataDim,o,s);t.coord=l[0],t.value=l[1]}else t.coord=[t.xAxis!=null?t.xAxis:t.radiusAxis,t.yAxis!=null?t.yAxis:t.angleAxis]}if(t.coord==null||!ht(i))t.coord=[];else for(var u=t.coord,v=0;v<2;v++)Cb[u[v]]&&(u[v]=dA(e,e.mapDimension(i[v]),u[v]));return t}}function oB(r,t,e,a){var i={};return r.valueIndex!=null||r.valueDim!=null?(i.valueDataDim=r.valueIndex!=null?t.getDimension(r.valueIndex):r.valueDim,i.valueAxis=e.getAxis(gnt(a,i.valueDataDim)),i.baseAxis=e.getOtherAxis(i.valueAxis),i.baseDataDim=t.mapDimension(i.baseAxis.dim)):(i.baseAxis=a.getBaseAxis(),i.valueAxis=e.getOtherAxis(i.baseAxis),i.baseDataDim=t.mapDimension(i.baseAxis.dim),i.valueDataDim=t.mapDimension(i.valueAxis.dim)),i}function gnt(r,t){var e=r.getData().getDimensionInfo(t);return e&&e.coordDim}function Dh(r,t){return r&&r.containData&&t.coord&&!E2(t)?r.containData(t.coord):!0}function ynt(r,t,e){return r&&r.containZone&&t.coord&&e.coord&&!E2(t)&&!E2(e)?r.containZone(t.coord,e.coord):!0}function sB(r,t){return r?function(e,a,i,n){var o=n<2?e.coord&&e.coord[n]:e.value;return Ll(o,t[n])}:function(e,a,i,n){return Ll(e.value,t[n])}}function dA(r,t,e){if(e==="average"){var a=0,i=0;return r.each(t,function(n,o){isNaN(n)||(a+=n,i++)}),a/i}else return e==="median"?r.getMedian(t):r.getDataExtent(t)[e==="max"?1:0]}var kb=Lr(),fA=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.init=function(){this.markerGroupMap=Wt()},t.prototype.render=function(e,a,i){var n=this,o=this.markerGroupMap;o.each(function(s){kb(s).keep=!1}),a.eachSeries(function(s){var l=Ws.getMarkerModelFromSeries(s,n.type);l&&n.renderSeries(s,l,a,i)}),o.each(function(s){!kb(s).keep&&n.group.remove(s.group)})},t.prototype.markKeep=function(e){kb(e).keep=!0},t.prototype.toggleBlurSeries=function(e,a){var i=this;R(e,function(n){var o=Ws.getMarkerModelFromSeries(n,i.type);if(o){var s=o.getData();s.eachItemGraphicEl(function(l){l&&(a?Q4(l):IS(l))})}})},t.type="marker",t}(Ma);function zR(r,t,e){var a=t.coordinateSystem;r.each(function(i){var n=r.getItemModel(i),o,s=Lt(n.get("x"),e.getWidth()),l=Lt(n.get("y"),e.getHeight());if(!isNaN(s)&&!isNaN(l))o=[s,l];else if(t.getMarkerPosition)o=t.getMarkerPosition(r.getValues(r.dimensions,i));else if(a){var u=r.get(a.dimensions[0],i),v=r.get(a.dimensions[1],i);o=a.dataToPoint([u,v])}isNaN(s)||(o[0]=s),isNaN(l)||(o[1]=l),r.setItemLayout(i,o)})}var mnt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.updateTransform=function(e,a,i){a.eachSeries(function(n){var o=Ws.getMarkerModelFromSeries(n,"markPoint");o&&(zR(o.getData(),n,i),this.markerGroupMap.get(n.id).updateLayout())},this)},t.prototype.renderSeries=function(e,a,i,n){var o=e.coordinateSystem,s=e.id,l=e.getData(),u=this.markerGroupMap,v=u.get(s)||u.set(s,new Zh),c=_nt(o,e,a);a.setData(c),zR(a.getData(),e,n),c.each(function(d){var f=c.getItemModel(d),h=f.getShallow("symbol"),g=f.getShallow("symbolSize"),m=f.getShallow("symbolRotate"),x=f.getShallow("symbolOffset"),b=f.getShallow("symbolKeepAspect");if(le(h)||le(g)||le(m)||le(x)){var w=a.getRawValue(d),S=a.getDataParams(d);le(h)&&(h=h(w,S)),le(g)&&(g=g(w,S)),le(m)&&(m=m(w,S)),le(x)&&(x=x(w,S))}var C=f.getModel("itemStyle").getItemStyle(),T=Uh(l,"color");C.fill||(C.fill=T),c.setItemVisual(d,{symbol:h,symbolSize:g,symbolRotate:m,symbolOffset:x,symbolKeepAspect:b,style:C})}),v.updateData(c),this.group.add(v.group),c.eachItemGraphicEl(function(d){d.traverse(function(f){Ie(f).dataModel=a})}),this.markKeep(v),v.group.silent=a.get("silent")||e.get("silent")},t.type="markPoint",t}(fA);function _nt(r,t,e){var a;r?a=pt(r&&r.dimensions,function(s){var l=t.getData().getDimensionInfo(t.getData().mapDimension(s))||{};return lt(lt({},l),{name:s,ordinalMeta:null})}):a=[{name:"value",type:"float"}];var i=new Oi(a,e),n=pt(e.get("data"),We(kh,t));r&&(n=ta(n,We(Dh,r)));var o=sB(!!r,a);return i.initData(n,null,o),i}function xnt(r){r.registerComponentModel(hnt),r.registerComponentView(mnt),r.registerPreprocessor(function(t){cA(t.series,"markPoint")&&(t.markPoint=t.markPoint||{})})}var bnt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.createMarkerModelFromSeries=function(e,a,i){return new t(e,a,i)},t.type="markLine",t.defaultOption={z:5,symbol:["circle","arrow"],symbolSize:[8,16],symbolOffset:0,precision:2,tooltip:{trigger:"item"},label:{show:!0,position:"end",distance:5},lineStyle:{type:"dashed"},emphasis:{label:{show:!0},lineStyle:{width:3}},animationEasing:"linear"},t}(Ws),fy=Lr(),wnt=function(r,t,e,a){var i=r.getData(),n;if(ht(a))n=a;else{var o=a.type;if(o==="min"||o==="max"||o==="average"||o==="median"||a.xAxis!=null||a.yAxis!=null){var s=void 0,l=void 0;if(a.yAxis!=null||a.xAxis!=null)s=t.getAxis(a.yAxis!=null?"y":"x"),l=si(a.yAxis,a.xAxis);else{var u=oB(a,i,t,r);s=u.valueAxis;var v=J3(i,u.valueDataDim);l=dA(i,v,o)}var c=s.dim==="x"?0:1,d=1-c,f=be(a),h={coord:[]};f.type=null,f.coord=[],f.coord[d]=-1/0,h.coord[d]=1/0;var g=e.get("precision");g>=0&&Or(l)&&(l=+l.toFixed(Math.min(g,20))),f.coord[c]=h.coord[c]=l,n=[f,h,{type:o,valueIndex:a.valueIndex,value:l}]}else n=[]}var m=[kh(r,n[0]),kh(r,n[1]),lt({},n[2])];return m[2].type=m[2].type||null,Ze(m[2],m[0]),Ze(m[2],m[1]),m};function Im(r){return!isNaN(r)&&!isFinite(r)}function BR(r,t,e,a){var i=1-r,n=a.dimensions[r];return Im(t[i])&&Im(e[i])&&t[r]===e[r]&&a.getAxis(n).containData(t[r])}function Snt(r,t){if(r.type==="cartesian2d"){var e=t[0].coord,a=t[1].coord;if(e&&a&&(BR(1,e,a,r)||BR(0,e,a,r)))return!0}return Dh(r,t[0])&&Dh(r,t[1])}function Db(r,t,e,a,i){var n=a.coordinateSystem,o=r.getItemModel(t),s,l=Lt(o.get("x"),i.getWidth()),u=Lt(o.get("y"),i.getHeight());if(!isNaN(l)&&!isNaN(u))s=[l,u];else{if(a.getMarkerPosition)s=a.getMarkerPosition(r.getValues(r.dimensions,t));else{var v=n.dimensions,c=r.get(v[0],t),d=r.get(v[1],t);s=n.dataToPoint([c,d])}if(Iv(n,"cartesian2d")){var f=n.getAxis("x"),h=n.getAxis("y"),v=n.dimensions;Im(r.get(v[0],t))?s[0]=f.toGlobalCoord(f.getExtent()[e?0:1]):Im(r.get(v[1],t))&&(s[1]=h.toGlobalCoord(h.getExtent()[e?0:1]))}isNaN(l)||(s[0]=l),isNaN(u)||(s[1]=u)}r.setItemLayout(t,s)}var Tnt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.updateTransform=function(e,a,i){a.eachSeries(function(n){var o=Ws.getMarkerModelFromSeries(n,"markLine");if(o){var s=o.getData(),l=fy(o).from,u=fy(o).to;l.each(function(v){Db(l,v,!0,n,i),Db(u,v,!1,n,i)}),s.each(function(v){s.setItemLayout(v,[l.getItemLayout(v),u.getItemLayout(v)])}),this.markerGroupMap.get(n.id).updateLayout()}},this)},t.prototype.renderSeries=function(e,a,i,n){var o=e.coordinateSystem,s=e.id,l=e.getData(),u=this.markerGroupMap,v=u.get(s)||u.set(s,new ET);this.group.add(v.group);var c=Ant(o,e,a),d=c.from,f=c.to,h=c.line;fy(a).from=d,fy(a).to=f,a.setData(h);var g=a.get("symbol"),m=a.get("symbolSize"),x=a.get("symbolRotate"),b=a.get("symbolOffset");ht(g)||(g=[g,g]),ht(m)||(m=[m,m]),ht(x)||(x=[x,x]),ht(b)||(b=[b,b]),c.from.each(function(S){w(d,S,!0),w(f,S,!1)}),h.each(function(S){var C=h.getItemModel(S).getModel("lineStyle").getLineStyle();h.setItemLayout(S,[d.getItemLayout(S),f.getItemLayout(S)]),C.stroke==null&&(C.stroke=d.getItemVisual(S,"style").fill),h.setItemVisual(S,{fromSymbolKeepAspect:d.getItemVisual(S,"symbolKeepAspect"),fromSymbolOffset:d.getItemVisual(S,"symbolOffset"),fromSymbolRotate:d.getItemVisual(S,"symbolRotate"),fromSymbolSize:d.getItemVisual(S,"symbolSize"),fromSymbol:d.getItemVisual(S,"symbol"),toSymbolKeepAspect:f.getItemVisual(S,"symbolKeepAspect"),toSymbolOffset:f.getItemVisual(S,"symbolOffset"),toSymbolRotate:f.getItemVisual(S,"symbolRotate"),toSymbolSize:f.getItemVisual(S,"symbolSize"),toSymbol:f.getItemVisual(S,"symbol"),style:C})}),v.updateData(h),c.line.eachItemGraphicEl(function(S){Ie(S).dataModel=a,S.traverse(function(C){Ie(C).dataModel=a})});function w(S,C,T){var k=S.getItemModel(C);Db(S,C,T,e,n);var D=k.getModel("itemStyle").getItemStyle();D.fill==null&&(D.fill=Uh(l,"color")),S.setItemVisual(C,{symbolKeepAspect:k.get("symbolKeepAspect"),symbolOffset:Be(k.get("symbolOffset",!0),b[T?0:1]),symbolRotate:Be(k.get("symbolRotate",!0),x[T?0:1]),symbolSize:Be(k.get("symbolSize"),m[T?0:1]),symbol:Be(k.get("symbol",!0),g[T?0:1]),style:D})}this.markKeep(v),v.group.silent=a.get("silent")||e.get("silent")},t.type="markLine",t}(fA);function Ant(r,t,e){var a;r?a=pt(r&&r.dimensions,function(u){var v=t.getData().getDimensionInfo(t.getData().mapDimension(u))||{};return lt(lt({},v),{name:u,ordinalMeta:null})}):a=[{name:"value",type:"float"}];var i=new Oi(a,e),n=new Oi(a,e),o=new Oi([],e),s=pt(e.get("data"),We(wnt,t,r,e));r&&(s=ta(s,We(Snt,r)));var l=sB(!!r,a);return i.initData(pt(s,function(u){return u[0]}),null,l),n.initData(pt(s,function(u){return u[1]}),null,l),o.initData(pt(s,function(u){return u[2]})),o.hasItemOption=!0,{from:i,to:n,line:o}}function Cnt(r){r.registerComponentModel(bnt),r.registerComponentView(Tnt),r.registerPreprocessor(function(t){cA(t.series,"markLine")&&(t.markLine=t.markLine||{})})}var knt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.createMarkerModelFromSeries=function(e,a,i){return new t(e,a,i)},t.type="markArea",t.defaultOption={z:1,tooltip:{trigger:"item"},animation:!1,label:{show:!0,position:"top"},itemStyle:{borderWidth:0},emphasis:{label:{show:!0,position:"top"}}},t}(Ws),hy=Lr(),Dnt=function(r,t,e,a){var i=a[0],n=a[1];if(!(!i||!n)){var o=kh(r,i),s=kh(r,n),l=o.coord,u=s.coord;l[0]=si(l[0],-1/0),l[1]=si(l[1],-1/0),u[0]=si(u[0],1/0),u[1]=si(u[1],1/0);var v=fS([{},o,s]);return v.coord=[o.coord,s.coord],v.x0=o.x,v.y0=o.y,v.x1=s.x,v.y1=s.y,v}};function Rm(r){return!isNaN(r)&&!isFinite(r)}function VR(r,t,e,a){var i=1-r;return Rm(t[i])&&Rm(e[i])}function Mnt(r,t){var e=t.coord[0],a=t.coord[1],i={coord:e,x:t.x0,y:t.y0},n={coord:a,x:t.x1,y:t.y1};return Iv(r,"cartesian2d")?e&&a&&(VR(1,e,a)||VR(0,e,a))?!0:ynt(r,i,n):Dh(r,i)||Dh(r,n)}function FR(r,t,e,a,i){var n=a.coordinateSystem,o=r.getItemModel(t),s,l=Lt(o.get(e[0]),i.getWidth()),u=Lt(o.get(e[1]),i.getHeight());if(!isNaN(l)&&!isNaN(u))s=[l,u];else{if(a.getMarkerPosition){var v=r.getValues(["x0","y0"],t),c=r.getValues(["x1","y1"],t),d=n.clampData(v),f=n.clampData(c),h=[];e[0]==="x0"?h[0]=d[0]>f[0]?c[0]:v[0]:h[0]=d[0]>f[0]?v[0]:c[0],e[1]==="y0"?h[1]=d[1]>f[1]?c[1]:v[1]:h[1]=d[1]>f[1]?v[1]:c[1],s=a.getMarkerPosition(h,e,!0)}else{var g=r.get(e[0],t),m=r.get(e[1],t),x=[g,m];n.clampData&&n.clampData(x,x),s=n.dataToPoint(x,!0)}if(Iv(n,"cartesian2d")){var b=n.getAxis("x"),w=n.getAxis("y"),g=r.get(e[0],t),m=r.get(e[1],t);Rm(g)?s[0]=b.toGlobalCoord(b.getExtent()[e[0]==="x0"?0:1]):Rm(m)&&(s[1]=w.toGlobalCoord(w.getExtent()[e[1]==="y0"?0:1]))}isNaN(l)||(s[0]=l),isNaN(u)||(s[1]=u)}return s}var GR=[["x0","y0"],["x1","y0"],["x1","y1"],["x0","y1"]],Lnt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.updateTransform=function(e,a,i){a.eachSeries(function(n){var o=Ws.getMarkerModelFromSeries(n,"markArea");if(o){var s=o.getData();s.each(function(l){var u=pt(GR,function(c){return FR(s,l,c,n,i)});s.setItemLayout(l,u);var v=s.getItemGraphicEl(l);v.setShape("points",u)})}},this)},t.prototype.renderSeries=function(e,a,i,n){var o=e.coordinateSystem,s=e.id,l=e.getData(),u=this.markerGroupMap,v=u.get(s)||u.set(s,{group:new Ae});this.group.add(v.group),this.markKeep(v);var c=Int(o,e,a);a.setData(c),c.each(function(d){var f=pt(GR,function(D){return FR(c,d,D,e,n)}),h=o.getAxis("x").scale,g=o.getAxis("y").scale,m=h.getExtent(),x=g.getExtent(),b=[h.parse(c.get("x0",d)),h.parse(c.get("x1",d))],w=[g.parse(c.get("y0",d)),g.parse(c.get("y1",d))];Un(b),Un(w);var S=!(m[0]>b[1]||m[1]w[1]||x[1]=0},t.prototype.getOrient=function(){return this.get("orient")==="vertical"?{index:1,name:"vertical"}:{index:0,name:"horizontal"}},t.type="legend.plain",t.dependencies=["series"],t.defaultOption={z:4,show:!0,orient:"horizontal",left:"center",top:0,align:"auto",backgroundColor:"rgba(0,0,0,0)",borderColor:"#ccc",borderRadius:0,borderWidth:0,padding:5,itemGap:10,itemWidth:25,itemHeight:14,symbolRotate:"inherit",symbolKeepAspect:!0,inactiveColor:"#ccc",inactiveBorderColor:"#ccc",inactiveBorderWidth:"auto",itemStyle:{color:"inherit",opacity:"inherit",borderColor:"inherit",borderWidth:"auto",borderCap:"inherit",borderJoin:"inherit",borderDashOffset:"inherit",borderMiterLimit:"inherit"},lineStyle:{width:"auto",color:"inherit",inactiveColor:"#ccc",inactiveWidth:2,opacity:"inherit",type:"inherit",cap:"inherit",join:"inherit",dashOffset:"inherit",miterLimit:"inherit"},textStyle:{color:"#333"},selectedMode:!0,selector:!1,selectorLabel:{show:!0,borderRadius:10,padding:[3,5,3,5],fontSize:12,fontFamily:"sans-serif",color:"#666",borderWidth:1,borderColor:"#666"},emphasis:{selectorLabel:{show:!0,color:"#eee",backgroundColor:"#666"}},selectorPosition:"auto",selectorItemGap:7,selectorButtonGap:10,tooltip:{show:!1}},t}(_r),nc=We,O2=R,py=Ae,lB=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.newlineDisabled=!1,e}return t.prototype.init=function(){this.group.add(this._contentGroup=new py),this.group.add(this._selectorGroup=new py),this._isFirstRender=!0},t.prototype.getContentGroup=function(){return this._contentGroup},t.prototype.getSelectorGroup=function(){return this._selectorGroup},t.prototype.render=function(e,a,i){var n=this._isFirstRender;if(this._isFirstRender=!1,this.resetInner(),!!e.get("show",!0)){var o=e.get("align"),s=e.get("orient");(!o||o==="auto")&&(o=e.get("left")==="right"&&s==="vertical"?"right":"left");var l=e.get("selector",!0),u=e.get("selectorPosition",!0);l&&(!u||u==="auto")&&(u=s==="horizontal"?"end":"start"),this.renderInner(o,e,a,i,l,s,u);var v=e.getBoxLayoutParams(),c={width:i.getWidth(),height:i.getHeight()},d=e.get("padding"),f=Xa(v,c,d),h=this.layoutInner(e,o,f,n,l,u),g=Xa(ve({width:h.width,height:h.height},v),c,d);this.group.x=g.x-h.x,this.group.y=g.y-h.y,this.group.markRedraw(),this.group.add(this._backgroundEl=jz(h,e))}},t.prototype.resetInner=function(){this.getContentGroup().removeAll(),this._backgroundEl&&this.group.remove(this._backgroundEl),this.getSelectorGroup().removeAll()},t.prototype.renderInner=function(e,a,i,n,o,s,l){var u=this.getContentGroup(),v=Wt(),c=a.get("selectedMode"),d=[];i.eachRawSeries(function(f){!f.get("legendHoverLink")&&d.push(f.id)}),O2(a.getData(),function(f,h){var g=f.get("name");if(!this.newlineDisabled&&(g===""||g===` +`)){var m=new py;m.newline=!0,u.add(m);return}var x=i.getSeriesByName(g)[0];if(!v.get(g))if(x){var b=x.getData(),w=b.getVisual("legendLineStyle")||{},S=b.getVisual("legendIcon"),C=b.getVisual("style"),T=this._createItem(x,g,h,f,a,e,w,C,S,c,n);T.on("click",nc(HR,g,null,n,d)).on("mouseover",nc(z2,x.name,null,n,d)).on("mouseout",nc(B2,x.name,null,n,d)),i.ssr&&T.eachChild(function(k){var D=Ie(k);D.seriesIndex=x.seriesIndex,D.dataIndex=h,D.ssrType="legend"}),v.set(g,!0)}else i.eachRawSeries(function(k){if(!v.get(g)&&k.legendVisualProvider){var D=k.legendVisualProvider;if(!D.containName(g))return;var M=D.indexOfName(g),L=D.getItemVisual(M,"style"),I=D.getItemVisual(M,"legendIcon"),P=gn(L.fill);P&&P[3]===0&&(P[3]=.2,L=lt(lt({},L),{fill:Ps(P,"rgba")}));var E=this._createItem(k,g,h,f,a,e,{},L,I,c,n);E.on("click",nc(HR,null,g,n,d)).on("mouseover",nc(z2,null,g,n,d)).on("mouseout",nc(B2,null,g,n,d)),i.ssr&&E.eachChild(function(N){var O=Ie(N);O.seriesIndex=k.seriesIndex,O.dataIndex=h,O.ssrType="legend"}),v.set(g,!0)}},this)},this),o&&this._createSelector(o,a,n,s,l)},t.prototype._createSelector=function(e,a,i,n,o){var s=this.getSelectorGroup();O2(e,function(u){var v=u.type,c=new Rr({style:{x:0,y:0,align:"center",verticalAlign:"middle"},onclick:function(){i.dispatchAction({type:v==="all"?"legendAllSelect":"legendInverseSelect",legendId:a.id})}});s.add(c);var d=a.getModel("selectorLabel"),f=a.getModel(["emphasis","selectorLabel"]);mi(c,{normal:d,emphasis:f},{defaultText:u.title}),nv(c)})},t.prototype._createItem=function(e,a,i,n,o,s,l,u,v,c,d){var f=e.visualDrawType,h=o.get("itemWidth"),g=o.get("itemHeight"),m=o.isSelected(a),x=n.get("symbolRotate"),b=n.get("symbolKeepAspect"),w=n.get("icon");v=w||v||"roundRect";var S=Ent(v,n,l,u,f,m,d),C=new py,T=n.getModel("textStyle");if(le(e.getLegendIcon)&&(!w||w==="inherit"))C.add(e.getLegendIcon({itemWidth:h,itemHeight:g,icon:v,iconRotate:x,itemStyle:S.itemStyle,lineStyle:S.lineStyle,symbolKeepAspect:b}));else{var k=w==="inherit"&&e.getData().getVisual("symbol")?x==="inherit"?e.getData().getVisual("symbolRotate"):x:0;C.add(Nnt({itemWidth:h,itemHeight:g,icon:v,iconRotate:k,itemStyle:S.itemStyle,symbolKeepAspect:b}))}var D=s==="left"?h+5:-5,M=s,L=o.get("formatter"),I=a;Pt(L)&&L?I=L.replace("{name}",a??""):le(L)&&(I=L(a));var P=m?T.getTextColor():n.get("inactiveColor");C.add(new Rr({style:ya(T,{text:I,x:D,y:g/2,fill:P,align:M,verticalAlign:"middle"},{inheritColor:P})}));var E=new Mr({shape:C.getBoundingRect(),style:{fill:"transparent"}}),N=n.getModel("tooltip");return N.get("show")&&kv({el:E,componentModel:o,itemName:a,itemTooltipOption:N.option}),C.add(E),C.eachChild(function(O){O.silent=!0}),E.silent=!c,this.getContentGroup().add(C),nv(C),C.__legendDataIndex=i,C},t.prototype.layoutInner=function(e,a,i,n,o,s){var l=this.getContentGroup(),u=this.getSelectorGroup();sv(e.get("orient"),l,e.get("itemGap"),i.width,i.height);var v=l.getBoundingRect(),c=[-v.x,-v.y];if(u.markRedraw(),l.markRedraw(),o){sv("horizontal",u,e.get("selectorItemGap",!0));var d=u.getBoundingRect(),f=[-d.x,-d.y],h=e.get("selectorButtonGap",!0),g=e.getOrient().index,m=g===0?"width":"height",x=g===0?"height":"width",b=g===0?"y":"x";s==="end"?f[g]+=v[m]+h:c[g]+=d[m]+h,f[1-g]+=v[x]/2-d[x]/2,u.x=f[0],u.y=f[1],l.x=c[0],l.y=c[1];var w={x:0,y:0};return w[m]=v[m]+h+d[m],w[x]=Math.max(v[x],d[x]),w[b]=Math.min(0,d[b]+f[1-g]),w}else return l.x=c[0],l.y=c[1],this.group.getBoundingRect()},t.prototype.remove=function(){this.getContentGroup().removeAll(),this._isFirstRender=!0},t.type="legend.plain",t}(Ma);function Ent(r,t,e,a,i,n,o){function s(m,x){m.lineWidth==="auto"&&(m.lineWidth=x.lineWidth>0?2:0),O2(m,function(b,w){m[w]==="inherit"&&(m[w]=x[w])})}var l=t.getModel("itemStyle"),u=l.getItemStyle(),v=r.lastIndexOf("empty",0)===0?"fill":"stroke",c=l.getShallow("decal");u.decal=!c||c==="inherit"?a.decal:Zc(c,o),u.fill==="inherit"&&(u.fill=a[i]),u.stroke==="inherit"&&(u.stroke=a[v]),u.opacity==="inherit"&&(u.opacity=(i==="fill"?a:e).opacity),s(u,a);var d=t.getModel("lineStyle"),f=d.getLineStyle();if(s(f,e),u.fill==="auto"&&(u.fill=a.fill),u.stroke==="auto"&&(u.stroke=a.fill),f.stroke==="auto"&&(f.stroke=a.fill),!n){var h=t.get("inactiveBorderWidth"),g=u[v];u.lineWidth=h==="auto"?a.lineWidth>0&&g?2:0:u.lineWidth,u.fill=t.get("inactiveColor"),u.stroke=t.get("inactiveBorderColor"),f.stroke=d.get("inactiveColor"),f.lineWidth=d.get("inactiveWidth")}return{itemStyle:u,lineStyle:f}}function Nnt(r){var t=r.icon||"roundRect",e=qa(t,0,0,r.itemWidth,r.itemHeight,r.itemStyle.fill,r.symbolKeepAspect);return e.setStyle(r.itemStyle),e.rotation=(r.iconRotate||0)*Math.PI/180,e.setOrigin([r.itemWidth/2,r.itemHeight/2]),t.indexOf("empty")>-1&&(e.style.stroke=e.style.fill,e.style.fill="#fff",e.style.lineWidth=2),e}function HR(r,t,e,a){B2(r,t,e,a),e.dispatchAction({type:"legendToggleSelect",name:r??t}),z2(r,t,e,a)}function uB(r){for(var t=r.getZr().storage.getDisplayList(),e,a=0,i=t.length;ai[o],m=[-f.x,-f.y];a||(m[n]=v[u]);var x=[0,0],b=[-h.x,-h.y],w=Be(e.get("pageButtonGap",!0),e.get("itemGap",!0));if(g){var S=e.get("pageButtonPosition",!0);S==="end"?b[n]+=i[o]-h[o]:x[n]+=h[o]+w}b[1-n]+=f[s]/2-h[s]/2,v.setPosition(m),c.setPosition(x),d.setPosition(b);var C={x:0,y:0};if(C[o]=g?i[o]:f[o],C[s]=Math.max(f[s],h[s]),C[l]=Math.min(0,h[l]+b[1-n]),c.__rectSize=i[o],g){var T={x:0,y:0};T[o]=Math.max(i[o]-h[o]-w,0),T[s]=C[s],c.setClipPath(new Mr({shape:T})),c.__rectSize=T[o]}else d.eachChild(function(D){D.attr({invisible:!0,silent:!0})});var k=this._getPageInfo(e);return k.pageIndex!=null&&Gr(v,{x:k.contentPosition[0],y:k.contentPosition[1]},g?e:null),this._updatePageInfoView(e,k),C},t.prototype._pageGo=function(e,a,i){var n=this._getPageInfo(a)[e];n!=null&&i.dispatchAction({type:"legendScroll",scrollDataIndex:n,legendId:a.id})},t.prototype._updatePageInfoView=function(e,a){var i=this._controllerGroup;R(["pagePrev","pageNext"],function(v){var c=v+"DataIndex",d=a[c]!=null,f=i.childOfName(v);f&&(f.setStyle("fill",d?e.get("pageIconColor",!0):e.get("pageIconInactiveColor",!0)),f.cursor=d?"pointer":"default")});var n=i.childOfName("pageText"),o=e.get("pageFormatter"),s=a.pageIndex,l=s!=null?s+1:0,u=a.pageCount;n&&o&&n.setStyle("text",Pt(o)?o.replace("{current}",l==null?"":l+"").replace("{total}",u==null?"":u+""):o({current:l,total:u}))},t.prototype._getPageInfo=function(e){var a=e.get("scrollDataIndex",!0),i=this.getContentGroup(),n=this._containerGroup.__rectSize,o=e.getOrient().index,s=Mb[o],l=Lb[o],u=this._findTargetItemIndex(a),v=i.children(),c=v[u],d=v.length,f=d?1:0,h={contentPosition:[i.x,i.y],pageCount:f,pageIndex:f-1,pagePrevDataIndex:null,pageNextDataIndex:null};if(!c)return h;var g=S(c);h.contentPosition[o]=-g.s;for(var m=u+1,x=g,b=g,w=null;m<=d;++m)w=S(v[m]),(!w&&b.e>x.s+n||w&&!C(w,x.s))&&(b.i>x.i?x=b:x=w,x&&(h.pageNextDataIndex==null&&(h.pageNextDataIndex=x.i),++h.pageCount)),b=w;for(var m=u-1,x=g,b=g,w=null;m>=-1;--m)w=S(v[m]),(!w||!C(b,w.s))&&x.i=k&&T.s<=k+n}},t.prototype._findTargetItemIndex=function(e){if(!this._showController)return 0;var a,i=this.getContentGroup(),n;return i.eachChild(function(o,s){var l=o.__legendDataIndex;n==null&&l!=null&&(n=s),l===e&&(a=s)}),a??n},t.type="legend.scroll",t}(lB);function Fnt(r){r.registerAction("legendScroll","legendscroll",function(t,e){var a=t.scrollDataIndex;a!=null&&e.eachComponent({mainType:"legend",subType:"scroll",query:t},function(i){i.setScrollDataIndex(a)})})}function Gnt(r){sr(vB),r.registerComponentModel(Bnt),r.registerComponentView(Vnt),Fnt(r)}function Hnt(r){sr(vB),sr(Gnt)}var Wnt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="dataZoom.inside",t.defaultOption=Ul(Ch.defaultOption,{disabled:!1,zoomLock:!1,zoomOnMouseWheel:!0,moveOnMouseMove:!0,moveOnMouseWheel:!1,preventDefaultMouseMove:!0}),t}(Ch),hA=Lr();function Unt(r,t,e){hA(r).coordSysRecordMap.each(function(a){var i=a.dataZoomInfoMap.get(t.uid);i&&(i.getRange=e)})}function qnt(r,t){for(var e=hA(r).coordSysRecordMap,a=e.keys(),i=0;ia[e+t]&&(t=s),i=i&&o.get("preventDefaultMouseMove",!0)}),{controlType:t,opt:{zoomOnMouseWheel:!0,moveOnMouseMove:!0,moveOnMouseWheel:!0,preventDefaultMouseMove:!!i}}}function jnt(r){r.registerProcessor(r.PRIORITY.PROCESSOR.FILTER,function(t,e){var a=hA(e),i=a.coordSysRecordMap||(a.coordSysRecordMap=Wt());i.each(function(n){n.dataZoomInfoMap=null}),t.eachComponent({mainType:"dataZoom",subType:"inside"},function(n){var o=Yz(n);R(o.infoList,function(s){var l=s.model.uid,u=i.get(l)||i.set(l,$nt(e,s.model)),v=u.dataZoomInfoMap||(u.dataZoomInfoMap=Wt());v.set(n.uid,{dzReferCoordSysInfo:s,model:n,getRange:null})})}),i.each(function(n){var o=n.controller,s,l=n.dataZoomInfoMap;if(l){var u=l.keys()[0];u!=null&&(s=l.get(u))}if(!s){cB(i,n);return}var v=Xnt(l);o.enable(v.controlType,v.opt),o.setPointerChecker(n.containsPoint),hd(n,"dispatchAction",s.model.get("throttle",!0),"fixRate")})})}var Knt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type="dataZoom.inside",e}return t.prototype.render=function(e,a,i){if(r.prototype.render.apply(this,arguments),e.noTarget()){this._clear();return}this.range=e.getPercentRange(),Unt(i,e,{pan:Nt(Ib.pan,this),zoom:Nt(Ib.zoom,this),scrollMove:Nt(Ib.scrollMove,this)})},t.prototype.dispose=function(){this._clear(),r.prototype.dispose.apply(this,arguments)},t.prototype._clear=function(){qnt(this.api,this.dataZoomModel),this.range=null},t.type="dataZoom.inside",t}(nA),Ib={zoom:function(r,t,e,a){var i=this.range,n=i.slice(),o=r.axisModels[0];if(o){var s=Rb[t](null,[a.originX,a.originY],o,e,r),l=(s.signal>0?s.pixelStart+s.pixelLength-s.pixel:s.pixel-s.pixelStart)/s.pixelLength*(n[1]-n[0])+n[0],u=Math.max(1/a.scale,0);n[0]=(n[0]-l)*u+l,n[1]=(n[1]-l)*u+l;var v=this.dataZoomModel.findRepresentativeAxisProxy().getMinMaxSpan();if(Pv(0,n,[0,100],0,v.minSpan,v.maxSpan),this.range=n,i[0]!==n[0]||i[1]!==n[1])return n}},pan:$R(function(r,t,e,a,i,n){var o=Rb[a]([n.oldX,n.oldY],[n.newX,n.newY],t,i,e);return o.signal*(r[1]-r[0])*o.pixel/o.pixelLength}),scrollMove:$R(function(r,t,e,a,i,n){var o=Rb[a]([0,0],[n.scrollDelta,n.scrollDelta],t,i,e);return o.signal*(r[1]-r[0])*n.scrollDelta})};function $R(r){return function(t,e,a,i){var n=this.range,o=n.slice(),s=t.axisModels[0];if(s){var l=r(o,s,t,e,a,i);if(Pv(l,o,[0,100],"all"),this.range=o,n[0]!==o[0]||n[1]!==o[1])return o}}}var Rb={grid:function(r,t,e,a,i){var n=e.axis,o={},s=i.model.coordinateSystem.getRect();return r=r||[0,0],n.dim==="x"?(o.pixel=t[0]-r[0],o.pixelLength=s.width,o.pixelStart=s.x,o.signal=n.inverse?1:-1):(o.pixel=t[1]-r[1],o.pixelLength=s.height,o.pixelStart=s.y,o.signal=n.inverse?-1:1),o},polar:function(r,t,e,a,i){var n=e.axis,o={},s=i.model.coordinateSystem,l=s.getRadiusAxis().getExtent(),u=s.getAngleAxis().getExtent();return r=r?s.pointToCoord(r):[0,0],t=s.pointToCoord(t),e.mainType==="radiusAxis"?(o.pixel=t[0]-r[0],o.pixelLength=l[1]-l[0],o.pixelStart=l[0],o.signal=n.inverse?1:-1):(o.pixel=t[1]-r[1],o.pixelLength=u[1]-u[0],o.pixelStart=u[0],o.signal=n.inverse?-1:1),o},singleAxis:function(r,t,e,a,i){var n=e.axis,o=i.model.coordinateSystem.getRect(),s={};return r=r||[0,0],n.orient==="horizontal"?(s.pixel=t[0]-r[0],s.pixelLength=o.width,s.pixelStart=o.x,s.signal=n.inverse?1:-1):(s.pixel=t[1]-r[1],s.pixelLength=o.height,s.pixelStart=o.y,s.signal=n.inverse?-1:1),s}};function dB(r){oA(r),r.registerComponentModel(Wnt),r.registerComponentView(Knt),jnt(r)}var Qnt=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.type="dataZoom.slider",t.layoutMode="box",t.defaultOption=Ul(Ch.defaultOption,{show:!0,right:"ph",top:"ph",width:"ph",height:"ph",left:null,bottom:null,borderColor:"#d2dbee",borderRadius:3,backgroundColor:"rgba(47,69,84,0)",dataBackground:{lineStyle:{color:"#d2dbee",width:.5},areaStyle:{color:"#d2dbee",opacity:.2}},selectedDataBackground:{lineStyle:{color:"#8fb0f7",width:.5},areaStyle:{color:"#8fb0f7",opacity:.2}},fillerColor:"rgba(135,175,274,0.2)",handleIcon:"path://M-9.35,34.56V42m0-40V9.5m-2,0h4a2,2,0,0,1,2,2v21a2,2,0,0,1-2,2h-4a2,2,0,0,1-2-2v-21A2,2,0,0,1-11.35,9.5Z",handleSize:"100%",handleStyle:{color:"#fff",borderColor:"#ACB8D1"},moveHandleSize:7,moveHandleIcon:"path://M-320.9-50L-320.9-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-348-41-339-50-320.9-50z M-212.3-50L-212.3-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-239.4-41-230.4-50-212.3-50z M-103.7-50L-103.7-50c18.1,0,27.1,9,27.1,27.1V85.7c0,18.1-9,27.1-27.1,27.1l0,0c-18.1,0-27.1-9-27.1-27.1V-22.9C-130.9-41-121.8-50-103.7-50z",moveHandleStyle:{color:"#D2DBEE",opacity:.7},showDetail:!0,showDataShadow:"auto",realtime:!0,zoomLock:!1,textStyle:{color:"#6E7079"},brushSelect:!0,brushStyle:{color:"rgba(135,175,274,0.15)"},emphasis:{handleLabel:{show:!0},handleStyle:{borderColor:"#8FB0F7"},moveHandleStyle:{color:"#8FB0F7"}}}),t}(Ch),hf=Mr,YR=7,Jnt=1,Pb=30,tot=7,pf="horizontal",ZR="vertical",eot=5,rot=["line","bar","candlestick","scatter"],aot={easing:"cubicOut",duration:100,delay:0},iot=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e._displayables={},e}return t.prototype.init=function(e,a){this.api=a,this._onBrush=Nt(this._onBrush,this),this._onBrushEnd=Nt(this._onBrushEnd,this)},t.prototype.render=function(e,a,i,n){if(r.prototype.render.apply(this,arguments),hd(this,"_dispatchZoomAction",e.get("throttle"),"fixRate"),this._orient=e.getOrient(),e.get("show")===!1){this.group.removeAll();return}if(e.noTarget()){this._clear(),this.group.removeAll();return}(!n||n.type!=="dataZoom"||n.from!==this.uid)&&this._buildView(),this._updateView()},t.prototype.dispose=function(){this._clear(),r.prototype.dispose.apply(this,arguments)},t.prototype._clear=function(){fh(this,"_dispatchZoomAction");var e=this.api.getZr();e.off("mousemove",this._onBrush),e.off("mouseup",this._onBrushEnd)},t.prototype._buildView=function(){var e=this.group;e.removeAll(),this._brushing=!1,this._displayables.brushRect=null,this._resetLocation(),this._resetInterval();var a=this._displayables.sliderGroup=new Ae;this._renderBackground(),this._renderHandle(),this._renderDataShadow(),e.add(a),this._positionGroup()},t.prototype._resetLocation=function(){var e=this.dataZoomModel,a=this.api,i=e.get("brushSelect"),n=i?tot:0,o=this._findCoordRect(),s={width:a.getWidth(),height:a.getHeight()},l=this._orient===pf?{right:s.width-o.x-o.width,top:s.height-Pb-YR-n,width:o.width,height:Pb}:{right:YR,top:o.y,width:Pb,height:o.height},u=cd(e.option);R(["right","top","width","height"],function(c){u[c]==="ph"&&(u[c]=l[c])});var v=Xa(u,s);this._location={x:v.x,y:v.y},this._size=[v.width,v.height],this._orient===ZR&&this._size.reverse()},t.prototype._positionGroup=function(){var e=this.group,a=this._location,i=this._orient,n=this.dataZoomModel.getFirstTargetAxisModel(),o=n&&n.get("inverse"),s=this._displayables.sliderGroup,l=(this._dataShadowInfo||{}).otherAxisInverse;s.attr(i===pf&&!o?{scaleY:l?1:-1,scaleX:1}:i===pf&&o?{scaleY:l?1:-1,scaleX:-1}:i===ZR&&!o?{scaleY:l?-1:1,scaleX:1,rotation:Math.PI/2}:{scaleY:l?-1:1,scaleX:-1,rotation:Math.PI/2});var u=e.getBoundingRect([s]);e.x=a.x-u.x,e.y=a.y-u.y,e.markRedraw()},t.prototype._getViewExtent=function(){return[0,this._size[0]]},t.prototype._renderBackground=function(){var e=this.dataZoomModel,a=this._size,i=this._displayables.sliderGroup,n=e.get("brushSelect");i.add(new hf({silent:!0,shape:{x:0,y:0,width:a[0],height:a[1]},style:{fill:e.get("backgroundColor")},z2:-40}));var o=new hf({shape:{x:0,y:0,width:a[0],height:a[1]},style:{fill:"transparent"},z2:0,onclick:Nt(this._onClickPanel,this)}),s=this.api.getZr();n?(o.on("mousedown",this._onBrushStart,this),o.cursor="crosshair",s.on("mousemove",this._onBrush),s.on("mouseup",this._onBrushEnd)):(s.off("mousemove",this._onBrush),s.off("mouseup",this._onBrushEnd)),i.add(o)},t.prototype._renderDataShadow=function(){var e=this._dataShadowInfo=this._prepareDataShadowInfo();if(this._displayables.dataShadowSegs=[],!e)return;var a=this._size,i=this._shadowSize||[],n=e.series,o=n.getRawData(),s=n.getShadowDim&&n.getShadowDim(),l=s&&o.getDimensionInfo(s)?n.getShadowDim():e.otherDim;if(l==null)return;var u=this._shadowPolygonPts,v=this._shadowPolylinePts;if(o!==this._shadowData||l!==this._shadowDim||a[0]!==i[0]||a[1]!==i[1]){var c=o.getDataExtent(l),d=(c[1]-c[0])*.3;c=[c[0]-d,c[1]+d];var f=[0,a[1]],h=[0,a[0]],g=[[a[0],0],[0,0]],m=[],x=h[1]/(o.count()-1),b=0,w=Math.round(o.count()/a[0]),S;o.each([l],function(M,L){if(w>0&&L%w){b+=x;return}var I=M==null||isNaN(M)||M==="",P=I?0:ra(M,c,f,!0);I&&!S&&L?(g.push([g[g.length-1][0],0]),m.push([m[m.length-1][0],0])):!I&&S&&(g.push([b,0]),m.push([b,0])),g.push([b,P]),m.push([b,P]),b+=x,S=I}),u=this._shadowPolygonPts=g,v=this._shadowPolylinePts=m}this._shadowData=o,this._shadowDim=l,this._shadowSize=[a[0],a[1]];var C=this.dataZoomModel;function T(M){var L=C.getModel(M?"selectedDataBackground":"dataBackground"),I=new Ae,P=new Hi({shape:{points:u},segmentIgnoreThreshold:1,style:L.getModel("areaStyle").getAreaStyle(),silent:!0,z2:-20}),E=new Ui({shape:{points:v},segmentIgnoreThreshold:1,style:L.getModel("lineStyle").getLineStyle(),silent:!0,z2:-19});return I.add(P),I.add(E),I}for(var k=0;k<3;k++){var D=T(k===1);this._displayables.sliderGroup.add(D),this._displayables.dataShadowSegs.push(D)}},t.prototype._prepareDataShadowInfo=function(){var e=this.dataZoomModel,a=e.get("showDataShadow");if(a!==!1){var i,n=this.ecModel;return e.eachTargetAxis(function(o,s){var l=e.getAxisProxy(o,s).getTargetSeriesModels();R(l,function(u){if(!i&&!(a!==!0&&ir(rot,u.get("type"))<0)){var v=n.getComponent(Al(o),s).axis,c=not(o),d,f=u.coordinateSystem;c!=null&&f.getOtherAxis&&(d=f.getOtherAxis(v).inverse),c=u.getData().mapDimension(c),i={thisAxis:v,series:u,thisDim:o,otherDim:c,otherAxisInverse:d}}},this)},this),i}},t.prototype._renderHandle=function(){var e=this.group,a=this._displayables,i=a.handles=[null,null],n=a.handleLabels=[null,null],o=this._displayables.sliderGroup,s=this._size,l=this.dataZoomModel,u=this.api,v=l.get("borderRadius")||0,c=l.get("brushSelect"),d=a.filler=new hf({silent:c,style:{fill:l.get("fillerColor")},textConfig:{position:"inside"}});o.add(d),o.add(new hf({silent:!0,subPixelOptimize:!0,shape:{x:0,y:0,width:s[0],height:s[1],r:v},style:{stroke:l.get("dataBackgroundColor")||l.get("borderColor"),lineWidth:Jnt,fill:"rgba(0,0,0,0)"}})),R([0,1],function(w){var S=l.get("handleIcon");!um[S]&&S.indexOf("path://")<0&&S.indexOf("image://")<0&&(S="path://"+S);var C=qa(S,-1,0,2,2,null,!0);C.attr({cursor:XR(this._orient),draggable:!0,drift:Nt(this._onDragMove,this,w),ondragend:Nt(this._onDragEnd,this),onmouseover:Nt(this._showDataInfo,this,!0),onmouseout:Nt(this._showDataInfo,this,!1),z2:5});var T=C.getBoundingRect(),k=l.get("handleSize");this._handleHeight=Lt(k,this._size[1]),this._handleWidth=T.width/T.height*this._handleHeight,C.setStyle(l.getModel("handleStyle").getItemStyle()),C.style.strokeNoScale=!0,C.rectHover=!0,C.ensureState("emphasis").style=l.getModel(["emphasis","handleStyle"]).getItemStyle(),nv(C);var D=l.get("handleColor");D!=null&&(C.style.fill=D),o.add(i[w]=C);var M=l.getModel("textStyle"),L=l.get("handleLabel")||{},I=L.show||!1;e.add(n[w]=new Rr({silent:!0,invisible:!I,style:ya(M,{x:0,y:0,text:"",verticalAlign:"middle",align:"center",fill:M.getTextColor(),font:M.getFont()}),z2:10}))},this);var f=d;if(c){var h=Lt(l.get("moveHandleSize"),s[1]),g=a.moveHandle=new Mr({style:l.getModel("moveHandleStyle").getItemStyle(),silent:!0,shape:{r:[0,0,2,2],y:s[1]-.5,height:h}}),m=h*.8,x=a.moveHandleIcon=qa(l.get("moveHandleIcon"),-m/2,-m/2,m,m,"#fff",!0);x.silent=!0,x.y=s[1]+h/2-.5,g.ensureState("emphasis").style=l.getModel(["emphasis","moveHandleStyle"]).getItemStyle();var b=Math.min(s[1]/2,Math.max(h,10));f=a.moveZone=new Mr({invisible:!0,shape:{y:s[1]-b,height:h+b}}),f.on("mouseover",function(){u.enterEmphasis(g)}).on("mouseout",function(){u.leaveEmphasis(g)}),o.add(g),o.add(x),o.add(f)}f.attr({draggable:!0,cursor:XR(this._orient),drift:Nt(this._onDragMove,this,"all"),ondragstart:Nt(this._showDataInfo,this,!0),ondragend:Nt(this._onDragEnd,this),onmouseover:Nt(this._showDataInfo,this,!0),onmouseout:Nt(this._showDataInfo,this,!1)})},t.prototype._resetInterval=function(){var e=this._range=this.dataZoomModel.getPercentRange(),a=this._getViewExtent();this._handleEnds=[ra(e[0],[0,100],a,!0),ra(e[1],[0,100],a,!0)]},t.prototype._updateInterval=function(e,a){var i=this.dataZoomModel,n=this._handleEnds,o=this._getViewExtent(),s=i.findRepresentativeAxisProxy().getMinMaxSpan(),l=[0,100];Pv(a,n,o,i.get("zoomLock")?"all":e,s.minSpan!=null?ra(s.minSpan,l,o,!0):null,s.maxSpan!=null?ra(s.maxSpan,l,o,!0):null);var u=this._range,v=this._range=Un([ra(n[0],o,l,!0),ra(n[1],o,l,!0)]);return!u||u[0]!==v[0]||u[1]!==v[1]},t.prototype._updateView=function(e){var a=this._displayables,i=this._handleEnds,n=Un(i.slice()),o=this._size;R([0,1],function(f){var h=a.handles[f],g=this._handleHeight;h.attr({scaleX:g/2,scaleY:g/2,x:i[f]+(f?-1:1),y:o[1]/2-g/2})},this),a.filler.setShape({x:n[0],y:0,width:n[1]-n[0],height:o[1]});var s={x:n[0],width:n[1]-n[0]};a.moveHandle&&(a.moveHandle.setShape(s),a.moveZone.setShape(s),a.moveZone.getBoundingRect(),a.moveHandleIcon&&a.moveHandleIcon.attr("x",s.x+s.width/2));for(var l=a.dataShadowSegs,u=[0,n[0],n[1],o[0]],v=0;va[0]||i[1]<0||i[1]>a[1])){var n=this._handleEnds,o=(n[0]+n[1])/2,s=this._updateInterval("all",i[0]-o);this._updateView(),s&&this._dispatchZoomAction(!1)}},t.prototype._onBrushStart=function(e){var a=e.offsetX,i=e.offsetY;this._brushStart=new Je(a,i),this._brushing=!0,this._brushStartTime=+new Date},t.prototype._onBrushEnd=function(e){if(this._brushing){var a=this._displayables.brushRect;if(this._brushing=!1,!!a){a.attr("ignore",!0);var i=a.shape,n=+new Date;if(!(n-this._brushStartTime<200&&Math.abs(i.width)<5)){var o=this._getViewExtent(),s=[0,100];this._range=Un([ra(i.x,o,s,!0),ra(i.x+i.width,o,s,!0)]),this._handleEnds=[i.x,i.x+i.width],this._updateView(),this._dispatchZoomAction(!1)}}}},t.prototype._onBrush=function(e){this._brushing&&(Os(e.event),this._updateBrushRect(e.offsetX,e.offsetY))},t.prototype._updateBrushRect=function(e,a){var i=this._displayables,n=this.dataZoomModel,o=i.brushRect;o||(o=i.brushRect=new hf({silent:!0,style:n.getModel("brushStyle").getItemStyle()}),i.sliderGroup.add(o)),o.attr("ignore",!1);var s=this._brushStart,l=this._displayables.sliderGroup,u=l.transformCoordToLocal(e,a),v=l.transformCoordToLocal(s.x,s.y),c=this._size;u[0]=Math.max(Math.min(c[0],u[0]),0),o.setShape({x:v[0],y:0,width:u[0]-v[0],height:c[1]})},t.prototype._dispatchZoomAction=function(e){var a=this._range;this.api.dispatchAction({type:"dataZoom",from:this.uid,dataZoomId:this.dataZoomModel.id,animation:e?aot:null,start:a[0],end:a[1]})},t.prototype._findCoordRect=function(){var e,a=Yz(this.dataZoomModel).infoList;if(!e&&a.length){var i=a[0].model.coordinateSystem;e=i.getRect&&i.getRect()}if(!e){var n=this.api.getWidth(),o=this.api.getHeight();e={x:n*.2,y:o*.2,width:n*.6,height:o*.6}}return e},t.type="dataZoom.slider",t}(nA);function not(r){var t={x:"y",y:"x",radius:"angle",angle:"radius"};return t[r]}function XR(r){return r==="vertical"?"ns-resize":"ew-resize"}function fB(r){r.registerComponentModel(Qnt),r.registerComponentView(iot),oA(r)}function oot(r){sr(dB),sr(fB)}var hB={get:function(r,t,e){var a=be((sot[r]||{})[t]);return e&&ht(a)?a[a.length-1]:a}},sot={color:{active:["#006edd","#e0ffff"],inactive:["rgba(0,0,0,0)"]},colorHue:{active:[0,360],inactive:[0,0]},colorSaturation:{active:[.3,1],inactive:[0,0]},colorLightness:{active:[.9,.5],inactive:[0,0]},colorAlpha:{active:[.3,1],inactive:[0,0]},opacity:{active:[.3,1],inactive:[0,0]},symbol:{active:["circle","roundRect","diamond"],inactive:["none"]},symbolSize:{active:[10,50],inactive:[0,0]}},jR=ei.mapVisual,lot=ei.eachVisual,uot=ht,KR=R,vot=Un,cot=ra,Pm=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e.stateList=["inRange","outOfRange"],e.replacableOptionKeys=["inRange","outOfRange","target","controller","color"],e.layoutMode={type:"box",ignoreSize:!0},e.dataBound=[-1/0,1/0],e.targetVisuals={},e.controllerVisuals={},e}return t.prototype.init=function(e,a,i){this.mergeDefaultAndTheme(e,i)},t.prototype.optionUpdated=function(e,a){var i=this.option;!a&&aB(i,e,this.replacableOptionKeys),this.textStyleModel=this.getModel("textStyle"),this.resetItemSize(),this.completeVisualOption()},t.prototype.resetVisual=function(e){var a=this.stateList;e=Nt(e,this),this.controllerVisuals=R2(this.option.controller,a,e),this.targetVisuals=R2(this.option.target,a,e)},t.prototype.getItemSymbol=function(){return null},t.prototype.getTargetSeriesIndices=function(){var e=this.option.seriesIndex,a=[];return e==null||e==="all"?this.ecModel.eachSeries(function(i,n){a.push(n)}):a=la(e),a},t.prototype.eachTargetSeries=function(e,a){R(this.getTargetSeriesIndices(),function(i){var n=this.ecModel.getSeriesByIndex(i);n&&e.call(a,n)},this)},t.prototype.isTargetSeries=function(e){var a=!1;return this.eachTargetSeries(function(i){i===e&&(a=!0)}),a},t.prototype.formatValueText=function(e,a,i){var n=this.option,o=n.precision,s=this.dataBound,l=n.formatter,u;i=i||["<",">"],ht(e)&&(e=e.slice(),u=!0);var v=a?e:u?[c(e[0]),c(e[1])]:c(e);if(Pt(l))return l.replace("{value}",u?v[0]:v).replace("{value2}",u?v[1]:v);if(le(l))return u?l(e[0],e[1]):l(e);if(u)return e[0]===s[0]?i[0]+" "+v[1]:e[1]===s[1]?i[1]+" "+v[0]:v[0]+" - "+v[1];return v;function c(d){return d===s[0]?"min":d===s[1]?"max":(+d).toFixed(Math.min(o,20))}},t.prototype.resetExtent=function(){var e=this.option,a=vot([e.min,e.max]);this._dataExtent=a},t.prototype.getDataDimensionIndex=function(e){var a=this.option.dimension;if(a!=null)return e.getDimensionIndex(a);for(var i=e.dimensions,n=i.length-1;n>=0;n--){var o=i[n],s=e.getDimensionInfo(o);if(!s.isCalculationCoord)return s.storeDimIndex}},t.prototype.getExtent=function(){return this._dataExtent.slice()},t.prototype.completeVisualOption=function(){var e=this.ecModel,a=this.option,i={inRange:a.inRange,outOfRange:a.outOfRange},n=a.target||(a.target={}),o=a.controller||(a.controller={});Ze(n,i),Ze(o,i);var s=this.isCategory();l.call(this,n),l.call(this,o),u.call(this,n,"inRange","outOfRange"),v.call(this,o);function l(c){uot(a.color)&&!c.inRange&&(c.inRange={color:a.color.slice().reverse()}),c.inRange=c.inRange||{color:e.get("gradientColor")}}function u(c,d,f){var h=c[d],g=c[f];h&&!g&&(g=c[f]={},KR(h,function(m,x){if(ei.isValidType(x)){var b=hB.get(x,"inactive",s);b!=null&&(g[x]=b,x==="color"&&!g.hasOwnProperty("opacity")&&!g.hasOwnProperty("colorAlpha")&&(g.opacity=[0,0]))}}))}function v(c){var d=(c.inRange||{}).symbol||(c.outOfRange||{}).symbol,f=(c.inRange||{}).symbolSize||(c.outOfRange||{}).symbolSize,h=this.get("inactiveColor"),g=this.getItemSymbol(),m=g||"roundRect";KR(this.stateList,function(x){var b=this.itemSize,w=c[x];w||(w=c[x]={color:s?h:[h]}),w.symbol==null&&(w.symbol=d&&be(d)||(s?m:[m])),w.symbolSize==null&&(w.symbolSize=f&&be(f)||(s?b[0]:[b[0],b[0]])),w.symbol=jR(w.symbol,function(T){return T==="none"?m:T});var S=w.symbolSize;if(S!=null){var C=-1/0;lot(S,function(T){T>C&&(C=T)}),w.symbolSize=jR(S,function(T){return cot(T,[0,C],[0,b[0]],!0)})}},this)}},t.prototype.resetItemSize=function(){this.itemSize=[parseFloat(this.get("itemWidth")),parseFloat(this.get("itemHeight"))]},t.prototype.isCategory=function(){return!!this.option.categories},t.prototype.setSelected=function(e){},t.prototype.getSelected=function(){return null},t.prototype.getValueState=function(e){return null},t.prototype.getVisualMeta=function(e){return null},t.type="visualMap",t.dependencies=["series"],t.defaultOption={show:!0,z:4,seriesIndex:"all",min:0,max:200,left:0,right:null,top:null,bottom:0,itemWidth:null,itemHeight:null,inverse:!1,orient:"vertical",backgroundColor:"rgba(0,0,0,0)",borderColor:"#ccc",contentColor:"#5793f3",inactiveColor:"#aaa",borderWidth:0,padding:5,textGap:10,precision:0,textStyle:{color:"#333"}},t}(_r),QR=[20,140],dot=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.optionUpdated=function(e,a){r.prototype.optionUpdated.apply(this,arguments),this.resetExtent(),this.resetVisual(function(i){i.mappingMethod="linear",i.dataExtent=this.getExtent()}),this._resetRange()},t.prototype.resetItemSize=function(){r.prototype.resetItemSize.apply(this,arguments);var e=this.itemSize;(e[0]==null||isNaN(e[0]))&&(e[0]=QR[0]),(e[1]==null||isNaN(e[1]))&&(e[1]=QR[1])},t.prototype._resetRange=function(){var e=this.getExtent(),a=this.option.range;!a||a.auto?(e.auto=1,this.option.range=e):ht(a)&&(a[0]>a[1]&&a.reverse(),a[0]=Math.max(a[0],e[0]),a[1]=Math.min(a[1],e[1]))},t.prototype.completeVisualOption=function(){r.prototype.completeVisualOption.apply(this,arguments),R(this.stateList,function(e){var a=this.option.controller[e].symbolSize;a&&a[0]!==a[1]&&(a[0]=a[1]/3)},this)},t.prototype.setSelected=function(e){this.option.range=e.slice(),this._resetRange()},t.prototype.getSelected=function(){var e=this.getExtent(),a=Un((this.get("range")||[]).slice());return a[0]>e[1]&&(a[0]=e[1]),a[1]>e[1]&&(a[1]=e[1]),a[0]=i[1]||e<=a[1])?"inRange":"outOfRange"},t.prototype.findTargetDataIndices=function(e){var a=[];return this.eachTargetSeries(function(i){var n=[],o=i.getData();o.each(this.getDataDimensionIndex(o),function(s,l){e[0]<=s&&s<=e[1]&&n.push(l)},this),a.push({seriesId:i.id,dataIndex:n})},this),a},t.prototype.getVisualMeta=function(e){var a=JR(this,"outOfRange",this.getExtent()),i=JR(this,"inRange",this.option.range.slice()),n=[];function o(f,h){n.push({value:f,color:e(f,h)})}for(var s=0,l=0,u=i.length,v=a.length;le[1])break;n.push({color:this.getControllerVisual(l,"color",a),offset:s/i})}return n.push({color:this.getControllerVisual(e[1],"color",a),offset:1}),n},t.prototype._createBarPoints=function(e,a){var i=this.visualMapModel.itemSize;return[[i[0]-a[0],e[0]],[i[0],e[0]],[i[0],e[1]],[i[0]-a[1],e[1]]]},t.prototype._createBarGroup=function(e){var a=this._orient,i=this.visualMapModel.get("inverse");return new Ae(a==="horizontal"&&!i?{scaleX:e==="bottom"?1:-1,rotation:Math.PI/2}:a==="horizontal"&&i?{scaleX:e==="bottom"?-1:1,rotation:-Math.PI/2}:a==="vertical"&&!i?{scaleX:e==="left"?1:-1,scaleY:-1}:{scaleX:e==="left"?1:-1})},t.prototype._updateHandle=function(e,a){if(this._useHandle){var i=this._shapes,n=this.visualMapModel,o=i.handleThumbs,s=i.handleLabels,l=n.itemSize,u=n.getExtent(),v=this._applyTransform("left",i.mainGroup);fot([0,1],function(c){var d=o[c];d.setStyle("fill",a.handlesColor[c]),d.y=e[c];var f=Fo(e[c],[0,l[1]],u,!0),h=this.getControllerVisual(f,"symbolSize");d.scaleX=d.scaleY=h/l[0],d.x=l[0]-h/2;var g=wo(i.handleLabelPoints[c],ov(d,this.group));if(this._orient==="horizontal"){var m=v==="left"||v==="top"?(l[0]-h)/2:(l[0]-h)/-2;g[1]+=m}s[c].setStyle({x:g[0],y:g[1],text:n.formatValueText(this._dataInterval[c]),verticalAlign:"middle",align:this._orient==="vertical"?this._applyTransform("left",i.mainGroup):"center"})},this)}},t.prototype._showIndicator=function(e,a,i,n){var o=this.visualMapModel,s=o.getExtent(),l=o.itemSize,u=[0,l[1]],v=this._shapes,c=v.indicator;if(c){c.attr("invisible",!1);var d={convertOpacityToAlpha:!0},f=this.getControllerVisual(e,"color",d),h=this.getControllerVisual(e,"symbolSize"),g=Fo(e,s,u,!0),m=l[0]-h/2,x={x:c.x,y:c.y};c.y=g,c.x=m;var b=wo(v.indicatorLabelPoint,ov(c,this.group)),w=v.indicatorLabel;w.attr("invisible",!1);var S=this._applyTransform("left",v.mainGroup),C=this._orient,T=C==="horizontal";w.setStyle({text:(i||"")+o.formatValueText(a),verticalAlign:T?S:"middle",align:T?"center":S});var k={x:m,y:g,style:{fill:f}},D={style:{x:b[0],y:b[1]}};if(o.ecModel.isAnimationEnabled()&&!this._firstShowIndicator){var M={duration:100,easing:"cubicInOut",additive:!0};c.x=x.x,c.y=x.y,c.animateTo(k,M),w.animateTo(D,M)}else c.attr(k),w.attr(D);this._firstShowIndicator=!1;var L=this._shapes.handleLabels;if(L)for(var I=0;Io[1]&&(c[1]=1/0),a&&(c[0]===-1/0?this._showIndicator(v,c[1],"< ",l):c[1]===1/0?this._showIndicator(v,c[0],"> ",l):this._showIndicator(v,v,"≈ ",l));var d=this._hoverLinkDataIndices,f=[];(a||aP(i))&&(f=this._hoverLinkDataIndices=i.findTargetDataIndices(c));var h=b9(d,f);this._dispatchHighDown("downplay",Hy(h[0],i)),this._dispatchHighDown("highlight",Hy(h[1],i))}},t.prototype._hoverLinkFromSeriesMouseOver=function(e){var a;if(Hu(e.target,function(l){var u=Ie(l);if(u.dataIndex!=null)return a=u,!0},!0),!!a){var i=this.ecModel.getSeriesByIndex(a.seriesIndex),n=this.visualMapModel;if(n.isTargetSeries(i)){var o=i.getData(a.dataType),s=o.getStore().get(n.getDataDimensionIndex(o),a.dataIndex);isNaN(s)||this._showIndicator(s,s)}}},t.prototype._hideIndicator=function(){var e=this._shapes;e.indicator&&e.indicator.attr("invisible",!0),e.indicatorLabel&&e.indicatorLabel.attr("invisible",!0);var a=this._shapes.handleLabels;if(a)for(var i=0;i=0&&(n.dimension=o,a.push(n))}}),r.getData().setVisual("visualMeta",a)}}];function bot(r,t,e,a){for(var i=t.targetVisuals[a],n=ei.prepareVisualTypes(i),o={color:Uh(r.getData(),"color")},s=0,l=n.length;s0:t.splitNumber>0)||t.calculable)?"continuous":"piecewise"}),r.registerAction(mot,_ot),R(xot,function(t){r.registerVisual(r.PRIORITY.VISUAL.COMPONENT,t)}),r.registerPreprocessor(wot))}function mB(r){r.registerComponentModel(dot),r.registerComponentView(got),yB(r)}var Sot=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e._pieceList=[],e}return t.prototype.optionUpdated=function(e,a){r.prototype.optionUpdated.apply(this,arguments),this.resetExtent();var i=this._mode=this._determineMode();this._pieceList=[],Tot[this._mode].call(this,this._pieceList),this._resetSelected(e,a);var n=this.option.categories;this.resetVisual(function(o,s){i==="categories"?(o.mappingMethod="category",o.categories=be(n)):(o.dataExtent=this.getExtent(),o.mappingMethod="piecewise",o.pieceList=pt(this._pieceList,function(l){return l=be(l),s!=="inRange"&&(l.visual=null),l}))})},t.prototype.completeVisualOption=function(){var e=this.option,a={},i=ei.listVisualTypes(),n=this.isCategory();R(e.pieces,function(s){R(i,function(l){s.hasOwnProperty(l)&&(a[l]=1)})}),R(a,function(s,l){var u=!1;R(this.stateList,function(v){u=u||o(e,v,l)||o(e.target,v,l)},this),!u&&R(this.stateList,function(v){(e[v]||(e[v]={}))[l]=hB.get(l,v==="inRange"?"active":"inactive",n)})},this);function o(s,l,u){return s&&s[l]&&s[l].hasOwnProperty(u)}r.prototype.completeVisualOption.apply(this,arguments)},t.prototype._resetSelected=function(e,a){var i=this.option,n=this._pieceList,o=(a?i:e).selected||{};if(i.selected=o,R(n,function(l,u){var v=this.getSelectedMapKey(l);o.hasOwnProperty(v)||(o[v]=!0)},this),i.selectedMode==="single"){var s=!1;R(n,function(l,u){var v=this.getSelectedMapKey(l);o[v]&&(s?o[v]=!1:s=!0)},this)}},t.prototype.getItemSymbol=function(){return this.get("itemSymbol")},t.prototype.getSelectedMapKey=function(e){return this._mode==="categories"?e.value+"":e.index+""},t.prototype.getPieceList=function(){return this._pieceList},t.prototype._determineMode=function(){var e=this.option;return e.pieces&&e.pieces.length>0?"pieces":this.option.categories?"categories":"splitNumber"},t.prototype.setSelected=function(e){this.option.selected=be(e)},t.prototype.getValueState=function(e){var a=ei.findPieceIndex(e,this._pieceList);return a!=null&&this.option.selected[this.getSelectedMapKey(this._pieceList[a])]?"inRange":"outOfRange"},t.prototype.findTargetDataIndices=function(e){var a=[],i=this._pieceList;return this.eachTargetSeries(function(n){var o=[],s=n.getData();s.each(this.getDataDimensionIndex(s),function(l,u){var v=ei.findPieceIndex(l,i);v===e&&o.push(u)},this),a.push({seriesId:n.id,dataIndex:o})},this),a},t.prototype.getRepresentValue=function(e){var a;if(this.isCategory())a=e.value;else if(e.value!=null)a=e.value;else{var i=e.interval||[];a=i[0]===-1/0&&i[1]===1/0?0:(i[0]+i[1])/2}return a},t.prototype.getVisualMeta=function(e){if(this.isCategory())return;var a=[],i=["",""],n=this;function o(v,c){var d=n.getRepresentValue({interval:v});c||(c=n.getValueState(d));var f=e(d,c);v[0]===-1/0?i[0]=f:v[1]===1/0?i[1]=f:a.push({value:v[0],color:f},{value:v[1],color:f})}var s=this._pieceList.slice();if(!s.length)s.push({interval:[-1/0,1/0]});else{var l=s[0].interval[0];l!==-1/0&&s.unshift({interval:[-1/0,l]}),l=s[s.length-1].interval[1],l!==1/0&&s.push({interval:[l,1/0]})}var u=-1/0;return R(s,function(v){var c=v.interval;c&&(c[0]>u&&o([u,c[0]],"outOfRange"),o(c.slice()),u=c[1])},this),{stops:a,outerColors:i}},t.type="visualMap.piecewise",t.defaultOption=Ul(Pm.defaultOption,{selected:null,minOpen:!1,maxOpen:!1,align:"auto",itemWidth:20,itemHeight:14,itemSymbol:"roundRect",pieces:null,categories:null,splitNumber:5,selectedMode:"multiple",itemGap:10,hoverLink:!0}),t}(Pm),Tot={splitNumber:function(r){var t=this.option,e=Math.min(t.precision,20),a=this.getExtent(),i=t.splitNumber;i=Math.max(parseInt(i,10),1),t.splitNumber=i;for(var n=(a[1]-a[0])/i;+n.toFixed(e)!==n&&e<5;)e++;t.precision=e,n=+n.toFixed(e),t.minOpen&&r.push({interval:[-1/0,a[0]],close:[0,0]});for(var o=0,s=a[0];o","≥"][a[0]]];e.text=e.text||this.formatValueText(e.value!=null?e.value:e.interval,!1,i)},this)}};function sP(r,t){var e=r.inverse;(r.orient==="vertical"?!e:e)&&t.reverse()}var Aot=function(r){tt(t,r);function t(){var e=r!==null&&r.apply(this,arguments)||this;return e.type=t.type,e}return t.prototype.doRender=function(){var e=this.group;e.removeAll();var a=this.visualMapModel,i=a.get("textGap"),n=a.textStyleModel,o=n.getFont(),s=n.getTextColor(),l=this._getItemAlign(),u=a.itemSize,v=this._getViewData(),c=v.endsText,d=si(a.get("showLabel",!0),!c),f=!a.get("selectedMode");c&&this._renderEndsText(e,c[0],u,d,l),R(v.viewPieceList,function(h){var g=h.piece,m=new Ae;m.onclick=Nt(this._onItemClick,this,g),this._enableHoverLink(m,h.indexInModelPieceList);var x=a.getRepresentValue(g);if(this._createItemSymbol(m,x,[0,0,u[0],u[1]],f),d){var b=this.visualMapModel.getValueState(x);m.add(new Rr({style:{x:l==="right"?-i:u[0]+i,y:u[1]/2,text:g.text,verticalAlign:"middle",align:l,font:o,fill:s,opacity:b==="outOfRange"?.5:1},silent:f}))}e.add(m)},this),c&&this._renderEndsText(e,c[1],u,d,l),sv(a.get("orient"),e,a.get("itemGap")),this.renderBackground(e),this.positionGroup(e)},t.prototype._enableHoverLink=function(e,a){var i=this;e.on("mouseover",function(){return n("highlight")}).on("mouseout",function(){return n("downplay")});var n=function(o){var s=i.visualMapModel;s.option.hoverLink&&i.api.dispatchAction({type:o,batch:Hy(s.findTargetDataIndices(a),s)})}},t.prototype._getItemAlign=function(){var e=this.visualMapModel,a=e.option;if(a.orient==="vertical")return gB(e,this.api,e.itemSize);var i=a.align;return(!i||i==="auto")&&(i="left"),i},t.prototype._renderEndsText=function(e,a,i,n,o){if(a){var s=new Ae,l=this.visualMapModel.textStyleModel;s.add(new Rr({style:ya(l,{x:n?o==="right"?i[0]:0:i[0]/2,y:i[1]/2,verticalAlign:"middle",align:n?o:"center",text:a})})),e.add(s)}},t.prototype._getViewData=function(){var e=this.visualMapModel,a=pt(e.getPieceList(),function(s,l){return{piece:s,indexInModelPieceList:l}}),i=e.get("text"),n=e.get("orient"),o=e.get("inverse");return(n==="horizontal"?o:!o)?a.reverse():i&&(i=i.slice().reverse()),{viewPieceList:a,endsText:i}},t.prototype._createItemSymbol=function(e,a,i,n){var o=qa(this.getControllerVisual(a,"symbol"),i[0],i[1],i[2],i[3],this.getControllerVisual(a,"color"));o.silent=n,e.add(o)},t.prototype._onItemClick=function(e){var a=this.visualMapModel,i=a.option,n=i.selectedMode;if(n){var o=be(i.selected),s=a.getSelectedMapKey(e);n==="single"||n===!0?(o[s]=!0,R(o,function(l,u){o[u]=u===s})):o[s]=!o[s],this.api.dispatchAction({type:"selectDataRange",from:this.uid,visualMapId:this.visualMapModel.id,selected:o})}},t.type="visualMap.piecewise",t}(pB);function _B(r){r.registerComponentModel(Sot),r.registerComponentView(Aot),yB(r)}function Cot(r){sr(mB),sr(_B)}var kot={label:{enabled:!0},decal:{show:!1}},lP=Lr(),Dot={};function Mot(r,t){var e=r.getModel("aria");if(!e.get("enabled"))return;var a=be(kot);Ze(a.label,r.getLocaleModel().get("aria"),!1),Ze(e.option,a,!1),i(),n();function i(){var u=e.getModel("decal"),v=u.get("show");if(v){var c=Wt();r.eachSeries(function(d){if(!d.isColorBySeries()){var f=c.get(d.type);f||(f={},c.set(d.type,f)),lP(d).scope=f}}),r.eachRawSeries(function(d){if(r.isSeriesFiltered(d))return;if(le(d.enableAriaDecal)){d.enableAriaDecal();return}var f=d.getData();if(d.isColorBySeries()){var b=Bw(d.ecModel,d.name,Dot,r.getSeriesCount()),w=f.getVisual("decal");f.setVisual("decal",S(w,b))}else{var h=d.getRawData(),g={},m=lP(d).scope;f.each(function(C){var T=f.getRawIndex(C);g[T]=C});var x=h.count();h.each(function(C){var T=g[C],k=h.getName(C)||C+"",D=Bw(d.ecModel,k,m,x),M=f.getItemVisual(T,"decal");f.setItemVisual(T,"decal",S(M,D))})}function S(C,T){var k=C?lt(lt({},T),C):T;return k.dirty=!0,k}})}}function n(){var u=t.getZr().dom;if(u){var v=r.getLocaleModel().get("aria"),c=e.getModel("label");if(c.option=ve(c.option,v),!!c.get("enabled")){if(u.setAttribute("role","img"),c.get("description")){u.setAttribute("aria-label",c.get("description"));return}var d=r.getSeriesCount(),f=c.get(["data","maxCount"])||10,h=c.get(["series","maxCount"])||10,g=Math.min(d,h),m;if(!(d<1)){var x=s();if(x){var b=c.get(["general","withTitle"]);m=o(b,{title:x})}else m=c.get(["general","withoutTitle"]);var w=[],S=d>1?c.get(["series","multiple","prefix"]):c.get(["series","single","prefix"]);m+=o(S,{seriesCount:d}),r.eachSeries(function(D,M){if(M1?c.get(["series","multiple",P]):c.get(["series","single",P]),L=o(L,{seriesId:D.seriesIndex,seriesName:D.get("name"),seriesType:l(D.subType)});var E=D.getData();if(E.count()>f){var N=c.get(["data","partialData"]);L+=o(N,{displayCnt:f})}else L+=c.get(["data","allData"]);for(var O=c.get(["data","separator","middle"]),V=c.get(["data","separator","end"]),B=c.get(["data","excludeDimensionId"]),z=[],H=0;H":"gt",">=":"gte","=":"eq","!=":"ne","<>":"ne"},Rot=function(){function r(t){var e=this._condVal=Pt(t)?new RegExp(t):GG(t)?t:null;if(e==null){var a="";Jr(a)}}return r.prototype.evaluate=function(t){var e=typeof t;return Pt(e)?this._condVal.test(t):Or(e)?this._condVal.test(t+""):!1},r}(),Pot=function(){function r(){}return r.prototype.evaluate=function(){return this.value},r}(),Eot=function(){function r(){}return r.prototype.evaluate=function(){for(var t=this.children,e=0;e2&&a.push(i),i=[E,N]}function v(E,N,O,V){mc(E,O)&&mc(N,V)||i.push(E,N,O,V,O,V)}function c(E,N,O,V,B,z){var H=Math.abs(N-E),Y=Math.tan(H/4)*4/3,$=ND:I2&&a.push(i),a}function F2(r,t,e,a,i,n,o,s,l,u){if(mc(r,e)&&mc(t,a)&&mc(i,o)&&mc(n,s)){l.push(o,s);return}var v=2/u,c=v*v,d=o-r,f=s-t,h=Math.sqrt(d*d+f*f);d/=h,f/=h;var g=e-r,m=a-t,x=i-o,b=n-s,w=g*g+m*m,S=x*x+b*b;if(w=0&&D=0){l.push(o,s);return}var M=[],L=[];Nl(r,e,i,o,.5,M),Nl(t,a,n,s,.5,L),F2(M[0],L[0],M[1],L[1],M[2],L[2],M[3],L[3],l,u),F2(M[4],L[4],M[5],L[5],M[6],L[6],M[7],L[7],l,u)}function Zot(r,t){var e=V2(r),a=[];t=t||1;for(var i=0;i0)for(var u=0;uMath.abs(u),c=bB([l,u],v?0:1,t),d=(v?s:u)/c.length,f=0;fi,o=bB([a,i],n?0:1,t),s=n?"width":"height",l=n?"height":"width",u=n?"x":"y",v=n?"y":"x",c=r[s]/o.length,d=0;d1?null:new Je(g*l+r,g*u+t)}function Kot(r,t,e){var a=new Je;Je.sub(a,e,t),a.normalize();var i=new Je;Je.sub(i,r,t);var n=i.dot(a);return n}function sc(r,t){var e=r[r.length-1];e&&e[0]===t[0]&&e[1]===t[1]||r.push(t)}function Qot(r,t,e){for(var a=r.length,i=[],n=0;no?(u.x=v.x=s+n/2,u.y=l,v.y=l+o):(u.y=v.y=l+o/2,u.x=s,v.x=s+n),Qot(t,u,v)}function Em(r,t,e,a){if(e===1)a.push(t);else{var i=Math.floor(e/2),n=r(t);Em(r,n[0],i,a),Em(r,n[1],e-i,a)}return a}function Jot(r,t){for(var e=[],a=0;a0;u/=2){var v=0,c=0;(r&u)>0&&(v=1),(t&u)>0&&(c=1),s+=u*u*(3*v^c),c===0&&(v===1&&(r=u-1-r,t=u-1-t),l=r,r=t,t=l)}return s}function zm(r){var t=1/0,e=1/0,a=-1/0,i=-1/0,n=pt(r,function(s){var l=s.getBoundingRect(),u=s.getComputedTransform(),v=l.x+l.width/2+(u?u[4]:0),c=l.y+l.height/2+(u?u[5]:0);return t=Math.min(v,t),e=Math.min(c,e),a=Math.max(v,a),i=Math.max(c,i),[v,c]}),o=pt(n,function(s,l){return{cp:s,z:lst(s[0],s[1],t,e,a,i),path:r[l]}});return o.sort(function(s,l){return s.z-l.z}).map(function(s){return s.path})}function TB(r){return rst(r.path,r.count)}function G2(){return{fromIndividuals:[],toIndividuals:[],count:0}}function ust(r,t,e){var a=[];function i(C){for(var T=0;T=0;i--)if(!e[i].many.length){var l=e[s].many;if(l.length<=1)if(s)s=0;else return e;var n=l.length,u=Math.ceil(n/2);e[i].many=l.slice(u,n),e[s].many=l.slice(0,u),s++}return e}var cst={clone:function(r){for(var t=[],e=1-Math.pow(1-r.path.style.opacity,1/r.count),a=0;a0))return;var s=a.getModel("universalTransition").get("delay"),l=Object.assign({setToFinal:!0},o),u,v;yP(r)&&(u=r,v=t),yP(t)&&(u=t,v=r);function c(x,b,w,S,C){var T=x.many,k=x.one;if(T.length===1&&!C){var D=b?T[0]:k,M=b?k:T[0];if(Nm(D))c({many:[D],one:M},!0,w,S,!0);else{var L=s?ve({delay:s(w,S)},l):l;gA(D,M,L),n(D,M,D,M,L)}}else for(var I=ve({dividePath:cst[e],individualDelay:s&&function(B,z,H,Y){return s(B+w,S)}},l),P=b?ust(T,k,I):vst(k,T,I),E=P.fromIndividuals,N=P.toIndividuals,O=E.length,V=0;Vt.length,f=u?mP(v,u):mP(d?t:r,[d?r:t]),h=0,g=0;gAB))for(var n=a.getIndices(),o=0;o0&&T.group.traverse(function(D){D instanceof ur&&!D.animators.length&&D.animateFrom({style:{opacity:0}},k)})})}function SP(r){var t=r.getModel("universalTransition").get("seriesKey");return t||r.id}function TP(r){return ht(r)?r.sort().join(","):r}function fl(r){if(r.hostModel)return r.hostModel.getModel("universalTransition").get("divideShape")}function mst(r,t){var e=Wt(),a=Wt(),i=Wt();return R(r.oldSeries,function(n,o){var s=r.oldDataGroupIds[o],l=r.oldData[o],u=SP(n),v=TP(u);a.set(v,{dataGroupId:s,data:l}),ht(u)&&R(u,function(c){i.set(c,{key:v,dataGroupId:s,data:l})})}),R(t.updatedSeries,function(n){if(n.isUniversalTransitionEnabled()&&n.isAnimationEnabled()){var o=n.get("dataGroupId"),s=n.getData(),l=SP(n),u=TP(l),v=a.get(u);if(v)e.set(u,{oldSeries:[{dataGroupId:v.dataGroupId,divide:fl(v.data),data:v.data}],newSeries:[{dataGroupId:o,divide:fl(s),data:s}]});else if(ht(l)){var c=[];R(l,function(h){var g=a.get(h);g.data&&c.push({dataGroupId:g.dataGroupId,divide:fl(g.data),data:g.data})}),c.length&&e.set(u,{oldSeries:c,newSeries:[{dataGroupId:o,data:s,divide:fl(s)}]})}else{var d=i.get(l);if(d){var f=e.get(d.key);f||(f={oldSeries:[{dataGroupId:d.dataGroupId,data:d.data,divide:fl(d.data)}],newSeries:[]},e.set(d.key,f)),f.newSeries.push({dataGroupId:o,data:s,divide:fl(s)})}}}}),e}function AP(r,t){for(var e=0;e=0&&i.push({dataGroupId:t.oldDataGroupIds[s],data:t.oldData[s],divide:fl(t.oldData[s]),groupIdDim:o.dimension})}),R(la(r.to),function(o){var s=AP(e.updatedSeries,o);if(s>=0){var l=e.updatedSeries[s].getData();n.push({dataGroupId:t.oldDataGroupIds[s],data:l,divide:fl(l),groupIdDim:o.dimension})}}),i.length>0&&n.length>0&&CB(i,n,a)}function xst(r){r.registerUpdateLifecycle("series:beforeupdate",function(t,e,a){R(la(a.seriesTransition),function(i){R(la(i.to),function(n){for(var o=a.updatedSeries,s=0;s");function Gn(r,t){$r(t,!0);let e=Ef(t,"height",3,"320px"),a=Ef(t,"className",3,""),i=rt(void 0),n=null,o=null,s=null;function l(d){return d!=null&&typeof d=="object"&&Object.keys(d).length>0}async function u(){if(!n||!p(i)||!l(t.option))return;await EE(),s!=null&&typeof cancelAnimationFrame<"u"&&cancelAnimationFrame(s);const d=()=>{!n||!p(i)||!l(t.option)||(n.resize(),n.setOption(t.option,{notMerge:!1,lazyUpdate:!0,replaceMerge:["xAxis","yAxis","series","graphic"]}))};if(typeof requestAnimationFrame>"u"){d();return}s=requestAnimationFrame(d)}function v(d){u()}mn(()=>{p(i)&&(n=d$(p(i),null,{renderer:"canvas"}),o=new ResizeObserver(()=>void u()),o.observe(p(i)),document.addEventListener("kronos:theme",v),u())}),Xm(()=>{s!=null&&typeof cancelAnimationFrame<"u"&&cancelAnimationFrame(s),o?.disconnect(),n?.dispose(),document.removeEventListener("kronos:theme",v)}),Ym(()=>{t.option,u()});var c=bst();tG(c,d=>W(i,d),()=>p(i)),U(()=>{or(c,1,cn(a())),ha(c,`width: 100%; height: ${e()??""};`)}),F(r,c),Yr()}var wst=G('
W3 · /api/training/history
학습 손실 곡선
step loss rolling avg
최저 최고 표시 포인트
');function Sst(r,t){$r(t,!0);let e=rt(Fr([]));Qm.subscribe($=>W(e,$,!0));let a=rt(null);nS.subscribe($=>W(a,$,!0));let i=rt("light");mo.subscribe($=>W(i,$,!0));let n=rt(500),o=q(()=>{if(p(i),typeof window>"u")return null;const $=getComputedStyle(document.documentElement);return{accent:$.getPropertyValue("--accent").trim()||"#38bdf8",accentStrong:$.getPropertyValue("--accent-strong").trim()||"#0891b2",c3:$.getPropertyValue("--c-3").trim()||"#eab308",warn:$.getPropertyValue("--warn").trim()||"#f59e0b",grid:$.getPropertyValue("--border-faint").trim()||"#e2e8f0",border:$.getPropertyValue("--border").trim()||"#cbd5e1",text:$.getPropertyValue("--fg").trim()||"#1e293b",textDim:$.getPropertyValue("--dim").trim()||"#64748b",surface:$.getPropertyValue("--surface").trim()||"#ffffff"}});function s($,Z){const Q=[];for(let at=0;at<$.length;at++){const et=Math.max(0,at-Z),_t=$.slice(et,at+1),Ot=_t.reduce((xt,mt)=>xt+mt.loss,0)/_t.length;Q.push([$[at].step,Ot])}return Q}function l($){const Z=Array.isArray($)?$:[$],Q=Z[0],at=Array.isArray(Q?.value)?Q.value[0]:Q?.axisValue,et=Z.map(_t=>{const Ot=Array.isArray(_t.value)?_t.value[1]:_t.value;return Ot==null||Number.isNaN(Number(Ot))?"":`
+ ${_t.marker??""}${_t.seriesName} + ${Number(Ot).toFixed(4)} +
`}).filter(Boolean).join("");return`
Step ${at??"—"}
${et}`}function u($,Z){const Q=($||"").trim();if(/^#[0-9a-f]{6}$/i.test(Q)){const at=Math.round(Math.max(0,Math.min(1,Z))*255).toString(16).padStart(2,"0");return`${Q}${at}`}if(/^#[0-9a-f]{3}$/i.test(Q)){const at=Q.slice(1).split("").map(_t=>_t+_t).join(""),et=Math.round(Math.max(0,Math.min(1,Z))*255).toString(16).padStart(2,"0");return`#${at}${et}`}return/^rgb\(/i.test(Q)?Q.replace(/^rgb\((.*)\)$/i,`rgba($1, ${Z})`):/^oklch\(/i.test(Q)?Q.replace(/\s*\/\s*[\d.]+\s*\)$/i,` / ${Z})`).replace(/\)$/i,` / ${Z})`):`rgba(20, 184, 166, ${Z})`}let v=q(()=>(Array.isArray(p(a)?.points)?p(a).points:[]).map(Z=>({step:Number(Z.step),loss:Number(Z.loss)})).filter(Z=>Number.isFinite(Z.step)&&Number.isFinite(Z.loss))),c=q(()=>p(e).length>0?p(e):p(v)),d=q(()=>p(n)==="all"?p(c):p(c).slice(-p(n))),f=q(()=>{if(p(d).length===0)return{min:null,max:null};const $=p(d).map(Z=>Z.loss);return{min:Math.min(...$),max:Math.max(...$)}}),h=q(()=>{if(p(i),!p(o))return{};const $=p(d).map(Q=>[Q.step,Q.loss]),Z=s(p(d),Math.min(60,p(d).length));return{backgroundColor:"transparent",textStyle:{color:p(o).text,fontFamily:"Pretendard Variable, sans-serif"},grid:{left:56,right:24,top:24,bottom:60},xAxis:{type:"value",name:"Step",nameTextStyle:{color:p(o).textDim,fontSize:11},axisLabel:{color:p(o).textDim,fontSize:10},splitLine:{lineStyle:{color:p(o).grid}},axisLine:{lineStyle:{color:p(o).border}},scale:!0,min:"dataMin",max:"dataMax"},yAxis:{type:"value",name:"Loss",nameTextStyle:{color:p(o).textDim,fontSize:11},axisLabel:{color:p(o).textDim,fontSize:10},splitLine:{lineStyle:{color:p(o).grid}},axisLine:{lineStyle:{color:p(o).border}},scale:!0},dataZoom:[{type:"inside",xAxisIndex:0,throttle:50},{type:"slider",xAxisIndex:0,height:18,bottom:6,backgroundColor:"transparent",borderColor:p(o).border,fillerColor:u(p(o).accent,.2),handleStyle:{color:p(o).accent,borderColor:p(o).accent},textStyle:{color:p(o).textDim,fontSize:9}}],tooltip:{trigger:"axis",appendToBody:!0,confine:!0,axisPointer:{type:"cross",label:{color:p(o).text,backgroundColor:p(o).surface}},backgroundColor:p(o).surface,borderColor:p(o).border,textStyle:{color:p(o).text,fontSize:12},formatter:l},graphic:p(d).length===0?[{type:"text",left:"center",top:"middle",style:{text:"손실 데이터 수신 대기 중…",fill:p(o).textDim,fontSize:12,fontFamily:"Pretendard Variable, sans-serif"}}]:[],series:[{name:"step loss",type:"line",data:$,smooth:.4,symbol:"circle",showSymbol:$.length<=2,symbolSize:5,lineStyle:{color:p(o).accent,width:1.5},areaStyle:{color:{type:"linear",x:0,y:0,x2:0,y2:1,colorStops:[{offset:0,color:u(p(o).accent,.25)},{offset:1,color:u(p(o).accent,0)}]}}},{name:"rolling avg",type:"line",data:Z,smooth:.6,symbol:"circle",showSymbol:Z.length<=2,symbolSize:5,lineStyle:{color:p(o).c3,width:2}}]}});var g=wst(),m=y(g),x=_(y(m),2),b=y(x),w=y(b),S=_(w,2),C=_(S,2),T=_(m,2),k=_(y(T),4),D=y(k),M=_(T,2);Gn(M,{get option(){return p(h)},height:"380px"});var L=_(M,2),I=y(L),P=y(I),E=_(y(P)),N=y(E),O=_(P,2),V=_(y(O)),B=y(V),z=_(O,2),H=_(y(z)),Y=y(H);U(($,Z)=>{xe(w,"data-active",p(n)===100?"true":"false"),xe(S,"data-active",p(n)===500?"true":"false"),xe(C,"data-active",p(n)==="all"?"true":"false"),A(D,`${p(a)?.stage?`${p(a).stage} · `:""}스크롤 / 슬라이더로 줌`),A(N,$),A(B,Z),A(Y,p(d).length)},[()=>p(f).min!=null?p(f).min.toFixed(4):"—",()=>p(f).max!=null?p(f).max.toFixed(4):"—"]),cr("click",w,()=>W(n,100)),cr("click",S,()=>W(n,500)),cr("click",C,()=>W(n,"all")),F(r,g),Yr()}an(["click"]);var Tst=G('
W4 · ETA Timeline
학습 시작 → 현재 → 예상 완료
완료 현재 예정
시작
중반
완료
학습 시작 경과 남은 시간 완료 예상
');function Ast(r,t){$r(t,!0);let e=rt(null);Tv.subscribe(xt=>W(e,xt,!0));let a=q(()=>p(e)?.latest_stage??{}),i=q(()=>p(a)?.eta_seconds??null),n=q(()=>p(a)?.updated_at??null),o=q(()=>p(a)?.overall_percent??0),s=rt(Fr(Date.now())),l;mn(()=>{l=window.setInterval(()=>W(s,Date.now(),!0),1e3)}),Xm(()=>{l!=null&&clearInterval(l)});let u=q(()=>{if(p(i)==null)return null;const xt=p(n)?Date.parse(p(n)):p(s);return isNaN(xt)?null:xt+p(i)*1e3}),v=q(()=>{if(p(i)==null||p(o)<=0)return null;const xt=p(o)/Math.max(.01,100-p(o));return p(i)*xt}),c=q(()=>p(v)==null?null:p(s)-p(v)*1e3),d=q(()=>Ft.durationCompact(p(i))),f=q(()=>Ft.durationCompact(p(v))),h=q(()=>p(c)!=null?Ft.kstShort(p(c)):"—"),g=q(()=>p(u)!=null?Ft.kstShort(p(u)):"—"),m=q(()=>p(o));var x=Tst(),b=_(y(x),2),w=y(b),S=y(w);let C;var T=_(S,2);ha(T,"",{},{left:"0%"});var k=_(T,2);ha(k,"",{},{left:"50%"});var D=y(k),M=_(k,2);let L;var I=_(y(M),2),P=y(I),E=_(M,2);ha(E,"",{},{left:"100%"});var N=_(b,2),O=y(N),V=y(O),B=_(y(V)),z=y(B),H=_(V,2),Y=_(y(H)),$=y(Y),Z=_(H,2),Q=_(y(Z)),at=y(Q),et=_(Z,2),_t=_(y(et)),Ot=y(_t);U(xt=>{C=ha(S,"",C,{width:`${p(m)}%`}),or(D,1,`pin ${p(m)>=50?"past":"future"}`,"svelte-1jbx7oi"),L=ha(M,"",L,{left:`${p(m)}%`}),A(P,`현재 ${xt??""}`),A(z,p(h)),A($,p(f)),A(at,p(d)),A(Ot,p(g))},[()=>Ft.pct(p(m),1)]),F(r,x),Yr()}var Cst=G('· 전력 한계 ',1),kst=G('· RAM ',1),Dst=G('
VRAM 사용
'),Mst=G('
W5 · /api/training/gpu + /api/training/system
GPU 사용률 % VRAM % GPU 온도 °C CPU 사용률 % CPU 온도 °C
GPU 사용률
GPU 온도
VRAM
CPU 사용률
CPU 온도
');function Lst(r,t){$r(t,!0);let e=rt(Fr([]));sS.subscribe(kt=>W(e,kt,!0));let a=rt(null);oS.subscribe(kt=>W(a,kt,!0));let i=rt(Fr([]));UE.subscribe(kt=>W(i,kt,!0));let n=rt(null);jm.subscribe(kt=>W(n,kt,!0));let o=rt("light");mo.subscribe(kt=>W(o,kt,!0));let s=q(()=>p(a)?.gpus?.[0]),l=q(()=>p(n)?.cpu??null),u=q(()=>p(n)?.memory??null);function v(){return p(s)?{util:p(s).utilization_gpu_percent??null,temp:p(s).temperature_c??null,vram:p(s).memory_used_percent??null,ts:Date.now()}:null}function c(){return!p(l)&&!p(u)?null:{cpuUtil:p(l)?.utilization_percent??null,cpuTemp:p(l)?.temperature_c??null,cpuTempPct:p(l)?.temperature_percent??null,ram:p(u)?.used_percent??null,ts:Date.now()}}function d(kt){return kt?new Date(kt).toLocaleTimeString("ko-KR",{timeZone:"Asia/Seoul",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}):"—"}function f(kt){const _e=Array.isArray(kt)?kt[1]:kt;return _e==null||Number.isNaN(Number(_e))?null:Number(_e)}function h(kt){const _e=Array.isArray(kt)?kt[0]:null;return _e==null||Number.isNaN(Number(_e))?null:Number(_e)}function g(kt){const _e=Array.isArray(kt)?kt:[kt],ke=h(_e[0]?.value),Tr=_e.map(Oe=>{const Hr=f(Oe.value);if(Hr==null)return"";const Zr=Oe.seriesName?.includes("온도")?"°C":"%";return`
+ ${Oe.marker??""}${Oe.seriesName} + ${Hr.toFixed(1)}${Zr} +
`}).filter(Boolean).join("");return`
${d(ke)}
${Tr}`}let m=q(()=>{if(p(e).length>0)return p(e);const kt=v();return kt?[kt]:[]}),x=q(()=>{if(p(i).length>0)return p(i);const kt=c();return kt?[kt]:[]}),b=q(()=>{const kt=[...p(m),...p(x)].map(_e=>_e.ts).filter(Boolean);return kt.length?Math.max(...kt):null}),w=q(()=>{if(p(o),typeof window>"u")return null;const kt=getComputedStyle(document.documentElement);return{c2:kt.getPropertyValue("--c-2").trim()||"#3b82f6",c3:kt.getPropertyValue("--c-3").trim()||"#eab308",c4:kt.getPropertyValue("--c-4").trim()||"#f97316",cpu:kt.getPropertyValue("--info").trim()||"#06b6d4",cpuTemp:kt.getPropertyValue("--danger").trim()||"#ef4444",ram:kt.getPropertyValue("--success").trim()||"#22c55e",grid:kt.getPropertyValue("--border-faint").trim()||"#e2e8f0",border:kt.getPropertyValue("--border").trim()||"#cbd5e1",text:kt.getPropertyValue("--fg").trim()||"#1e293b",textDim:kt.getPropertyValue("--dim").trim()||"#64748b",surface:kt.getPropertyValue("--surface").trim()||"#ffffff"}}),S=q(()=>{if(p(o),!p(w))return{};const kt=p(m),_e=p(x),ke=kt.length>0||_e.length>0,Tr=Math.max(kt.length,_e.length)<=2;return{backgroundColor:"transparent",textStyle:{color:p(w).text,fontFamily:"Pretendard Variable, sans-serif"},grid:{left:40,right:24,top:22,bottom:32},xAxis:{type:"time",axisLabel:{show:!1},axisLine:{lineStyle:{color:p(w).border}},axisTick:{show:!1}},yAxis:{type:"value",min:0,max:100,axisLabel:{color:p(w).textDim,fontSize:10,formatter:"{value}%"},splitLine:{lineStyle:{color:p(w).grid}},axisLine:{show:!1}},tooltip:{trigger:"axis",appendToBody:!0,confine:!0,axisPointer:{type:"line",lineStyle:{color:p(w).border,type:"dashed"}},backgroundColor:p(w).surface,borderColor:p(w).border,textStyle:{color:p(w).text,fontSize:12},formatter:Oe=>g(Oe)},graphic:ke?[]:[{type:"text",left:"center",top:"middle",style:{text:"GPU 데이터 수신 대기 중…",fill:p(w).textDim,fontSize:12,fontFamily:"Pretendard Variable, sans-serif"}}],series:[{name:"GPU 사용률 %",type:"line",smooth:.5,symbol:"circle",showSymbol:Tr,symbolSize:5,data:kt.map(Oe=>[Oe.ts,Oe.util]),lineStyle:{color:p(w).c3,width:1.8}},{name:"VRAM %",type:"line",smooth:.5,symbol:"circle",showSymbol:Tr,symbolSize:5,data:kt.map(Oe=>[Oe.ts,Oe.vram]),lineStyle:{color:p(w).c2,width:1.8}},{name:"GPU 온도 °C",type:"line",smooth:.5,symbol:"circle",showSymbol:Tr,symbolSize:5,data:kt.map(Oe=>[Oe.ts,Oe.temp]),lineStyle:{color:p(w).c4,width:1.8}},{name:"CPU 사용률 %",type:"line",smooth:.35,symbol:"circle",showSymbol:Tr,symbolSize:5,data:_e.map(Oe=>[Oe.ts,Oe.cpuUtil]),lineStyle:{color:p(w).cpu,width:1.7,type:"dashed"}},{name:"CPU 온도 °C",type:"line",smooth:.35,symbol:"circle",showSymbol:Tr,symbolSize:5,data:_e.map(Oe=>[Oe.ts,Oe.cpuTemp]),lineStyle:{color:p(w).cpuTemp,width:1.7,type:"dashed"}}]}});function C(kt,_e=100){return kt==null?"0 264":`${Math.max(0,Math.min(1,kt/_e))*264} 264`}function T(kt,_e=0){return kt==null?"—":`${Ft.num(kt,_e)}%`}function k(kt){return kt==null?"미측정":`${Ft.num(kt,1)}°C`}function D(){return p(l)?.temperature_percent!=null?`${Ft.num(p(l).temperature_percent,0)}%`:p(l)?.temperature_limit_c!=null&&p(l)?.temperature_c!=null?`${Ft.num(p(l).temperature_c/p(l).temperature_limit_c*100,0)}%`:"미측정"}var M=Mst(),L=y(M),I=y(L),P=_(y(I),2),E=y(P),N=_(I,2),O=y(N),V=y(O),B=_(V),z=_(O,2),H=y(z),Y=_(L,2),$=_(y(Y),10),Z=y($),Q=_(Y,2);Gn(Q,{get option(){return p(S)},height:"240px"});var at=_(Q,2),et=y(at),_t=y(et),Ot=_(y(_t)),xt=_(Ot),mt=y(xt),wt=_(et,2),ft=y(wt),K=_(y(ft)),Ct=_(K),Mt=y(Ct),zt=_(wt,2),Yt=y(zt),we=_(y(Yt)),Kt=_(we),qe=y(Kt),fr=_(zt,2),re=y(fr),ge=_(y(re)),ye=_(ge),se=y(ye),St=_(fr,2),Et=y(St),ue=_(y(Et)),Se=_(ue),me=y(Se),Xe=_(at,2);{var br=kt=>{var _e=Dst(),ke=_(y(_e)),Tr=y(ke),Oe=_(ke,2);{var Hr=Zt=>{var j=Cst(),ot=_(lr(j)),Tt=y(ot);U(ce=>A(Tt,`${ce??""} W`),[()=>p(s).power_limit_watts.toFixed(0)]),F(Zt,j)};It(Oe,Zt=>{p(s).power_limit_watts&&Zt(Hr)})}var Zr=_(Oe),da=_(Zr);{var ua=Zt=>{var j=kst(),ot=_(lr(j)),Tt=y(ot);U(ce=>A(Tt,ce),[()=>T(p(u).used_percent)]),F(Zt,j)};It(da,Zt=>{p(u)?.used_percent!=null&&Zt(ua)})}U((Zt,j,ot,Tt)=>{A(Tr,`${Zt??""} / ${j??""}`),A(Zr,` · CPU 온도 ${ot??""} + · CPU 열한도 ${Tt??""} `)},[()=>Ft.bytes(p(s).memory_used_mib),()=>Ft.bytes(p(s).memory_total_mib),()=>k(p(l)?.temperature_c),D]),F(kt,_e)};It(Xe,kt=>{p(s)?.memory_used_mib!=null&&p(s)?.memory_total_mib!=null&&kt(br)})}U((kt,_e,ke,Tr,Oe,Hr,Zr,da,ua,Zt,j,ot,Tt)=>{A(E,`${p(s)?.name??"GPU"??""} · GPU/CPU 실시간 트렌드`),ha(V,`background:${p(s)?.power_draw_available?"var(--success)":"var(--muted)"}`),A(B,` 전원 ${p(s)?.power_draw_available?"실측 OK":"실측 불가"}`),A(H,`CPU ${kt??""} · 온도 ${_e??""}`),A(Z,`버퍼 GPU ${p(e).length??""} / CPU ${p(i).length??""} · 최신 ${ke??""}`),xe(Ot,"stroke-dasharray",Tr),A(mt,Oe),xe(K,"stroke-dasharray",Hr),A(Mt,Zr),xe(we,"stroke-dasharray",da),A(qe,ua),xe(ge,"stroke-dasharray",Zt),A(se,j),xe(ue,"stroke-dasharray",ot),A(me,Tt)},[()=>T(p(l)?.utilization_percent),()=>k(p(l)?.temperature_c),()=>d(p(b)),()=>C(p(s)?.utilization_gpu_percent),()=>p(s)?.utilization_gpu_percent!=null?Math.round(p(s).utilization_gpu_percent)+"%":"—",()=>C(p(s)?.temperature_c,90),()=>p(s)?.temperature_c!=null?Math.round(p(s).temperature_c)+"°":"—",()=>C(p(s)?.memory_used_percent),()=>p(s)?.memory_used_percent!=null?Math.round(p(s).memory_used_percent)+"%":"—",()=>C(p(l)?.utilization_percent),()=>p(l)?.utilization_percent!=null?Math.round(p(l).utilization_percent)+"%":"—",()=>C(p(l)?.temperature_percent??p(l)?.temperature_c,p(l)?.temperature_percent!=null?100:95),()=>p(l)?.temperature_c!=null?Math.round(p(l).temperature_c)+"°":"—"]),F(r,M),Yr()}var Ist=G(' '),Rst=G(' '),Pst=G('평탄'),Est=G('안정 ',1),Nst=G('변동 ',1),Ost=G('

'),zst=G('
W6 · 손실 변동성
평균 (μ)
표준편차 (σ)
최저
최고
');function Bst(r,t){$r(t,!0);let e=.06,a=q(()=>t.stats.std!=null&&t.stats.std{var E=Ist(),N=y(E);U(O=>A(N,`▼ ${O??""}%`),[()=>Ft.num(t.trend.pct,1)]),F(P,E)},c=P=>{var E=Rst(),N=y(E);U(O=>A(N,`▲ ${O??""}%`),[()=>Ft.num(t.trend.pct,1)]),F(P,E)},d=P=>{var E=Pst();F(P,E)};It(u,P=>{t.trend.dir==="down"?P(v):t.trend.dir==="up"?P(c,1):P(d,-1)})}var f=_(n,2),h=y(f),g=_(y(h),2),m=y(g),x=_(h,2),b=_(y(x),2),w=y(b),S=_(x,2),C=_(y(S),2),T=y(C),k=_(S,2),D=_(y(k),2),M=y(D),L=_(f,2);{var I=P=>{var E=Ost(),N=y(E),O=y(N);{var V=Z=>{var Q=Est(),at=_(lr(Q),2);at.textContent="σ가 임계값 0.06 미만",F(Z,Q)},B=Z=>{var Q=Nst(),at=_(lr(Q),2);at.textContent="σ가 임계값 0.06 이상",F(Z,Q)};It(O,Z=>{p(a)?Z(V):Z(B,-1)})}var z=_(N,2),H=y(z);{var Y=Z=>{var Q=Hc("손실이 평탄해지고 있어 단계가 수렴 구간에 진입한 것으로 보입니다.");F(Z,Q)},$=Z=>{var Q=Hc("손실이 아직 크게 움직이고 있어 추가 학습이 필요한 구간입니다.");F(Z,Q)};It(H,Z=>{p(a)?Z(Y):Z($,-1)})}F(P,E)};It(L,P=>{t.stats.n>0&&P(I)})}U((P,E,N,O)=>{A(l,`최근 ${t.stats.n??""} step`),A(m,P),A(w,E),A(T,N),A(M,O)},[()=>t.stats.mean!=null?t.stats.mean.toFixed(4):"—",()=>t.stats.std!=null?t.stats.std.toFixed(4):"—",()=>t.stats.min!=null?t.stats.min.toFixed(4):"—",()=>t.stats.max!=null?t.stats.max.toFixed(4):"—"]),F(r,i),Yr()}var Vst=G('
'),Fst=G('
로그가 없습니다.
'),Gst=G('
로그를 불러오는 중...
'),Hst=G('
'),Wst=G('
'),Ust=G('
W9 · /api/training/logs · stdout tail
tail -f · 10초 주기
Loss LR sps · checkpoint compile error · OOM
마지막 갱신
');function qst(r,t){$r(t,!0);let e=rt(null);Tv.subscribe(V=>W(e,V,!0));let a=rt(5);tv.subscribe(V=>W(a,V,!0));let i=rt(Fr([])),n=rt("-"),o=rt(null),s=rt(!1),l=rt(10),u=q(()=>p(e)?.latest_stage?.train_stage??null);async function v(){if(!p(s)){W(s,!0),W(o,null);try{const V=p(u)?`/api/training/logs?stage=${encodeURIComponent(p(u))}&lines=${p(l)}`:`/api/training/logs?lines=${p(l)}`,B=await fetch(V);if(!B.ok){W(o,`HTTP ${B.status}`);return}const z=await B.json();if(z.error){W(o,z.error,!0);return}W(i,Array.isArray(z.lines)?z.lines:[],!0),W(n,Ft.kstTime(Date.now()),!0)}catch(V){W(o,V?.message??"로그 조회 실패",!0)}finally{W(s,!1)}}}let c;mn(()=>{v(),c=window.setInterval(v,1e4)}),Xm(()=>{c!=null&&clearInterval(c)}),Ym(()=>{p(l),v()});function d(V){if(!V)return"";let B=V.replace(/&/g,"&").replace(//g,">");return B=B.replace(/Loss:\s*(-?[0-9.]+(?:e[-+]?\d+)?)/gi,(z,H)=>`Loss: ${H}`),B=B.replace(/LR\s+(-?[0-9.]+(?:e[-+]?\d+)?)/gi,(z,H)=>`LR ${H}`),B=B.replace(/(samples\/?s|sps)\s*[:=]\s*(-?[0-9.]+(?:e[-+]?\d+)?)/gi,(z,H,Y)=>`${H}=${Y}`),B=B.replace(/(Step\s+)(\d+(?:\/\d+)?)/gi,(z,H,Y)=>`${H}${Y}`),B=B.replace(/(AMP\s+enabled\s*—\s*dtype=\w+\s+scaler=\w+)/gi,(z,H)=>`${H}`),B=B.replace(/(torch\.compile\s+enabled\s*—\s*mode=\S+\s+fullgraph=\w+)/gi,(z,H)=>`${H}`),B=B.replace(/(torch\.compile\s+failed[^\n]*)/gi,(z,H)=>`${H}`),B=B.replace(/(out\s+of\s+memory|OOM|CUDA error|Traceback|Error|Exception)/gi,(z,H)=>`${H}`),B=B.replace(/(checkpoint saved|saved checkpoint|pre-validation\s+epoch\s+\d+)/gi,(z,H)=>`${H}`),B}var f=Ust(),h=y(f),g=y(h),m=_(y(g),2),x=y(m),b=_(g,2),w=y(b),S=y(w),C=_(S,2),T=_(C,2),k=_(h,2);{var D=V=>{var B=Vst(),z=y(B);U(()=>A(z,`⚠ ${p(o)??""}`)),F(V,B)},M=V=>{var B=Fst();F(V,B)},L=V=>{var B=Gst();F(V,B)},I=V=>{var B=Wst();nt(B,21,()=>p(i),ut,(z,H)=>{var Y=Hst();Wn(Y,()=>d(p(H)),!0),F(z,Y)}),F(V,B)};It(k,V=>{p(o)?V(D):p(i).length===0&&!p(s)?V(M,1):p(s)&&p(i).length===0?V(L,2):V(I,-1)})}var P=_(k,2),E=_(y(P),2),N=_(y(E)),O=y(N);U(()=>{A(x,`학습 로그 실시간 tail${p(u)?` · ${p(u)}`:""}`),xe(S,"data-active",p(l)===10?"true":"false"),xe(C,"data-active",p(l)===20?"true":"false"),xe(T,"data-active",p(l)===50?"true":"false"),A(O,p(n))}),cr("click",S,()=>W(l,10)),cr("click",C,()=>W(l,20)),cr("click",T,()=>W(l,50)),F(r,f),Yr()}an(["click"]);var $st=G(" "),Yst=G('→ 0.0%'),Zst=G('

'),Xst=G('
'),jst=G(' '),Kst=G('
'),Qst=G('1초 정규화'),Jst=G('
'),tlt=G(' '),elt=G(' '),rlt=G("
",1),alt=G(`
DATASET SCOPE

공식 Kronos OHLCV 입력에 맞춰 를 사용하고, + 시간 feature는 로 보강합니다.

학습 samples
전체 rows
종목 테이블
실제 거래일
구간 기간 세션 그룹 rows samples
FEATURES
TIME FEATURES
주의: price_mode가 이면 STOM tick DB의 현재가 기반으로 OHLC가 구성됩니다.
DB: · Report:
`),ilt=G('
DATASET SCOPE

데이터 요약을 아직 읽지 못했습니다

확인 필요
'),nlt=G(`
현재 손실
최근값
평균 ±
처리 속도 live
samples/s
step
Learning Rate scheduled
소수 표기
TRAINING HEALTH

현재 학습이 잘 진행 중인지 기준과 함께 확인합니다

아래 정상 범위는 수익률 보장이 아니라 실시간 학습 모니터링용 안전 기준입니다. + 손실은 고정 절대값보다 최근 rolling 평균과 표준편차를 기준으로 이상 여부를 판단합니다.

STAGE-AWARE PROGRESS

전체 진행률을 한 줄 단계 바로 봅니다

전체 진행
현재 단계
`,1);function olt(r,t){$r(t,!0);let e=rt(Fr({}));lS.subscribe(vt=>W(e,vt,!0));let a=rt(Fr([]));Qm.subscribe(vt=>W(a,vt,!0));let i=rt(null);Tv.subscribe(vt=>W(i,vt,!0));let n=rt(null);jm.subscribe(vt=>W(n,vt,!0));const o=q(()=>{const vt=p(a).slice(-200);if(vt.length===0)return{mean:null,std:null,min:null,max:null,n:0};const bt=vt.map(He=>He.loss),Gt=bt.reduce((He,te)=>He+te,0)/bt.length,he=bt.reduce((He,te)=>He+(te-Gt)**2,0)/bt.length,Ye=Math.sqrt(he);return{mean:Gt,std:Ye,min:Math.min(...bt),max:Math.max(...bt),n:vt.length}}),s=q(()=>{if(p(a).length<100)return{dir:"flat",pct:0};const vt=p(a).slice(-50).map(He=>He.loss),bt=p(a).slice(-100,-50).map(He=>He.loss),Gt=vt.reduce((He,te)=>He+te,0)/vt.length,he=bt.reduce((He,te)=>He+te,0)/bt.length;if(Math.abs(Gt-he)<1e-6)return{dir:"flat",pct:0};const Ye=he!==0?Math.abs((Gt-he)/he)*100:0;return{dir:Gtp(e).learningRate);const u=q(()=>p(i)?.dataset_summary??null),v=q(()=>p(i)?.latest_stage??null),c=q(()=>p(n)?.cpu??null),d=q(()=>p(c)?.utilization_percent??null),f=q(()=>p(c)?.temperature_c??null),h=q(()=>p(c)?.temperature_percent??null),g=q(()=>L(p(i)?.overall_percent??p(v)?.overall_percent??0)),m=q(()=>Math.max(1,Number(p(v)?.stage_count??p(i)?.stage_count??2)||2)),x=q(()=>L(p(v)?.stage_percent??0)),b=q(()=>I(p(v)?.train_stage)),w=q(()=>p(a).length?p(a)[p(a).length-1]:null),S=q(()=>p(a).length>1?p(a)[Math.max(0,p(a).length-30)]:null),C=q(()=>!p(w)||!p(S)?null:p(w).step-p(S).step),T=q(()=>{const vt=p(u)?.splits??{};return[{key:"train",label:"학습",tone:"accent"},{key:"val",label:"검증",tone:"info"},{key:"test",label:"테스트",tone:"warn"}].map(Gt=>({...Gt,summary:vt[Gt.key]})).filter(Gt=>!!Gt.summary)});function k(vt){return!vt||vt.length!==8?vt??"-":`${vt.slice(0,4)}-${vt.slice(4,6)}-${vt.slice(6,8)}`}function D(vt){return!vt||vt.length<6?vt??"-":`${vt.slice(0,2)}:${vt.slice(2,4)}:${vt.slice(4,6)}`}function M(vt){return{open:"시가",high:"고가",low:"저가",close:"종가/현재가",vol:"거래량",amt:"거래대금",minute:"분",hour:"시",weekday:"요일",day:"일",month:"월"}[vt]??vt}function L(vt){const bt=typeof vt=="number"?vt:Number(vt);return Number.isFinite(bt)?Math.max(0,Math.min(100,bt)):0}function I(vt){return vt==="tokenizer"?"토크나이저":vt==="predictor"?"프리딕터":vt||"대기"}function P(vt,bt){return bt===2&&vt===1?"tokenizer":bt===2&&vt===2?"predictor":`stage-${vt}`}function E(vt,bt=1){if(vt==null)return"—";const Gt=typeof vt=="number"?vt:Number(vt);return Number.isFinite(Gt)?`${Ft.num(Gt,bt)}%`:"—"}function N(vt){if(vt==null)return"미측정";const bt=typeof vt=="number"?vt:Number(vt);return Number.isFinite(bt)?`${Ft.num(bt,1)}°C`:"미측정"}function O(vt){return vt==="ok"?"정상":vt==="watch"?"주의":vt==="bad"?"위험":"정보"}const V=q(()=>{const vt=p(v),bt=p(m),Gt=Number(vt?.stage_index??1)||1,he=Array.isArray(p(i)?.stages)?p(i).stages:[],Ye=new Map(he.map(He=>[He.train_stage,He]));return Array.from({length:bt},(He,te)=>{const ne=te+1,Te=P(ne,bt),rr=Ye.get(Te),hr=ne===Gt||vt?.train_stage===Te,vr=rr?.stage_percent??(hr?vt?.stage_percent:ne=99.95||["ok","recovered"].includes(rr?.status),status:rr?.status??(hr?vt?.status:void 0)}})}),B=q(()=>{const vt=p(m);return p(v)?`전체 ${Ft.num(p(g),1)}% = 완료 구간 + ${p(b)} ${Ft.num(p(x),1)}% × 1/${vt}`:"전체 진행률 = 단계별 진행률을 동일 구간으로 합산"}),z=q(()=>{const vt=p(e).loss??p(w)?.loss??null,bt=p(o).mean!=null&&p(o).std!=null?p(o).mean+p(o).std*2:null,Gt=p(o).mean!=null&&p(o).std!=null?Math.max(0,p(o).mean-p(o).std*2):null,he=vt==null||bt==null?"info":vt<=bt?"ok":vt<=bt*1.25?"watch":"bad",Ye=(p(C)??0)>0||(p(e).samplesPerSec??0)>0,He=p(v)?.status==="running"&&!Ye?"watch":"ok",te=p(l)==null?"info":p(l)>0&&p(l)<=.001?"ok":p(l)>0&&p(l)<=.01?"watch":"bad",ne=p(g)>=0&&p(g)<=100&&p(x)>=0&&p(x)<=100?"ok":"bad",Te=p(d)==null&&p(f)==null?"info":p(f)!=null&&p(f)>=92||p(d)!=null&&p(d)>=98?"bad":p(f)!=null&&p(f)>=85||p(d)!=null&&p(d)>=95?"watch":"ok";return[{key:"progress",label:"진행률",value:`전체 ${Ft.num(p(g),1)}% · 단계 ${Ft.num(p(x),1)}%`,normal:"0~100%, 현재 단계 안에서 증가",level:ne,message:ne==="ok"?"단계/전체 진행률 산식이 정상 범위입니다.":"진행률 값이 정상 범위를 벗어났습니다."},{key:"lr",label:"학습률(LR)",value:p(l)!=null?p(l).toExponential(2):"—",normal:"0 < LR ≤ 1e-3 참고",level:te,message:te==="ok"?"현재 LR은 보수적인 미세조정 범위입니다.":te==="info"?"최근 history에서 LR을 아직 읽지 못했습니다.":"LR이 일반 참고 범위보다 큽니다. 손실 급등 여부를 함께 보세요."},{key:"loss",label:"Rolling avg loss",value:vt!=null?`현재 ${vt.toFixed(4)} · 평균 ${p(o).mean!=null?p(o).mean.toFixed(4):"—"}`:"—",normal:Gt!=null&&bt!=null?`${Gt.toFixed(4)} ~ ${bt.toFixed(4)}`:"최근 200포인트 평균 ± 2σ",level:he,message:he==="ok"?"현재 손실이 최근 rolling 범위 안에 있습니다.":he==="info"?"rolling loss 포인트가 더 필요합니다.":"현재 손실이 rolling 평균 대비 높습니다. 다음 몇 분 추세 확인이 필요합니다."},{key:"step",label:"Step/Loss 수신",value:`step ${Ft.int(p(v)?.step)} · Δ${Ft.int(p(C)??0)}`,normal:"최근 30포인트에서 step 증가",level:He,message:He==="ok"?"step과 loss 로그가 계속 갱신되고 있습니다.":"running 상태지만 최근 step 증가가 약합니다."},{key:"cpu",label:"Host CPU 상태",value:`${E(p(d))} · ${N(p(f))}${p(h)!=null?` (${Ft.num(p(h),0)}%)`:""}`,normal:"CPU < 95%, 온도 < 85°C 권장",level:Te,message:Te==="ok"?"CPU 사용률/온도는 학습 감시에 무리가 없는 범위입니다.":Te==="info"?"CPU 온도 센서가 OS에서 노출되지 않을 수 있습니다.":"CPU 부하 또는 온도가 높습니다. 냉각/백그라운드 작업을 확인하세요."}]}),H=q(()=>{const vt=p(z).filter(Gt=>Gt.level==="bad").length,bt=p(z).filter(Gt=>Gt.level==="watch").length;return vt>0?{level:"bad",label:"학습 상태 위험",message:`${vt}개 항목이 위험 범위입니다.`}:bt>0?{level:"watch",label:"학습 상태 주의",message:`${bt}개 항목을 관찰하세요.`}:{level:"ok",label:"학습 정상 진행",message:"진행률·LR·loss·step·CPU 기준이 정상입니다."}});var Y=nlt(),$=lr(Y),Z=y($),Q=y(Z),at=_(y(Q),2);{var et=vt=>{var bt=$st(),Gt=y(bt);U(he=>{or(bt,1,`delta ${p(s).dir==="down"?"down":"up"}`),A(Gt,`${p(s).dir==="down"?"↓ 개선":"↑ 상승"} ${he??""}%`)},[()=>Ft.num(p(s).pct,1)]),F(vt,bt)},_t=vt=>{var bt=Yst();F(vt,bt)};It(at,vt=>{p(s).dir!=="flat"?vt(et):vt(_t,-1)})}var Ot=_(Q,2),xt=y(Ot),mt=_(Ot,2),wt=_(y(mt)),ft=y(wt),K=_(wt,2),Ct=y(K),Mt=_(Z,2),zt=_(y(Mt),2),Yt=y(zt),we=_(zt,2),Kt=_(y(we)),qe=y(Kt),fr=_(Kt),re=_(Mt,2),ge=_(y(re),2),ye=y(ge),se=_(ge,2),St=_(y(se)),Et=y(St),ue=_($,2),Se=y(ue),me=_(y(Se),2),Xe=y(me),br=y(Xe),kt=_(Xe,2),_e=y(kt),ke=_(kt,2),Tr=y(ke),Oe=_(Se,2);nt(Oe,21,()=>p(z),vt=>vt.key,(vt,bt)=>{var Gt=Zst(),he=y(Gt),Ye=y(he),He=y(Ye),te=_(Ye,2),ne=y(te),Te=_(he,2),rr=y(Te),hr=_(Te,2),vr=y(hr),st=_(hr,2),At=y(st);U(ee=>{xe(Gt,"data-level",p(bt).level),A(He,p(bt).label),A(ne,ee),A(rr,p(bt).value),A(vr,`정상 범위: ${p(bt).normal??""}`),A(At,p(bt).message)},[()=>O(p(bt).level)]),F(vt,Gt)});var Hr=_(ue,2),Zr=y(Hr),da=y(Zr),ua=_(y(da),4),Zt=y(ua),j=_(da,2),ot=y(j),Tt=_(y(ot),2),ce=y(Tt),ae=_(ot,2),Qt=_(y(ae),2),ie=y(Qt),$e=_(Zr,2),ze=y($e),De=_($e,2);nt(De,21,()=>p(V),vt=>vt.key,(vt,bt)=>{var Gt=Xst();let he;var Ye=y(Gt);let He;var te=_(Ye,2),ne=y(te);U(Te=>{xe(Gt,"data-active",p(bt).active?"true":"false"),xe(Gt,"data-complete",p(bt).complete?"true":"false"),xe(Gt,"title",Te),he=ha(Gt,"",he,{"flex-basis":`${100/p(m)}%`}),He=ha(Ye,"",He,{width:`${p(bt).stagePercent}%`}),A(ne,p(bt).stageIndex)},[()=>`${p(bt).stageIndex}구간 ${p(bt).label}: ${Ft.num(p(bt).stagePercent,1)}%`]),F(vt,Gt)});var Bt=_(De,2),Jt=y(Bt);nt(Jt,17,()=>p(V),vt=>vt.key,(vt,bt)=>{var Gt=jst();let he;var Ye=y(Gt);U(He=>{he=ha(Gt,"",he,{left:`${p(bt).start}%`}),A(Ye,`${He??""}%`)},[()=>Ft.num(p(bt).start,0)]),F(vt,Gt)});var fe=_(Jt,2);ha(fe,"",{},{left:"100%"});var Ge=_(Bt,2);nt(Ge,21,()=>p(V),vt=>vt.key,(vt,bt)=>{var Gt=Kst(),he=y(Gt),Ye=y(he),He=_(Ye,2),te=y(He),ne=y(te),Te=_(te,2),rr=y(Te),hr=_(he,2),vr=y(hr),st=y(vr),At=_(vr,2),ee=y(At);U((nr,wr,Xr,jr)=>{xe(Gt,"data-active",p(bt).active?"true":"false"),xe(Ye,"data-active",p(bt).active?"true":"false"),xe(Ye,"data-complete",p(bt).complete?"true":"false"),A(ne,`${p(bt).stageIndex??""}구간 · ${p(bt).label??""}`),A(rr,`전체 ${nr??""}~${wr??""}%`),A(st,`${Xr??""}%`),A(ee,`전체 기여 ${jr??""}%`)},[()=>Ft.num(p(bt).start,0),()=>Ft.num(p(bt).end,0),()=>Ft.num(p(bt).stagePercent,1),()=>Ft.num(p(bt).overallContribution,1)]),F(vt,Gt)});var Ve=_(Hr,2);{var je=vt=>{var bt=alt(),Gt=y(bt),he=y(Gt),Ye=_(y(he),2),He=y(Ye),te=_(Ye,2),ne=_(y(te)),Te=y(ne),rr=_(ne,2),hr=y(rr),vr=_(he,2),st=y(vr),At=y(st),ee=_(st,2),nr=y(ee),wr=_(ee,2),Xr=y(wr),jr=_(wr,2),Aa=y(jr),ba=_(jr,2);{var _a=de=>{var Ht=Qst();F(de,Ht)};It(ba,de=>{p(u).regularize_1s&&de(_a)})}var Pa=_(Gt,2),Ga=y(Pa),_i=_(y(Ga),2),vi=y(_i),pr=_(_i,2),Na=y(pr),qi=_(Ga,2),Ut=_(y(qi),2),Ir=y(Ut),Wr=_(Ut,2),va=y(Wr),ja=_(qi,2),Li=_(y(ja),2),ci=y(Li),nn=_(Li,2),_n=y(nn),to=_(ja,2),xn=_(y(to),2),Mo=y(xn),eo=_(xn,2),Lo=y(eo),Io=_(Pa,2),bn=y(Io),Ro=_(y(bn),2);nt(Ro,17,()=>p(T),de=>de.key,(de,Ht)=>{var Re=Jst(),Fe=y(Re),Le=y(Fe),Pe=y(Le),gr=_(Fe,2),Br=y(gr),Ca=_(gr,2),Oa=y(Ca),ka=_(Ca,2),ni=y(ka),wa=_(ka,2),Ra=y(wa),$i=_(wa,2),gs=y($i);U((Po,jl,Xs,Kl,Nv,_d)=>{or(Le,1,`pill ${p(Ht).tone??""}`,"svelte-qpia2v"),A(Pe,p(Ht).label),A(Br,`${Po??""} ~ ${jl??""}`),A(Oa,Xs),A(ni,Kl),A(Ra,Nv),A(gs,_d)},[()=>k(p(Ht).summary.first_session),()=>k(p(Ht).summary.last_session),()=>Ft.int(p(Ht).summary.sessions),()=>Ft.int(p(Ht).summary.groups),()=>Ft.int(p(Ht).summary.rows),()=>Ft.int(p(Ht).summary.current_target_samples??p(Ht).summary.possible_samples)]),F(de,Re)});var hs=_(bn,2),wn=y(hs),ro=_(y(wn),2);nt(ro,21,()=>p(u).features??[],ut,(de,Ht)=>{var Re=tlt(),Fe=y(Re),Le=_(Fe),Pe=y(Le);U(gr=>{A(Fe,gr),A(Pe,p(Ht))},[()=>M(p(Ht))]),F(de,Re)});var ao=_(wn,2),ql=_(y(ao),2);nt(ql,21,()=>p(u).time_features??[],ut,(de,Ht)=>{var Re=elt(),Fe=y(Re),Le=_(Fe),Pe=y(Le);U(gr=>{A(Fe,gr),A(Pe,p(Ht))},[()=>M(p(Ht))]),F(de,Re)});var ps=_(ao,2),Zs=_(y(ps),2),$l=y(Zs),Yl=_(Zs,2);{var Zl=de=>{var Ht=rlt(),Re=_(lr(Ht),1,!0);U(()=>A(Re,p(u).warnings[0])),F(de,Ht)};It(Yl,de=>{p(u).warnings?.length&&de(Zl)})}var Xl=_(Io,2),yt=_(y(Xl)),J=y(yt),qt=_(yt,2),Xt=y(qt);U((de,Ht,Re,Fe,Le,Pe,gr,Br,Ca,Oa,ka,ni,wa,Ra,$i)=>{A(He,`STOM 2025 전체 1초봉 · pred${p(u).horizon_seconds??p(u).predict_window??"-"??""}`),A(Te,de),A(hr,Ht),A(At,p(u).freq??"1s"),A(nr,`${Re??""}~${Fe??""}`),A(Xr,`lookback ${Le??""}`),A(Aa,`window ${Pe??""}`),A(vi,gr),A(Na,`검증 ${Br??""}`),A(Ir,Ca),A(va,`그룹 ${Oa??""}`),A(ci,ka),A(_n,`전체 ${ni??""} · 0행 ${wa??""}`),A(Mo,Ra),A(Lo,`~ ${$i??""}`),A($l,p(u).price_mode??"-"),A(J,p(u).source_db??"-"),A(Xt,p(u).report_path??"-")},[()=>p(u).features?.map(M).join(" · "),()=>p(u).time_features?.map(M).join(" · "),()=>D(p(u).range?.time_start),()=>D(p(u).range?.time_end),()=>Ft.int(p(u).lookback_window),()=>Ft.int(p(u).sample_window),()=>Ft.int(p(u).current_targets?.train_samples),()=>Ft.int(p(u).current_targets?.val_samples),()=>Ft.int(p(u).counts?.exported_row_count),()=>Ft.int(p(u).counts?.exported_group_count),()=>Ft.int(p(u).counts?.tables_with_rows),()=>Ft.int(p(u).counts?.table_count),()=>Ft.int(p(u).counts?.tables_zero_rows),()=>k(p(u).range?.actual_start),()=>k(p(u).range?.actual_end)]),F(vt,bt)},tr=vt=>{var bt=ilt(),Gt=y(bt),he=y(Gt),Ye=_(y(he),4),He=y(Ye);U(()=>A(He,p(u).message??"현재 run의 Qlib export report를 찾는 중입니다.")),F(vt,bt)};It(Ve,vt=>{p(u)?.available?vt(je):p(u)&&vt(tr,1)})}var yr=_(Ve,2),Me=y(yr);Sst(Me,{});var mr=_(Me,2);Bst(mr,{get stats(){return p(o)},get trend(){return p(s)}});var kr=_(yr,2);Ast(kr,{});var zr=_(kr,2);Lst(zr,{});var ea=_(zr,2);qst(ea,{}),U((vt,bt,Gt,he,Ye,He,te,ne,Te,rr,hr,vr)=>{A(xt,vt),A(ft,bt),A(Ct,Gt),A(Yt,he),A(qe,Ye),A(fr,` / ${He??""} · 현재 batch 기준`),A(ye,te),A(Et,ne),xe(me,"data-level",p(H).level),A(br,Te),A(_e,p(H).label),A(Tr,p(H).message),A(Zt,`전체 100%를 ${p(m)??""}개 단계 구간으로 나누고, 현재 단계의 채움 값은 해당 구간 안에서만 표시합니다. + 중복 표시를 줄이고, 단계별 위치·기여도·현재 속도를 한 화면에서 비교합니다.`),A(ce,`${rr??""}%`),A(ie,`${p(b)??""} ${hr??""}%`),A(ze,p(B)),xe(De,"aria-label",vr)},[()=>p(e).loss!=null?p(e).loss.toFixed(4):"-",()=>p(o).mean!=null?p(o).mean.toFixed(4):"-",()=>p(o).std!=null?p(o).std.toFixed(4):"-",()=>Ft.num(p(e).samplesPerSec,1),()=>Ft.int(p(i)?.latest_stage?.step),()=>Ft.int(p(i)?.latest_stage?.total_steps),()=>p(l)!=null?p(l).toExponential(2):"-",()=>p(l)!=null?p(l).toFixed(6):"-",()=>O(p(H).level),()=>Ft.num(p(g),1),()=>Ft.num(p(x),1),()=>`전체 진행률 ${Ft.num(p(g),1)}%, 현재 단계 ${p(b)} ${Ft.num(p(x),1)}%`]),F(r,Y),Yr()}var slt=G('
'),llt=G(' '),ult=G('미로드'),vlt=G(""),clt=G('
'),dlt=G(' '),flt=G('미로드'),hlt=G('
로드 가능한 데이터 파일이 없습니다
'),plt=G(""),glt=G('
',1),ylt=G('
'),mlt=G(''),_lt=z7(' 예측 실행',1),xlt=G('
💡 예측 실행을 위해서는 모델과 데이터를 먼저 로드해야 합니다.
'),blt=G('
예측 실패
오류
'),wlt=G(' '),Slt=G('
'),Tlt=G('
'),Alt=G('
RESULT · /api/predict
예측 결과
완료
'),Clt=G(`
본격 /api/predict · POST

예측 워크벤치

사전학습 Kronos 모델로 K-line 시계열 예측을 실행합니다. 예측기(predictor) 학습 완료 전에는 base/small/mini 사전학습 weight 가 적용됩니다. + Seed 고정 시 동일 파라미터에 대해 결정성이 보장됩니다.

MODEL · 사전학습 weight
모델 선택 / 로드
DATA · 입력 시계열
데이터 파일 선택 / 로드
PARAMETERS · sampling
예측 파라미터
낮을수록 보수적 · 높을수록 다양한 시나리오 생성
`,1);function klt(r,t){$r(t,!0);let e=rt(Fr({})),a=rt(null),i=rt(null),n=rt(Fr([])),o=rt(""),s=rt(""),l=rt(!1),u=rt(!1),v=rt(""),c=rt(""),d=rt(400),f=rt(120),h=rt(1),g=rt(.9),m=1,x=rt(!0),b=rt(42),w=rt("cpu"),S=rt(!1),C=rt(null),T=rt(null),k=rt(!1),D=rt(!1),M=rt(null),L=rt("light");mo.subscribe(st=>W(L,st,!0));async function I(){try{const st=await fetch("/api/available-models");if(!st.ok)return;const At=await st.json();W(e,At.models??{},!0),W(a,!!At.model_available),W(i,At.model_import_error,!0);const ee=Object.keys(p(e));ee.length>0&&!p(o)&&W(o,ee[0],!0)}catch{W(a,!1)}}async function P(){try{const st=await fetch("/api/data-files");if(!st.ok)return;const At=await st.json();if(W(n,Array.isArray(At)?At:Array.isArray(At.files)?At.files:[],!0),p(n).length>0&&!p(s)){const ee=p(n)[0];W(s,typeof ee=="string"?ee:ee.path??ee.name??"",!0)}}catch{}}mn(()=>{I(),P()});async function E(){if(!(!p(o)||p(k))){W(k,!0),W(M,null);try{const st=await fetch("/api/load-model",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({model_key:p(o),device:p(w)})}),At=await st.json();!st.ok||At.success===!1?(W(M,At?.error??At?.message??`HTTP ${st.status}`,!0),W(l,!1)):(W(l,!0),W(v,p(e)[p(o)]?.name??p(o),!0))}catch(st){W(M,st?.message??"모델 로드 실패",!0)}finally{W(k,!1)}}}async function N(){if(!(!p(s)||p(D))){W(D,!0),W(M,null);try{const st=await fetch("/api/load-data",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({file_path:p(s)})}),At=await st.json();!st.ok||At.success===!1?(W(M,At?.error??At?.message??`HTTP ${st.status}`,!0),W(u,!1)):(W(u,!0),W(c,p(s).split(/[/\\]/).pop()??p(s),!0))}catch(st){W(M,st?.message??"데이터 로드 실패",!0)}finally{W(D,!1)}}}async function O(){if(!p(S)){W(S,!0),W(T,null);try{const st=await fetch("/api/predict",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({lookback:p(d),pred_len:p(f),temperature:p(h),top_p:p(g),n_samples:m,seed:p(x)?p(b):null,device:p(w)})}),At=await st.json();if(!st.ok||At.success===!1){W(T,At?.error??At?.message??`HTTP ${st.status}`,!0);return}W(C,At,!0)}catch(st){W(T,st?.message??"예측 실행 실패",!0)}finally{W(S,!1)}}}let V=q(()=>{if(p(L),!p(C)||typeof window>"u")return{};const st=getComputedStyle(document.documentElement),At=st.getPropertyValue("--accent").trim(),ee=st.getPropertyValue("--c-2").trim(),nr=st.getPropertyValue("--c-4").trim(),wr=st.getPropertyValue("--border-faint").trim(),Xr=st.getPropertyValue("--fg").trim(),jr=st.getPropertyValue("--dim").trim(),Aa=st.getPropertyValue("--surface").trim(),ba=p(C).historical??p(C).history??p(C).input??[],_a=p(C).predicted??p(C).prediction??p(C).forecast??[],Pa=p(C).actual??p(C).truth??[],Ga=ba.map((pr,Na)=>[typeof pr=="object"?pr.timestamp??pr.time??pr.date??Na:Na,typeof pr=="object"?pr.close??pr.value??pr.y??pr:pr]),_i=_a.map((pr,Na)=>[typeof pr=="object"?pr.timestamp??pr.time??pr.date??ba.length+Na:ba.length+Na,typeof pr=="object"?pr.close??pr.value??pr.y??pr:pr]),vi=Pa.map((pr,Na)=>[typeof pr=="object"?pr.timestamp??pr.time??pr.date??ba.length+Na:ba.length+Na,typeof pr=="object"?pr.close??pr.value??pr.y??pr:pr]);return{backgroundColor:"transparent",textStyle:{color:Xr,fontFamily:"Pretendard Variable, sans-serif"},grid:{left:56,right:24,top:24,bottom:36},xAxis:{type:"category",axisLabel:{color:jr,fontSize:10},splitLine:{lineStyle:{color:wr}},axisLine:{lineStyle:{color:wr}}},yAxis:{type:"value",scale:!0,axisLabel:{color:jr,fontSize:10},splitLine:{lineStyle:{color:wr}},axisLine:{show:!1}},tooltip:{trigger:"axis",backgroundColor:Aa,borderColor:wr,textStyle:{color:Xr,fontSize:12}},legend:{textStyle:{color:jr,fontSize:11},icon:"roundRect"},series:[{name:"입력 (lookback)",type:"line",data:Ga,smooth:.3,symbol:"none",lineStyle:{color:ee,width:1.5}},{name:"예측",type:"line",data:_i,smooth:.3,symbol:"none",lineStyle:{color:At,width:2,type:"solid"}},...vi.length>0?[{name:"실측",type:"line",data:vi,smooth:.3,symbol:"none",lineStyle:{color:nr,width:1.5,type:"dashed"}}]:[]]}});var B=Clt(),z=lr(B),H=y(z),Y=_(y(H),2),$=_(y(Y)),Z=_(H,6);{var Q=st=>{var At=slt(),ee=y(At),nr=y(ee);U(()=>A(nr,`⚠ ${p(i)??""}`)),F(st,At)};It(Z,st=>{p(i)&&st(Q)})}var at=_(z,2),et=y(at),_t=y(et),Ot=_(y(_t),2);{var xt=st=>{var At=llt(),ee=_(y(At),1,!0);U(()=>A(ee,p(v))),F(st,At)},mt=st=>{var At=ult();F(st,At)};It(Ot,st=>{p(l)?st(xt):st(mt,-1)})}var wt=_(_t,2);nt(wt,21,()=>Object.entries(p(e)),ut,(st,At)=>{var ee=q(()=>Ki(p(At),2));let nr=()=>p(ee)[0];const Xr=q(()=>p(ee)[1]);var jr=vlt(),Aa=y(jr),ba={};U(()=>{A(Aa,`${p(Xr).name??nr()??""} · ${p(Xr).params??"?"??""} · ctx ${p(Xr).context_length??"?"??""}`),ba!==(ba=nr())&&(jr.value=(jr.__value=nr())??"")}),F(st,jr)});var ft=_(wt,2);{var K=st=>{var At=clt(),ee=y(At);U(()=>A(ee,p(e)[p(o)].description)),F(st,At)};It(ft,st=>{p(o)&&p(e)[p(o)]&&st(K)})}var Ct=_(ft,2),Mt=y(Ct),zt=y(Mt),Yt=_(Mt,2),we=y(Yt);we.value=we.__value="cpu";var Kt=_(we);Kt.value=Kt.__value="cuda";var qe=_(et,2),fr=y(qe),re=_(y(fr),2);{var ge=st=>{var At=dlt(),ee=_(y(At),1,!0);U(()=>A(ee,p(c))),F(st,At)},ye=st=>{var At=flt();F(st,At)};It(re,st=>{p(u)?st(ge):st(ye,-1)})}var se=_(fr,2);{var St=st=>{var At=hlt();F(st,At)},Et=st=>{var At=glt(),ee=lr(At);nt(ee,21,()=>p(n),ut,(Xr,jr)=>{const Aa=q(()=>typeof p(jr)=="string"?p(jr):p(jr).path??p(jr).name??""),ba=q(()=>p(Aa).split(/[/\\]/).pop());var _a=plt(),Pa=y(_a),Ga={};U(()=>{A(Pa,p(ba)),Ga!==(Ga=p(Aa))&&(_a.value=(_a.__value=p(Aa))??"")}),F(Xr,_a)});var nr=_(ee,2),wr=y(nr);U(()=>A(wr,p(s))),c1(ee,()=>p(s),Xr=>W(s,Xr)),F(st,At)};It(se,st=>{p(n).length===0?st(St):st(Et,-1)})}var ue=_(se,2),Se=y(ue),me=y(Se),Xe=_(at,2);{var br=st=>{var At=ylt(),ee=y(At),nr=y(ee);U(()=>A(nr,`⚠ ${p(M)??""}`)),F(st,At)};It(Xe,st=>{p(M)&&st(br)})}var kt=_(Xe,2),_e=y(kt),ke=_(y(_e),2),Tr=_(y(ke)),Oe=_(_e,2),Hr=y(Oe),Zr=y(Hr),da=_(y(Zr),2),ua=y(da),Zt=_(Zr,2),j=_(Zt,2),ot=y(j),Tt=_(Hr,2),ce=y(Tt),ae=_(y(ce),2),Qt=y(ae),ie=_(ce,2),$e=_(ie,2),ze=y($e),De=_(Tt,2),Bt=y(De),Jt=_(y(Bt),2),fe=y(Jt),Ge=_(Bt,2),Ve=_(De,2),je=y(Ve),tr=_(y(je),2),yr=y(tr),Me=_(je,2),mr=_(Me,2),kr=y(mr),zr=_(Oe,2),ea=y(zr),vt=y(ea),bt=_(ea,2);{var Gt=st=>{var At=mlt(),ee=_(y(At),2);Vd(ee,()=>p(b),nr=>W(b,nr)),F(st,At)};It(bt,st=>{p(x)&&st(Gt)})}var he=_(bt,2),Ye=y(he);{var He=st=>{var At=Hc("예측 실행 중…");F(st,At)},te=st=>{var At=_lt(),ee=lr(At);Wn(ee,()=>Ko.play,!0),F(st,At)};It(Ye,st=>{p(S)?st(He):st(te,-1)})}var ne=_(zr,2);{var Te=st=>{var At=xlt();F(st,At)};It(ne,st=>{(!p(l)||!p(u))&&st(Te)})}var rr=_(kt,2);{var hr=st=>{var At=blt(),ee=_(y(At),2),nr=y(ee);U(()=>A(nr,p(T))),F(st,At)},vr=st=>{var At=Alt(),ee=y(At),nr=_(y(ee),2),wr=_(y(nr),2);{var Xr=_a=>{var Pa=wlt(),Ga=_(y(Pa));U(_i=>A(Ga,`${_i??""}s`),[()=>p(C).elapsed_seconds.toFixed(2)]),F(_a,Pa)};It(wr,_a=>{p(C).elapsed_seconds!=null&&_a(Xr)})}var jr=_(ee,2);Gn(jr,{get option(){return p(V)},height:"380px"});var Aa=_(jr,2);{var ba=_a=>{var Pa=Tlt();nt(Pa,21,()=>Object.entries(p(C).metrics),ut,(Ga,_i)=>{var vi=q(()=>Ki(p(_i),2));let pr=()=>p(vi)[0],Na=()=>p(vi)[1];var qi=Slt(),Ut=y(qi),Ir=y(Ut),Wr=_(Ut,2),va=y(Wr);U(ja=>{A(Ir,pr()),A(va,ja)},[()=>typeof Na()=="number"?Na().toFixed(4):String(Na())]),F(Ga,qi)}),F(_a,Pa)};It(Aa,_a=>{p(C).metrics&&_a(ba)})}F(st,At)};It(rr,st=>{p(T)?st(hr):p(C)&&st(vr,1)})}U((st,At,ee,nr)=>{or(Y,1,`pill ${p(a)===!0?"success":p(a)===!1?"warn":""}`),A($,` ${p(a)===!0?"모델 라이브러리 사용 가능":p(a)===!1?"모델 라이브러리 미가용 (시뮬레이션)":"확인 중"}`),wt.disabled=st,Mt.disabled=!p(o)||p(k),A(zt,p(k)?"로드 중…":"모델 로드"),Se.disabled=!p(s)||p(D),A(me,p(D)?"로드 중…":"데이터 로드"),A(Tr,`seed ${p(x)?`고정 ${p(b)}`:"랜덤"}`),A(ua,p(d)),A(ot,`최근 ${p(d)??""} step 의 캔들을 입력으로 사용`),A(Qt,p(f)),A(ze,`앞으로 ${p(f)??""} step 의 캔들을 예측`),A(fe,At),A(yr,ee),A(kr,`누적 확률 ${nr??""} 미만의 토큰만 샘플링`),he.disabled=!p(l)||!p(u)||p(S)},[()=>Object.keys(p(e)).length===0,()=>p(h).toFixed(2),()=>p(g).toFixed(2),()=>p(g).toFixed(2)]),c1(wt,()=>p(o),st=>W(o,st)),cr("click",Mt,E),c1(Yt,()=>p(w),st=>W(w,st)),cr("click",Se,N),Vd(Zt,()=>p(d),st=>W(d,st)),Vd(ie,()=>p(f),st=>W(f,st)),Vd(Ge,()=>p(h),st=>W(h,st)),Vd(Me,()=>p(g),st=>W(g,st)),J7(vt,()=>p(x),st=>W(x,st)),cr("click",he,O),F(r,B),Yr()}an(["click"]);var Dlt=G('
요약 로딩 중...
'),Mlt=G('
'),Llt=G('
호환 테이블 stock table
학습 후보 그룹 lookback/predict 가능
예상 샘플 lookback 300 기준
DB 크기 STOM tick database
'),Ilt=G(' '),Rlt=G('
'),Plt=G('
HORIZON COMPARISON · 30/60/120/300초 비교

어느 예측 시간이 가장 의미 있는가?

동일 checkpoint로 각 horizon을 walk-forward 평가하고, 비용 25bp 기준 rolling gate까지 비교합니다.

현재 결론

horizon방향 적중random 대비Top-K net최적 필터 netRolling netRolling 방향Gate
'),Elt=G(''),Nlt=G('
표시할 산출물이 없습니다.
'),Olt=G('
상세 분석 로딩 중...
'),zlt=G('
'),Blt=G('
MODEL VERDICT

방향 적중률
MAPE 가격 경로 평균 오차율
평가 범위
평균 실제 등락률 예측 window 최종 시점 기준
',1),Vlt=G('
'),Flt=G('
조건식/Top-K 필터별 성과
',1),Glt=G(' '),Hlt=G('
ACTUAL VS PREDICTION

선택 window 실제 종가와 Kronos 예측 종가

RETURN SCATTER

전체 window 예측 등락률 vs 실제 등락률

초록=방향 적중 · 빨강=방향 실패
',1),Wlt=G(' '),Ult=G('
KRONOS 점수 상위 후보
순위종목일자점수신호예측 등락률실제 등락률방향
',1),qlt=G('
EVALUATION SUMMARY · 사용자가 바로 읽는 성과 요약
',1),$lt=G('
평균 gross
평균 net
방향 적중
누적 수익
'),Ylt=G('
fold 수
test net
positive fold
overfit gap
'),Zlt=G('
best net
direction
trades
coverage
'),Xlt=G('
ARTIFACT DETAIL

 
'),jlt=G('
왼쪽에서 산출물을 선택하세요.
'),Klt=G(`
본격 STOM 1초봉 · read-only

예측 진단

STOM 1초봉 예측, 백테스트, 필터 산출물과 horizon별 성과를 한 화면에서 비교합니다. + 모든 데이터는 read-only이며, 런타임 예측 CSV는 소스 커밋에 포함하지 않습니다.

`,1);function Qlt(r,t){$r(t,!0);let e=rt(null),a=rt(null),i=rt(Fr([])),n=rt(Fr([])),o=rt(Fr([])),s=rt(!1),l=rt(null),u=rt("prediction"),v=rt(null),c=rt(null),d=rt(null),f=rt(null),h=rt(null),g=rt(!1),m=rt(null);mn(()=>{x(),w()});async function x(){W(s,!0),W(l,null);try{const j=await fetch("/api/stom/summary");if(!j.ok)throw new Error(`HTTP ${j.status}`);W(e,await j.json(),!0)}catch(j){W(l,j?.message??"STOM 요약 조회 실패",!0)}finally{W(s,!1)}}async function b(j){try{const ot=await fetch(j);if(!ot.ok)return[];const Tt=await ot.json();return Array.isArray(Tt.files)?Tt.files:Array.isArray(Tt)?Tt:[]}catch{return[]}}async function w(){const[j,ot,Tt,ce]=await Promise.all([b("/api/stom/prediction-files"),b("/api/stom/qlib-backtests"),b("/api/stom/filter-reports"),fetch("/api/stom/horizon-comparison").catch(()=>null)]);W(i,j,!0),W(n,ot,!0),W(o,Tt,!0),ce?.ok&&W(a,await ce.json(),!0);const ae=V();!p(v)&&ae&&p(u)==="prediction"&&await C(ae)}function S(j){return j.name||j.path}async function C(j){W(v,j,!0),W(c,null),W(d,null),W(f,null),W(h,null),W(m,null),W(g,!0);try{const ot=encodeURIComponent(S(j));if(p(u)==="prediction"){const[Tt,ce,ae]=await Promise.all([fetch(`/api/stom/diagnostics?file=${ot}&max_symbols=30`),fetch(`/api/stom/prediction?file=${ot}`),fetch(`/api/stom/backtest-report?file=${ot}&top_k=5`)]);if(!Tt.ok)throw new Error(`진단 HTTP ${Tt.status}`);if(!ce.ok)throw new Error(`예측 상세 HTTP ${ce.status}`);W(d,await Tt.json(),!0),W(f,await ce.json(),!0),W(h,ae.ok?await ae.json():null,!0)}else if(p(u)==="backtest"){const Tt=await fetch(`/api/stom/qlib-backtests?file=${ot}`);if(!Tt.ok)throw new Error(`백테스트 HTTP ${Tt.status}`);W(c,await Tt.json(),!0)}else{const Tt=await fetch(`/api/stom/filter-reports?file=${ot}`);if(!Tt.ok)throw new Error(`필터 리포트 HTTP ${Tt.status}`);W(c,await Tt.json(),!0)}}catch(ot){W(m,ot?.message??"상세 조회 실패",!0)}finally{W(g,!1)}}function T(j){W(u,j,!0),W(v,null),W(c,null),W(d,null),W(f,null),W(h,null),W(m,null);const ot=p(j==="prediction"?I:j==="backtest"?P:E);ot[0]&&C(ot[0])}function k(j){return j==null?"—":j>=1024**3?(j/1024**3).toFixed(2)+" GiB":j>=1024**2?(j/1024**2).toFixed(1)+" MiB":j>=1024?(j/1024).toFixed(1)+" KiB":`${j} B`}function D(j){return j.includes("_kronos.csv")?0:j.includes("_persistence.csv")?1:j.includes("_random.csv")?2:3}function M(j){const ot=j.match(/pred(\d+)/);return ot?-Number(ot[1]):0}function L(j,ot){return[...j].sort((Tt,ce)=>{if(ot==="prediction"){const Qt=M(Tt.name)-M(ce.name);if(Qt!==0)return Qt;const ie=D(Tt.name)-D(ce.name);if(ie!==0)return ie}const ae=(ce.modified_at??0)-(Tt.modified_at??0);return Math.abs(ae)>120?ae:Tt.name.localeCompare(ce.name)})}const I=q(()=>L(p(i),"prediction")),P=q(()=>L(p(n),"backtest")),E=q(()=>L(p(o),"filter")),N=q(()=>p(u)==="prediction"?p(I):p(u)==="backtest"?p(P):p(E)),O=q(()=>p(u)==="prediction"?"예측 결과":p(u)==="backtest"?"백테스트":"필터 리포트");function V(){return p(I).find(j=>j.name.includes("_kronos.csv"))??p(I)[0]}function B(j,ot=1){const Tt=Number(j);return Number.isFinite(Tt)?`${Tt.toFixed(ot)}%`:"—"}function z(j,ot=1){const Tt=Number(j);return Number.isFinite(Tt)?`${(Tt*100).toFixed(ot)}%`:"—"}function H(j,ot=3){const Tt=Number(j);return Number.isFinite(Tt)?Math.abs(Tt)>=1e3?Ft.int(Tt):Tt.toFixed(ot):"—"}function Y(j){const ot=Number(j?.direction_accuracy??0),Tt=Number(j?.avg_actual_return??0);return ot>=.5&&Tt>=0?{label:"실전 후보 검토 가능",tone:"success",message:"방향 적중률이 50% 이상입니다. 그래도 비용 반영 rolling 검증을 함께 확인해야 합니다."}:ot>=.4?{label:"조건식 보완 필요",tone:"warn",message:"방향성은 일부 있지만 50%를 넘지 못했거나 비용 검증이 부족합니다. 조건식과 horizon 비교가 필요합니다."}:{label:"실전 사용 보류",tone:"danger",message:"현재 결과만으로 자동매매 신호로 사용하기 어렵습니다."}}const $=q(()=>Y(p(d)?.overall)),Z=q(()=>p(a)?.rows??[]),Q=q(()=>p(a)?.best_by_rolling_net??p(a)?.best_by_direction??null),at=q(()=>{const j=p(Z);return j.length?{backgroundColor:"transparent",grid:{left:52,right:24,top:42,bottom:44},tooltip:{trigger:"axis"},legend:{top:0,textStyle:{color:"inherit"}},xAxis:{type:"category",data:j.map(ot=>`${ot.horizon}s`),axisLabel:{color:"#64748b"}},yAxis:{type:"value",axisLabel:{formatter:"{value}%",color:"#64748b"},splitLine:{lineStyle:{color:"rgba(148,163,184,.2)"}}},series:[{name:"Kronos 방향 적중률",type:"bar",data:j.map(ot=>Number(ot.direction_accuracy??0)*100),itemStyle:{color:"#2563eb"}},{name:"Random 방향 적중률",type:"bar",data:j.map(ot=>Number(ot.random_direction_accuracy??0)*100),itemStyle:{color:"#94a3b8"}},{name:"Rolling net",type:"line",data:j.map(ot=>Number(ot.rolling_net_return_pct??0)),lineStyle:{color:"#ef4444",width:3},symbolSize:8}]}:{}}),et=q(()=>{const j=p(f)?.visual?.window_series??[];return j.length?{backgroundColor:"transparent",grid:{left:52,right:24,top:34,bottom:42},tooltip:{trigger:"axis"},legend:{top:0,textStyle:{color:"inherit"}},xAxis:{type:"category",data:j.map(ot=>String(ot.timestamp).slice(11,19)),axisLabel:{color:"#64748b"}},yAxis:{type:"value",scale:!0,axisLabel:{color:"#64748b"},splitLine:{lineStyle:{color:"rgba(148,163,184,.22)"}}},series:[{name:"실제 종가",type:"line",smooth:.25,symbol:"none",data:j.map(ot=>ot.actual_close),lineStyle:{color:"#0f172a",width:2.2}},{name:"Kronos 예측",type:"line",smooth:.25,symbol:"none",data:j.map(ot=>ot.pred_close),lineStyle:{color:"#ef4444",width:2,type:"dashed"}}]}:{}}),_t=q(()=>{const j=p(f)?.visual?.return_scatter??[];return j.length?{backgroundColor:"transparent",grid:{left:54,right:24,top:26,bottom:46},tooltip:{trigger:"item",formatter:ot=>{const Tt=j[ot.dataIndex];return`${Tt.symbol}
예측 ${H(Tt.pred_return_window,3)}%
실제 ${H(Tt.actual_return_window,3)}%
${Tt.direction_hit_window?"방향 적중":"방향 실패"}`}},xAxis:{type:"value",name:"예측 등락률 %",axisLabel:{color:"#64748b"},splitLine:{lineStyle:{color:"rgba(148,163,184,.2)"}}},yAxis:{type:"value",name:"실제 등락률 %",axisLabel:{color:"#64748b"},splitLine:{lineStyle:{color:"rgba(148,163,184,.2)"}}},series:[{name:"window",type:"scatter",symbolSize:7,data:j.map(ot=>[ot.pred_return_window,ot.actual_return_window]),itemStyle:{color:ot=>j[ot.dataIndex]?.direction_hit_window?"#22c55e":"#ef4444",opacity:.72}}]}:{}});function Ot(j){return j.modified_at?new Date(j.modified_at*1e3).toLocaleString("ko-KR",{timeZone:"Asia/Seoul"}):"—"}var xt=Klt(),mt=lr(xt),wt=y(mt),ft=_(y(wt),4),K=y(ft),Ct=_(K),Mt=_(mt,2);{var zt=j=>{var ot=Dlt();F(j,ot)},Yt=j=>{var ot=Mlt(),Tt=y(ot);U(()=>A(Tt,`요약 조회 실패: ${p(l)??""}`)),F(j,ot)},we=j=>{var ot=Llt(),Tt=y(ot),ce=_(y(Tt)),ae=y(ce),Qt=_(Tt,2),ie=_(y(Qt)),$e=y(ie),ze=_(Qt,2),De=_(y(ze)),Bt=y(De),Jt=_(ze,2),fe=_(y(Jt)),Ge=y(fe);U((Ve,je,tr,yr)=>{A(ae,Ve),A($e,je),A(Bt,tr),A(Ge,yr)},[()=>Ft.int(p(e).compatible_stock_table_count??0),()=>Ft.int(p(e).eligible_group_count??0),()=>Ft.int(p(e).estimated_samples??0),()=>k(p(e).db_size_bytes)]),F(j,ot)};It(Mt,j=>{p(s)&&!p(e)?j(zt):p(l)?j(Yt,1):p(e)&&j(we,2)})}var Kt=_(Mt,2);{var qe=j=>{var ot=Plt(),Tt=y(ot),ce=_(y(Tt),2),ae=y(ce),Qt=_(Tt,2),ie=y(Qt),$e=_(y(ie),2),ze=y($e),De=_($e,2),Bt=y(De),Jt=_(ie,2);{var fe=yr=>{var Me=Ilt(),mr=y(Me);U(kr=>A(mr,`Rolling net ${kr??""}`),[()=>B(p(Q).rolling_net_return_pct,3)]),F(yr,Me)};It(Jt,yr=>{p(Q)&&yr(fe)})}var Ge=_(Qt,2);Gn(Ge,{get option(){return p(at)},height:"320px"});var Ve=_(Ge,2),je=y(Ve),tr=_(y(je));nt(tr,21,()=>p(Z),ut,(yr,Me)=>{var mr=Rlt();let kr;var zr=y(mr),ea=y(zr),vt=y(ea),bt=_(zr),Gt=y(bt),he=_(bt),Ye=y(he),He=_(he),te=y(He),ne=_(He),Te=y(ne),rr=_(ne),hr=y(rr),vr=_(rr),st=y(vr),At=_(vr),ee=y(At),nr=y(ee);U((wr,Xr,jr,Aa,ba,_a)=>{kr=or(mr,1,"svelte-14272so",null,kr,{"best-row":p(Q)?.horizon===p(Me).horizon}),A(vt,`${p(Me).horizon??""}초`),A(Gt,wr),or(he,1,cn(p(Me).direction_edge_vs_random>=0?"positive":"negative"),"svelte-14272so"),A(Ye,Xr),or(He,1,cn(p(Me).topk_net_return_pct>=0?"positive":"negative"),"svelte-14272so"),A(te,jr),or(ne,1,cn(p(Me).best_filter_net_return_pct>=0?"positive":"negative"),"svelte-14272so"),A(Te,Aa),or(rr,1,cn(p(Me).rolling_net_return_pct>=0?"positive":"negative"),"svelte-14272so"),A(hr,ba),A(st,_a),or(ee,1,`pill ${p(Me).passes_gate?"success":"warn"}`,"svelte-14272so"),A(nr,p(Me).passes_gate?"통과":"보류")},[()=>z(p(Me).direction_accuracy,2),()=>B(p(Me).direction_edge_vs_random*100,2),()=>B(p(Me).topk_net_return_pct,3),()=>B(p(Me).best_filter_net_return_pct,3),()=>B(p(Me).rolling_net_return_pct,3),()=>z(p(Me).rolling_direction_hit_rate,2)]),F(yr,mr)}),U(()=>{or(ce,1,`pill ${p(a)?.passes_any_gate?"success":"warn"}`,"svelte-14272so"),A(ae,p(a)?.passes_any_gate?"확장 가능":"확장 보류"),or(Qt,1,`verdict-card ${p(a)?.passes_any_gate?"success":"warn"}`,"svelte-14272so"),A(ze,p(Q)?`${p(Q).horizon}초가 상대적으로 가장 유망`:"비교 결과 없음"),A(Bt,p(a)?.message)}),F(j,ot)};It(Kt,j=>{p(Z).length&&j(qe)})}var fr=_(Kt,2),re=y(fr),ge=y(re),ye=_(ge,2),se=_(ye,2),St=_(re,2),Et=y(St),ue=y(Et),Se=y(ue),me=y(Se),Xe=y(me),br=_(Se,2),kt=_(ue,2);{var _e=j=>{var ot=jo(),Tt=lr(ot);nt(Tt,17,()=>p(N).slice(0,80),ut,(ce,ae)=>{var Qt=Elt(),ie=y(Qt),$e=y(ie),ze=_(ie,2),De=y(ze);U((Bt,Jt)=>{or(Qt,1,`file-row ${p(v)?.name===p(ae).name?"selected":""}`,"svelte-14272so"),A($e,p(ae).name),A(De,`${Bt??""} · ${Jt??""}`)},[()=>k(p(ae).size_bytes),()=>Ot(p(ae))]),cr("click",Qt,()=>C(p(ae))),F(ce,Qt)}),F(j,ot)},ke=j=>{var ot=Nlt();F(j,ot)};It(kt,j=>{p(N).length?j(_e):j(ke,-1)})}var Tr=_(Et,2),Oe=y(Tr);{var Hr=j=>{var ot=Olt();F(j,ot)},Zr=j=>{var ot=zlt(),Tt=y(ot);U(()=>A(Tt,`⚠ ${p(m)??""}`)),F(j,ot)},da=j=>{var ot=qlt(),Tt=_(lr(ot),2);{var ce=Bt=>{var Jt=Blt(),fe=lr(Jt),Ge=y(fe),Ve=_(y(Ge),2),je=y(Ve),tr=_(Ve,2),yr=y(tr),Me=_(Ge,2),mr=y(Me),kr=_(fe,2),zr=y(kr),ea=_(y(zr)),vt=y(ea),bt=_(ea),Gt=y(bt),he=_(zr,2),Ye=_(y(he)),He=y(Ye),te=_(he,2),ne=_(y(te)),Te=y(ne),rr=_(ne),hr=y(rr),vr=_(te,2),st=_(y(vr)),At=y(st);U((ee,nr,wr,Xr,jr,Aa,ba,_a)=>{or(fe,1,`verdict-card ${p($).tone??""}`,"svelte-14272so"),A(je,p($).label),A(yr,p($).message),or(Me,1,`pill ${p($).tone??""}`,"svelte-14272so"),A(mr,`방향 적중 ${ee??""}`),A(vt,nr),A(Gt,`50% 기준 대비 ${wr??""}`),A(He,Xr),A(Te,`${jr??""} windows`),A(hr,`${Aa??""}종목 · ${ba??""}거래일`),A(At,_a)},[()=>z(p(d).overall.direction_accuracy,2),()=>z(p(d).overall.direction_accuracy,2),()=>Number(p(d).overall.direction_accuracy??0)>=.5?"상회":"미달",()=>B(p(d).overall.mape,3),()=>Ft.int(p(d).overall.windows),()=>Ft.int(p(d).overall.symbols),()=>Ft.int(p(d).overall.sessions),()=>B(p(d).overall.avg_actual_return,3)]),F(Bt,Jt)};It(Tt,Bt=>{p(d).overall&&Bt(ce)})}var ae=_(Tt,2);{var Qt=Bt=>{var Jt=Flt(),fe=_(lr(Jt),2);nt(fe,21,()=>p(h).filters.slice(0,6),ut,(Ge,Ve)=>{var je=Vlt(),tr=y(je),yr=y(tr),Me=_(tr,2),mr=y(Me),kr=_(Me,2),zr=y(kr);U((ea,vt,bt)=>{A(yr,p(Ve).label),A(mr,ea),A(zr,`count ${vt??""} · 실제 ${bt??""}`)},[()=>z(p(Ve).hit_rate,1),()=>Ft.int(p(Ve).count),()=>B(p(Ve).avg_actual_return,3)]),F(Ge,je)}),F(Bt,Jt)};It(ae,Bt=>{p(h)?.filters&&Bt(Qt)})}var ie=_(ae,2);{var $e=Bt=>{var Jt=Hlt(),fe=lr(Jt),Ge=y(fe),Ve=_(y(Ge),2);{var je=mr=>{var kr=Glt(),zr=y(kr);U(()=>A(zr,`${p(f).visual.selected_window.symbol??""} · window ${p(f).visual.selected_window.window_id??""}`)),F(mr,kr)};It(Ve,mr=>{p(f).visual.selected_window&&mr(je)})}var tr=_(Ge,2);Gn(tr,{get option(){return p(et)},height:"320px"});var yr=_(fe,2),Me=_(y(yr),2);Gn(Me,{get option(){return p(_t)},height:"320px"}),F(Bt,Jt)};It(ie,Bt=>{p(f)?.visual?.window_series?.length&&Bt($e)})}var ze=_(ie,2);{var De=Bt=>{var Jt=Ult(),fe=_(lr(Jt),2),Ge=y(fe),Ve=_(y(Ge));nt(Ve,21,()=>p(f).recommendations.slice(0,10),ut,(je,tr,yr)=>{var Me=Wlt(),mr=y(Me);mr.textContent=yr+1;var kr=_(mr),zr=y(kr),ea=_(kr),vt=y(ea),bt=_(ea),Gt=y(bt),he=_(bt),Ye=y(he),He=y(Ye),te=_(he),ne=y(te),Te=_(te),rr=y(Te),hr=_(Te),vr=y(hr);U((st,At,ee)=>{A(zr,p(tr).symbol),A(vt,p(tr).session),A(Gt,st),or(Ye,1,`pill ${p(tr).signal==="BUY_CANDIDATE"?"success":p(tr).signal==="WATCH"?"warn":"danger"}`,"svelte-14272so"),A(He,p(tr).signal),A(ne,At),A(rr,ee),A(vr,p(tr).direction_hit_window?"적중":"실패")},[()=>H(p(tr).kronos_score,1),()=>B(p(tr).pred_return_window,3),()=>B(p(tr).actual_return_window,3)]),F(je,Me)}),F(Bt,Jt)};It(ze,Bt=>{p(f)?.recommendations?.length&&Bt(De)})}F(j,ot)},ua=j=>{var ot=Xlt(),Tt=_(y(ot),2),ce=y(Tt),ae=_(Tt,2);{var Qt=Bt=>{var Jt=$lt(),fe=y(Jt),Ge=_(y(fe)),Ve=y(Ge),je=_(fe,2),tr=_(y(je)),yr=y(tr),Me=_(je,2),mr=_(y(Me)),kr=y(mr),zr=_(Me,2),ea=_(y(zr)),vt=y(ea);U((bt,Gt,he,Ye)=>{A(Ve,bt),A(yr,Gt),A(kr,he),A(vt,Ye)},[()=>B(p(c).metrics.avg_gross_return_pct,3),()=>B(p(c).metrics.avg_net_return_pct,3),()=>z(p(c).metrics.direction_hit_rate,2),()=>B(p(c).metrics.cumulative_return_pct,2)]),F(Bt,Jt)},ie=Bt=>{var Jt=Ylt(),fe=y(Jt),Ge=_(y(fe)),Ve=y(Ge),je=_(fe,2),tr=_(y(je)),yr=y(tr),Me=_(je,2),mr=_(y(Me)),kr=y(mr),zr=_(Me,2),ea=_(y(zr)),vt=y(ea);U((bt,Gt,he,Ye)=>{A(Ve,bt),A(yr,Gt),A(kr,he),A(vt,Ye)},[()=>Ft.int(p(c).summary.fold_count??0),()=>B(p(c).summary.avg_test_net_return_pct,3),()=>z(p(c).summary.positive_test_fold_rate,2),()=>B(p(c).summary.overfit_gap_pct,3)]),F(Bt,Jt)},$e=Bt=>{var Jt=Zlt(),fe=y(Jt),Ge=_(y(fe)),Ve=y(Ge),je=_(fe,2),tr=_(y(je)),yr=y(tr),Me=_(je,2),mr=_(y(Me)),kr=y(mr),zr=_(Me,2),ea=_(y(zr)),vt=y(ea);U((bt,Gt,he,Ye)=>{A(Ve,bt),A(yr,Gt),A(kr,he),A(vt,Ye)},[()=>B(p(c).best_filter.avg_net_return_pct,3),()=>z(p(c).best_filter.direction_hit_rate,2),()=>Ft.int(p(c).best_filter.trade_count??0),()=>z(p(c).best_filter.coverage,2)]),F(Bt,Jt)};It(ae,Bt=>{p(c).metrics?Bt(Qt):p(c).summary?Bt(ie,1):p(c).best_filter&&Bt($e,2)})}var ze=_(ae,2),De=y(ze);U((Bt,Jt)=>{A(ce,p(v)?.name),A(De,`${Bt??""}${Jt??""}`)},[()=>JSON.stringify(p(c),null,2).slice(0,2e3),()=>JSON.stringify(p(c)).length>2e3?` +...`:""]),F(j,ot)},Zt=j=>{var ot=jlt();F(j,ot)};It(Oe,j=>{p(g)?j(Hr):p(m)?j(Zr,1):p(u)==="prediction"&&p(d)?j(da,2):p(c)?j(ua,3):j(Zt,-1)})}U(()=>{ha(K,`background:${p(e)?.inspect_available?"var(--success)":"var(--warn)"}`),A(Ct,` inspect ${p(e)?.inspect_available?"OK":"제한"}`),or(ge,1,cn(p(u)==="prediction"?"active":""),"svelte-14272so"),or(ye,1,cn(p(u)==="backtest"?"active":""),"svelte-14272so"),or(se,1,cn(p(u)==="filter"?"active":""),"svelte-14272so"),A(Xe,p(O))}),cr("click",ge,()=>T("prediction")),cr("click",ye,()=>T("backtest")),cr("click",se,()=>T("filter")),cr("click",br,w),F(r,xt),Yr()}an(["click"]);async function Qe(r,t){try{const e=await fetch(r,t);return e.ok?await e.json():null}catch{return null}}const lo={rlRuns:(r=20)=>Qe(`/api/rl/runs?limit=${r}`),rlProgress:()=>Qe("/api/rl/progress"),rlRun:r=>Qe(`/api/rl/runs/${encodeURIComponent(r)}`),rlActions:(r,t=500)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/actions?limit=${t}`),rlTrades:(r,t=500)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/trades?limit=${t}`),rlEquity:(r,t=500)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/equity?limit=${t}`),rlEpisodes:(r,t=500)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/episodes?limit=${t}`),rlEvents:(r,t=500)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/events?limit=${t}`),rlTable:(r,t,e=500)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/table/${encodeURIComponent(t)}?limit=${e}`),rlWorkflowStages:(r,t=200)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/table/stages?limit=${t}`),rlWorkflowControls:(r,t=200)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/table/controls?limit=${t}`),rlProxyAvailability:(r,t=200)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/table/proxy_availability?limit=${t}`),rlOrderbookPersistence:(r,t=200)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/table/orderbook_persistence?limit=${t}`),rlParticipantStudyGroups:(r,t=200)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/table/participant_study_groups?limit=${t}`),rlFeatureAblation:(r,t=200)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/table/feature_ablation?limit=${t}`),rlCostGate:(r,t=500)=>Qe(`/api/rl/runs/${encodeURIComponent(r)}/cost-gate?limit=${t}`)};function Kf(r,t){return r?.[t]}function Ue(r,t,e="-"){const a=Kf(r,t);return a==null||typeof a=="object"?e:String(a)}function Jlt(r,t=0){const e=Number(r);return Number.isFinite(e)?e:t}function rs(r,t,e=0){return Jlt(Kf(r,t),e)}function tut(r,t){return Kf(r,t)===!0||Kf(r,t)==="True"||Kf(r,t)==="true"}function Bn(r,t=2){const e=Number(r);return Number.isFinite(e)?Ft.pct(e,t):"-"}function Ai(r,t=2){const e=Number(r);return Number.isFinite(e)?Ft.num(e,t):"-"}function eut(r){return r.name.replace(/^stom_1s_2025_/,"")}function kB(r){switch(r){case"baseline":return"RULE baseline";case"sb3_smoke":return"SB3 smoke";case"contextual_bandit":return"RL experiment";case"cost_gate":return"Cost gate";case"performance_leaderboard":return"Performance leaderboard";case"portfolio_paper":return"Portfolio paper";case"opening_30m_rule_filter":return"RULE filter evidence";case"orderbook_rl_readiness":return"RL readiness";default:return r??"unknown"}}function rut(r){return r==="baseline"||r==="opening_30m_rule_filter"?"success":r==="cost_gate"||r==="performance_leaderboard"?"info":r==="sb3_smoke"||r==="contextual_bandit"||r==="portfolio_paper"?"accent":r==="orderbook_rl_readiness"?"warn":""}function CP(r,t){return r instanceof Error?r.message:t}var aut=G('
RULE / RL EVIDENCE · 정규 기능 · Kronos 비의존 · not live-ready RULE MAINLINE RL EXPERIMENT NO-GO 공개 ts_imb RULE baseline · 23bp 23bp cost gate

강화학습

ts_imb RULE baseline은 강화학습 모델이 아닌 RULE MAINLINE입니다. RL EXPERIMENT는 DQN/PPO와 호가창 실험을 비교·반증하는 연구 산출물이며, NO-GO와 not live-ready guardrail을 먼저 표시합니다.

');function iut(r,t){var e=aut(),a=y(e),i=_(y(a),10),n=_(i,2),o=_(y(n));U(s=>{or(i,1,`pill ${t.costGatePassCount?"success":"warn"}`),or(n,1,`pill ${t.progressPct===100?"success":"warn"}`),A(o,`진행률 ${s??""}%`)},[()=>Math.round(t.progressPct)]),F(r,e)}var nut=G('
ORDERBOOK RL READINESS
시초 극초단타 tick+호가창 강화학습 준비도
Artifactorderbook_rl_readiness
Action spacehold · market_buy · market_exit
Observationtick · best quote · depth imbalance · OFI
Coverage

');function out(r,t){$r(t,!0);const e=q(()=>t.run?.summary??{}),a=q(()=>String(p(e).readiness_status??p(e).verdict??"NOT_GENERATED"));var i=nut(),n=y(i),o=_(y(n),2),s=_(y(o),1,!0),l=_(n,2),u=_(y(l),6),v=_(y(u)),c=y(v),d=_(l,2),f=y(d);{var h=m=>{var x=Hc();U(b=>A(x,`source: ${t.run.name??""} · eligible episodes ${b??""}`),[()=>Ai(p(e).eligible_episode_count,0)]),F(m,x)},g=m=>{var x=Hc("readiness artifact 없음. 이 카드는 환경 준비도만 표시하며 자동매매·수익 보장을 뜻하지 않습니다.");F(m,x)};It(f,m=>{t.run?m(h):m(g,-1)})}U((m,x)=>{A(s,p(a)),A(c,`${m??""} quote · ${x??""} spread`)},[()=>Bn(p(e).quote_coverage,1),()=>Bn(p(e).valid_spread_coverage,1)]),F(r,i),Yr()}var sut=G('
RULE MAINLINE RISK
ts_imb RULE baseline sizing
f
K
Daily loss
Cost
TP / SL
Risk unit

이 정책은 RULE mainline 기준 노출값입니다. RL 결과가 이 값을 대체하거나 live-ready/profit model임을 뜻하지 않습니다.

');function lut(r,t){$r(t,!0);const e=q(()=>t.ruleRun?.strategy_context?.risk_policy_summary??{});var a=sut(),i=y(a),n=_(y(i),2),o=_(y(n),1,!0),s=_(i,2),l=y(s),u=_(y(l)),v=y(u),c=_(l,2),d=_(y(c)),f=y(d),h=_(c,2),g=_(y(h)),m=y(g),x=_(h,2),b=_(y(x)),w=y(b),S=_(x,2),C=_(y(S)),T=y(C),k=_(S,2),D=_(y(k)),M=y(D);U((L,I,P,E,N,O,V)=>{A(o,t.selectedLabel||"RULE / RL separation"),A(v,`${L??""}%`),A(f,I),A(m,`${P??""}%`),A(w,`${E??""}bp`),A(T,`TP${N??""} / SL${O??""}`),A(M,`${V??""}%`)},[()=>Ai(p(e).per_trade_fraction_pct,1),()=>Ai(p(e).max_concurrent,0),()=>Ai(p(e).daily_loss_limit_pct,1),()=>Ai(p(e).cost_bps,0),()=>Ai(p(e).tp_pct,0),()=>Ai(p(e).sl_pct,0),()=>Ai(p(e).risk_unit_account_pct,3)]),F(r,a),Yr()}var uut=G(''),vut=G('
RUN SELECTOR
강화학습 산출물
');function cut(r,t){$r(t,!0);var e=vut(),a=_(y(e),2);nt(a,21,()=>t.runs,ut,(i,n)=>{var o=uut();let s;var l=y(o),u=y(l),v=_(l,2),c=y(v);U((d,f,h)=>{s=or(o,1,"",null,s,{active:p(n).name===t.selectedName}),A(u,d),or(v,1,`pill ${f??""}`),A(c,h)},[()=>eut(p(n)),()=>rut(p(n).artifact_type),()=>kB(p(n).artifact_type)]),cr("click",o,()=>t.onSelect(p(n).name)),F(i,o)}),F(r,e),Yr()}an(["click"]);var dut=G('

상세 로딩 중...

'),fut=G('
Artifact
Net evidence
Trades
Model

',1),hut=G('
SELECTED RUN
');function put(r,t){$r(t,!0);const e=q(()=>t.run?.summary??{}),a=q(()=>t.run?.strategy_context??{});var i=hut(),n=y(i),o=y(n),s=_(y(o)),l=y(s),u=_(o,2),v=_(y(u),1,!0),c=_(n,2);{var d=h=>{var g=dut();F(h,g)},f=h=>{var g=fut(),m=lr(g),x=y(m),b=_(y(x)),w=y(b),S=_(x,2),C=_(y(S)),T=y(C),k=_(S,2),D=_(y(k)),M=y(D),L=_(k,2),I=_(y(L)),P=y(I),E=_(m,2),N=y(E);U((O,V,B,z)=>{A(w,t.run.artifact_type),A(T,O),A(M,V),A(P,t.run.model?.model_type??"RULE / table artifact"),A(N,`${p(a).guardrail??"research evidence only; not live-ready and not a profit model"??""} · is_live_ready=${B??""} · is_profit_model=${z??""}`)},[()=>Bn(p(e).avg_episode_net_return_pct??p(e).buy_and_hold_avg_episode_net_return_pct,3),()=>Ai(p(e).trade_count,0),()=>String(p(a).is_live_ready),()=>String(p(a).is_profit_model)]),F(h,g)};It(c,h=>{t.loading?h(d):t.run&&h(f,1)})}U(h=>{A(l,t.run?.name??"선택 없음"),or(u,1,`pill ${p(a).line==="rule_mainline"?"success":"accent"}`),A(v,h)},[()=>p(a).label??kB(t.run?.artifact_type)]),F(r,i),Yr()}var gut=G(" "),yut=G('
candidatealgostatusOOS netmodel path
'),mut=G("OOS BASELINE / negative control "),_ut=G("FEATURE ABLATION "),xut=G('
evidenceidverdict/statusdelta / source
'),but=G("cumulative equity curve "),wut=G("time-bucket performance "),Sut=G("FAILURE REASONS NO-GO visible"),Tut=G("CONTEXT FEATURE SAMPLE "),Aut=G('
panelitemvalue
'),Cut=G(" "),kut=G('
stagestatusevidence
'),Dut=G(" "),Mut=G('
criterionstatusevidence
'),Lut=G('

Select an opening_30m_rl_workflow run to inspect stage evidence.

'),Iut=G('

Artifact
Cost
Split hash
Candidates
Baseline delta
Controls
');function Rut(r,t){$r(t,!0);function e(Zt){return typeof Zt=="object"&&Zt!==null&&!Array.isArray(Zt)}function a(Zt){return Array.isArray(Zt)?Zt.filter(e):[]}function i(Zt,j){return Array.isArray(Zt)?Zt.map(ot=>({[j]:String(ot)})):[]}function n(Zt,j){return e(Zt)?Zt[j]:void 0}const o=q(()=>t.run?.artifact_type==="opening_30m_rl_workflow"),s=q(()=>t.run?.artifact_type==="opening_30m_rule_filter"),l=q(()=>p(s)?"OPENING 30M RULE FILTER":"OPENING 30M RL WORKFLOW"),u=q(()=>p(s)?"RULE FILTER evidence panel":"RL EXPERIMENT evidence panel"),v=q(()=>p(s)?"rule/meta-label evidence · OOS BASELINE · negative controls · feature ablation / FEATURE ABLATION · FAILURE REASONS · cumulative equity curve · time-bucket performance · ts_imb RULE baseline · 23bp · not live-ready · not a profit model. Execution controls and training actions are intentionally absent.":"OPENING 30M RL CANDIDATES · OOS BASELINE · negative controls · feature ablation / FEATURE ABLATION · CONTEXT FEATURE SAMPLE · FAILURE REASONS · cumulative equity curve · time-bucket performance · ts_imb RULE baseline · 23bp · not live-ready · CUMULATIVE REWARD EVIDENCE only. Execution controls and training actions are intentionally absent."),c=q(()=>t.run?.summary??{}),d=q(()=>t.run?.detail??{}),f=q(()=>a(p(d).stages)),h=q(()=>n(p(d).config,"cost_bps")),g=q(()=>t.progress?.pages.find(Zt=>Zt.page==="Opening 30M RL Workflow")??null),m=q(()=>n(p(d).candidate_lifecycle,"training")?p(d).candidate_lifecycle:void 0),x=q(()=>n(p(m),"training")),b=q(()=>n(p(m),"promotion_gate")),w=q(()=>n(p(m),"split_manifest")),S=q(()=>n(p(m),"controls")),C=q(()=>n(p(m),"ablations")),T=q(()=>n(p(m),"context_features")),k=q(()=>a(n(p(x),"candidates"))),D=q(()=>a(n(p(S),"controls"))),M=q(()=>a(n(p(C),"ablations"))),L=q(()=>a(n(p(b),"equity_curve"))),I=q(()=>a(n(p(b),"time_bucket_performance"))),P=q(()=>i(n(p(b),"blocking_reasons"),"reason")),E=q(()=>i(n(p(T),"feature_names"),"feature_name")),N=q(()=>String(n(p(T),"status")??"missing")),O=q(()=>String(n(p(b),"verdict")??p(c).candidate_verdict??p(c).verdict??p(d).verdict??t.progress?.evidence?.latest_opening_workflow_verdict??"NO-GO")),V=q(()=>p(c).cost_bps??p(h)??23),B=q(()=>String(p(c).split_hash??n(p(w),"split_hash")??"-")),z=q(()=>p(f).find(Zt=>Ue(Zt,"name")==="controls")??null),H=q(()=>Ue(p(z),"status","NO-GO")),Y=q(()=>p(c).baseline_delta_pct??p(c).baseline_delta??"-");var $=Iut(),Z=y($),Q=y(Z),at=y(Q),et=y(at),_t=_(at,2),Ot=y(_t),xt=_(Q,2),mt=_(y(xt),1,!0),wt=_(Z,2),ft=y(wt),K=_(wt,2),Ct=y(K),Mt=_(y(Ct)),zt=y(Mt),Yt=_(Ct,2),we=_(y(Yt)),Kt=y(we),qe=_(Yt,2),fr=_(y(qe)),re=y(fr),ge=_(qe,2),ye=_(y(ge)),se=y(ye),St=_(ge,2),Et=_(y(St)),ue=y(Et),Se=_(St,2),me=_(y(Se)),Xe=y(me),br=_(K,2);{var kt=Zt=>{var j=yut(),ot=y(j),Tt=_(y(ot));nt(Tt,21,()=>p(k).slice(0,8),ut,(ce,ae)=>{var Qt=gut(),ie=y(Qt),$e=y(ie),ze=_(ie),De=y(ze),Bt=_(ze),Jt=y(Bt),fe=_(Bt),Ge=y(fe),Ve=_(fe),je=y(Ve);U((tr,yr,Me,mr,kr)=>{A($e,tr),A(De,yr),A(Jt,Me),A(Ge,mr),A(je,kr)},[()=>Ue(p(ae),"candidate_id"),()=>Ue(p(ae),"algorithm"),()=>Ue(p(ae),"status"),()=>String(p(ae).oos_net_return_pct??"-"),()=>Ue(p(ae),"model_path","-")]),F(ce,Qt)}),F(Zt,j)};It(br,Zt=>{p(o)&&p(k).length&&Zt(kt)})}var _e=_(br,2);{var ke=Zt=>{var j=xut(),ot=y(j),Tt=_(y(ot)),ce=y(Tt);nt(ce,17,()=>p(D).slice(0,4),ut,(Qt,ie)=>{var $e=mut(),ze=_(y($e)),De=y(ze),Bt=_(ze),Jt=y(Bt),fe=_(Bt),Ge=y(fe);U((Ve,je,tr)=>{A(De,Ve),A(Jt,je),A(Ge,tr)},[()=>Ue(p(ie),"control_type"),()=>Ue(p(ie),"verdict","NO-GO"),()=>Ue(p(ie),"evaluation_source","-")]),F(Qt,$e)});var ae=_(ce);nt(ae,17,()=>p(M).slice(0,5),ut,(Qt,ie)=>{var $e=_ut(),ze=_(y($e)),De=y(ze),Bt=_(ze),Jt=y(Bt),fe=_(Bt),Ge=y(fe);U((Ve,je,tr)=>{A(De,Ve),A(Jt,je),A(Ge,tr)},[()=>Ue(p(ie),"feature_set_id"),()=>Ue(p(ie),"comparison_status",Ue(p(ie),"passed","NO-GO")),()=>Ue(p(ie),"delta_vs_full_oos_pct","-")]),F(Qt,$e)}),F(Zt,j)};It(_e,Zt=>{p(o)&&(p(D).length||p(M).length)&&Zt(ke)})}var Tr=_(_e,2);{var Oe=Zt=>{var j=Aut(),ot=y(j),Tt=_(y(ot)),ce=y(Tt);nt(ce,17,()=>p(L).slice(0,4),ut,($e,ze)=>{var De=but(),Bt=_(y(De)),Jt=y(Bt),fe=_(Bt),Ge=y(fe);U((Ve,je)=>{A(Jt,Ve),A(Ge,je)},[()=>Ue(p(ze),"step"),()=>Ue(p(ze),"net_return_pct")]),F($e,De)});var ae=_(ce);nt(ae,17,()=>p(I).slice(0,4),ut,($e,ze)=>{var De=wut(),Bt=_(y(De)),Jt=y(Bt),fe=_(Bt),Ge=y(fe);U((Ve,je)=>{A(Jt,Ve),A(Ge,je)},[()=>Ue(p(ze),"bucket"),()=>Ue(p(ze),"net_return_pct")]),F($e,De)});var Qt=_(ae);nt(Qt,17,()=>p(P).slice(0,4),ut,($e,ze)=>{var De=Sut(),Bt=_(y(De)),Jt=y(Bt);U(fe=>A(Jt,fe),[()=>Ue(p(ze),"reason")]),F($e,De)});var ie=_(Qt);nt(ie,17,()=>p(E).slice(0,4),ut,($e,ze)=>{var De=Tut(),Bt=_(y(De)),Jt=y(Bt),fe=_(Bt),Ge=y(fe);U(Ve=>{A(Jt,Ve),A(Ge,p(N))},[()=>Ue(p(ze),"feature_name")]),F($e,De)}),F(Zt,j)};It(Tr,Zt=>{p(o)&&(p(L).length||p(I).length||p(P).length||p(E).length)&&Zt(Oe)})}var Hr=_(Tr,2);{var Zr=Zt=>{var j=kut(),ot=y(j),Tt=_(y(ot));nt(Tt,21,()=>p(f).slice(0,10),ut,(ce,ae)=>{var Qt=Cut(),ie=y(Qt),$e=y(ie),ze=_(ie),De=y(ze),Bt=_(ze),Jt=y(Bt);U((fe,Ge,Ve)=>{A($e,fe),A(De,Ge),A(Jt,Ve)},[()=>Ue(p(ae),"name"),()=>Ue(p(ae),"status"),()=>Ue(p(ae),"evidence",Ue(p(ae),"reason"))]),F(ce,Qt)}),F(Zt,j)},da=Zt=>{var j=Mut(),ot=y(j),Tt=_(y(ot));nt(Tt,21,()=>p(g).criteria.slice(0,10),ut,(ce,ae)=>{var Qt=Dut(),ie=y(Qt),$e=y(ie),ze=_(ie),De=y(ze),Bt=_(ze),Jt=y(Bt);U(()=>{A($e,p(ae).label),A(De,p(ae).passed?"passed":"NO-GO"),A(Jt,p(ae).evidence??"-")}),F(ce,Qt)}),F(Zt,j)},ua=Zt=>{var j=Lut();F(Zt,j)};It(Hr,Zt=>{p(o)&&p(f).length?Zt(Zr):p(g)?.criteria?.length?Zt(da,1):Zt(ua,-1)})}U((Zt,j)=>{A(et,p(l)),A(Ot,p(u)),A(mt,p(O)),A(ft,p(v)),A(zt,t.run?.artifact_type??"opening_30m_rl_workflow"),A(Kt,`${Zt??""}bp`),A(re,p(B)),A(se,p(k).length),A(ue,j),A(Xe,p(H))},[()=>Ai(p(V),1),()=>String(p(Y))]),F(r,$),Yr()}var Put=G(" "),Eut=G(" ORDERBOOK PERSISTENCE"),Nut=G(" participant study"),Out=G(" FEATURE ABLATION"),zut=G('
PARTICIPANT PROXY EVIDENCE
ORDERBOOK PERSISTENCE · PROXY AVAILABILITY · FEATURE ABLATION
NO-GO visible

Missing proxy columns
Proxy rows
Orderbook components
Ablations
proxystatussource
orderbook componentvaluestudy / ablation
');function But(r,t){$r(t,!0);const e=q(()=>t.run?.summary?.missing_proxy_columns),a=q(()=>Array.isArray(p(e))?p(e).join(", "):"-"),i=q(()=>t.run?.artifact_type==="opening_30m_rule_filter"?"RULE FILTER EVIDENCE":t.run?.strategy_context?.label??"RESEARCH EVIDENCE");var n=zut(),o=_(y(n),2),s=y(o),l=_(o,2),u=y(l),v=_(y(u)),c=y(v),d=_(u,2),f=_(y(d)),h=y(f),g=_(d,2),m=_(y(g)),x=y(m),b=_(g,2),w=_(y(b)),S=y(w),C=_(l,2),T=y(C),k=_(y(T));nt(k,21,()=>t.proxyRows.slice(0,8),ut,(N,O)=>{var V=Put(),B=y(V),z=y(B),H=_(B),Y=y(H),$=_(H),Z=y($);U((Q,at,et)=>{A(z,Q),A(Y,at),A(Z,et)},[()=>Ue(p(O),"proxy"),()=>Ue(p(O),"status"),()=>Ue(p(O),"source_column",Ue(p(O),"feature_group"))]),F(N,V)});var D=_(C,2),M=y(D),L=_(y(M)),I=y(L);nt(I,17,()=>t.orderbookRows.slice(0,6),ut,(N,O)=>{var V=Eut(),B=y(V),z=y(B),H=_(B),Y=y(H);U(($,Z)=>{A(z,$),A(Y,Z)},[()=>Ue(p(O),"component"),()=>Ai(p(O).value,3)]),F(N,V)});var P=_(I);nt(P,17,()=>t.studyRows.slice(0,3),ut,(N,O)=>{var V=Nut(),B=y(V),z=y(B),H=_(B),Y=y(H);U(($,Z)=>{A(z,$),A(Y,Z)},[()=>Ue(p(O),"group"),()=>Ai(p(O).episode_count,0)]),F(N,V)});var E=_(P);nt(E,17,()=>t.ablationRows.slice(0,3),ut,(N,O)=>{var V=Out(),B=y(V),z=y(B),H=_(B),Y=y(H);U(($,Z)=>{A(z,$),A(Y,Z)},[()=>Ue(p(O),"feature_ablation",Ue(p(O),"feature_set_id")),()=>Ue(p(O),"verdict",String(p(O).passed??"NO-GO"))]),F(N,V)}),U((N,O,V)=>{A(s,`${p(i)??""} · 23bp · not live-ready · participant proxy evidence only; actor identity is not inferred.`),A(c,p(a)),A(h,N),A(x,O),A(S,V)},[()=>Ai(t.proxyRows.length,0),()=>Ai(t.orderbookRows.length,0),()=>Ai(t.ablationRows.length,0)]),F(r,n),Yr()}function DB(r){return String(r??"").replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function MB(r){return`${DB(r)}`}function Qf(r){return DB(r)}function LB(r){return r.filter(t=>!!t).join("
")}function kP(r){return typeof r=="object"&&r!==null}function F_(r){return Array.isArray(r)?kP(r[0])?r[0]:null:kP(r)?r:null}function IB(r){return Number(r)>=0?"#16a34a":"#dc2626"}function Vut(r){const t=r.slice(0,12);return t.length?{backgroundColor:"transparent",grid:{left:58,right:28,top:42,bottom:48},tooltip:{trigger:"axis",formatter:e=>{const a=t[F_(e)?.dataIndex??0];return LB([MB(Ue(a,"model",Ue(a,"policy"))),Qf(`source ${Ue(a,"source")}`),Qf(`net evidence ${Bn(rs(a,"avg_episode_net_return_pct"),3)}`),Qf(`MDD ${Bn(rs(a,"max_drawdown_pct"),2)}`)])}},xAxis:{type:"category",data:t.map(e=>Ue(e,"model",Ue(e,"policy"))),axisLabel:{color:"#64748b",rotate:20}},yAxis:{type:"value",name:"net/MDD %",axisLabel:{formatter:"{value}%",color:"#64748b"}},series:[{name:"avg episode net",type:"bar",data:t.map(e=>rs(e,"avg_episode_net_return_pct")),itemStyle:{color:"#0f766e",borderRadius:[5,5,0,0]}},{name:"MDD",type:"bar",data:t.map(e=>rs(e,"max_drawdown_pct")),itemStyle:{color:"#ef4444",borderRadius:[5,5,0,0]}}]}:{}}function Fut(r){return r.length?{backgroundColor:"transparent",grid:{left:58,right:28,top:42,bottom:48},tooltip:{trigger:"axis"},xAxis:{type:"category",data:r.map(t=>Ue(t,"policy")),axisLabel:{color:"#64748b",rotate:20}},yAxis:{type:"value",name:"net %",axisLabel:{formatter:"{value}%",color:"#64748b"}},series:[{name:"23bp cost gate",type:"bar",data:r.map(t=>rs(t,"avg_episode_net_return_pct")),itemStyle:{color:t=>IB(F_(t)?.value),borderRadius:[5,5,0,0]}}]}:{}}function Gut(r){if(!r.length)return{};const t=r.some(e=>"net_return_pct"in e);return{backgroundColor:"transparent",grid:{left:58,right:24,top:30,bottom:38},tooltip:{trigger:"axis"},xAxis:{type:"category",data:r.map((e,a)=>Ue(e,"timestamp",Ue(e,"step",String(a+1))).slice(0,19)),axisLabel:{color:"#64748b"}},yAxis:{type:"value",name:"net evidence %",axisLabel:{formatter:"{value}%",color:"#64748b"}},series:[{name:"Time equity curve",type:"line",smooth:.2,symbol:"none",data:r.map(e=>t?rs(e,"net_return_pct"):(rs(e,"equity",1)-1)*100),lineStyle:{color:"#0f766e",width:2.4}}]}}function Hut(r,t,e){if(!r.length)return{};const a=new Map;for(const s of t)a.set(Ue(s,"episode_id",""),rs(s,"baseline_net_return_pct"));let i=0,n="";const o=r.map((s,l)=>{const u=Ue(s,"episode_id",""),v=!!(u&&n&&u!==n);return n=u||n,i+=rs(s,"reward")*100,{row:s,idx:l,episodeId:u,boundary:v,cumulativeRewardPct:i,baseline:a.get(u)??null}});return{backgroundColor:"transparent",grid:{left:64,right:34,top:42,bottom:48},tooltip:{trigger:"axis",formatter:s=>{const l=o[F_(s)?.dataIndex??0];return LB([MB(`${l?.episodeId||e} #${(l?.idx??0)+1}`),Qf(`reward evidence ${Bn(l?.cumulativeRewardPct,3)}`),Qf(`action ${Ue(l?.row,"action_name",Ue(l?.row,"action"))}`)])}},xAxis:{type:"category",data:o.map(s=>String(s.idx+1)),axisLabel:{color:"#64748b"}},yAxis:{type:"value",name:"cumulative reward %",axisLabel:{formatter:"{value}%",color:"#64748b"}},series:[{name:"model cumulative reward",type:"line",symbol:"none",data:o.map(s=>s.cumulativeRewardPct),lineStyle:{color:"#7c3aed",width:2.4},markLine:{silent:!0,symbol:"none",label:{show:!1},data:o.filter(s=>s.boundary).map(s=>({xAxis:s.idx}))}},{name:"ts_imb baseline",type:"line",symbol:"none",data:o.map(s=>s.baseline),lineStyle:{color:"#f59e0b",type:"dashed",width:2}}]}}function Wut(r){const t=r.slice(0,80);return t.length?{backgroundColor:"transparent",grid:{left:58,right:24,top:30,bottom:46},tooltip:{trigger:"axis"},xAxis:{type:"category",data:t.map((e,a)=>Ue(e,"symbol",Ue(e,"policy",String(a+1)))),axisLabel:{color:"#64748b",rotate:t.length>18?40:0}},yAxis:{type:"value",name:"trade net %",axisLabel:{formatter:"{value}%",color:"#64748b"}},series:[{name:"Net return evidence",type:"bar",data:t.map(e=>rs(e,"net_return_pct")),itemStyle:{color:e=>IB(F_(e)?.value),borderRadius:[4,4,0,0]}}]}:{}}function Uut(r){return r.filter(t=>tut(t,"passes_cost_gate")).length}var qut=G('

No leaderboard rows.

'),$ut=G('

No cost gate rows.

'),Yut=G('

No actions/live event data for cumulative reward evidence.

'),Zut=G('
Performance leaderboard
23bp cost gate evidence
CUMULATIVE REWARD EVIDENCE + ts_imb baseline
Time equity curve / net-return evidence
');function Xut(r,t){$r(t,!0);const e=q(()=>Vut(t.leaderboardRows)),a=q(()=>Fut(t.gateRows)),i=q(()=>Gut(t.equityRows)),n=q(()=>Hut(t.actionRows,t.episodeRows,t.selectedName)),o=q(()=>Wut(t.tradeRows));var s=Zut(),l=y(s),u=_(y(l),2);{var v=D=>{Gn(D,{get option(){return p(e)},height:"300px"})},c=D=>{var M=qut();F(D,M)};It(u,D=>{t.leaderboardRows.length?D(v):D(c,-1)})}var d=_(l,2),f=_(y(d),2);{var h=D=>{Gn(D,{get option(){return p(a)},height:"300px"})},g=D=>{var M=$ut();F(D,M)};It(f,D=>{t.gateRows.length?D(h):D(g,-1)})}var m=_(d,2),x=_(y(m),2);{var b=D=>{Gn(D,{get option(){return p(n)},height:"300px"})},w=D=>{var M=Yut();F(D,M)};It(x,D=>{t.actionRows.length?D(b):D(w,-1)})}var S=_(m,2),C=_(y(S),2);{var T=D=>{Gn(D,{get option(){return p(i)},height:"300px"})},k=D=>{Gn(D,{get option(){return p(o)},height:"300px"})};It(C,D=>{t.equityRows.length?D(T):D(k,-1)})}F(r,s),Yr()}var jut=G(" "),Kut=G(" "),Qut=G("control "),Jut=G("ablation "),tvt=G("NO-GO --false"),evt=G('
RULE filter controls ? ablations ? NO-GO reasons
typeidfiltercomparisonpassed
'),rvt=G('
성과 리더보드 · DQN/PPO vs RULE
ranksourcemodelnet evidencecost gate
거래별 net-return evidence
symbolpolicy/modelepisodenet
25bp cost gate compatibility marker',1);function avt(r,t){$r(t,!0);let e=Ef(t,"ruleFilterControlRows",19,()=>[]),a=Ef(t,"ruleFilterAblationRows",19,()=>[]),i=Ef(t,"ruleFilterFailureRows",19,()=>[]);var n=rvt(),o=lr(n),s=_(y(o),2),l=y(s),u=_(y(l));nt(u,21,()=>t.leaderboardRows.slice(0,12),ut,(m,x,b)=>{var w=jut(),S=y(w),C=y(S),T=_(S),k=y(T),D=_(T),M=y(D),L=_(D),I=y(L),P=_(L),E=y(P);U((N,O,V,B,z)=>{A(C,N),A(k,O),A(M,V),A(I,B),A(E,z)},[()=>Ai(p(x).rank??b+1,0),()=>Ue(p(x),"source"),()=>Ue(p(x),"model",Ue(p(x),"policy")),()=>Bn(p(x).avg_episode_net_return_pct,3),()=>String(p(x).passes_cost_gate??"-")]),F(m,w)});var v=_(o,2),c=_(y(v),2),d=y(c),f=_(y(d));nt(f,21,()=>t.tradeRows.slice(0,16),ut,(m,x)=>{var b=Kut(),w=y(b),S=y(w),C=_(w),T=y(C),k=_(C),D=y(k),M=_(k),L=y(M);U((I,P,E,N)=>{A(S,I),A(T,P),A(D,E),A(L,N)},[()=>Ue(p(x),"symbol"),()=>Ue(p(x),"model",Ue(p(x),"policy")),()=>Ue(p(x),"episode_id"),()=>Bn(p(x).net_return_pct,3)]),F(m,b)});var h=_(v,2);{var g=m=>{var x=evt(),b=_(y(x),2),w=y(b),S=_(y(w)),C=y(S);nt(C,17,()=>e().slice(0,8),ut,(D,M)=>{var L=Qut(),I=_(y(L)),P=y(I),E=_(I),N=y(E),O=_(E),V=y(O),B=_(O),z=y(B);U((H,Y,$,Z)=>{A(P,H),A(N,Y),A(V,$),A(z,Z)},[()=>Ue(p(M),"control_type"),()=>Bn(p(M).filter_oos_net_return_pct,3),()=>Bn(p(M).control_net_return_pct,3),()=>String(p(M).passed??"-")]),F(D,L)});var T=_(C);nt(T,17,()=>a().slice(0,8),ut,(D,M)=>{var L=Jut(),I=_(y(L)),P=y(I),E=_(I),N=y(E),O=_(E),V=y(O),B=_(O),z=y(B);U((H,Y,$,Z)=>{A(P,H),A(N,Y),A(V,$),A(z,Z)},[()=>Ue(p(M),"feature_set_id"),()=>Bn(p(M).full_context_return_pct,3),()=>Bn(p(M).ablated_return_pct,3),()=>String(p(M).passed??"-")]),F(D,L)});var k=_(T);nt(k,17,()=>i().slice(0,8),ut,(D,M)=>{var L=tvt(),I=_(y(L)),P=y(I);U(E=>A(P,E),[()=>Ue(p(M),"reason")]),F(D,L)}),F(m,x)};It(h,m=>{(e().length||a().length||i().length)&&m(g)})}F(r,n),Yr()}var ivt=G('
강화학습 대시보드 조회 실패

'),nvt=G('

강화학습 산출물을 불러오는 중...

'),ovt=G('
',1);function svt(r,t){$r(t,!0);let e=rt(Fr([])),a=rt(""),i=rt(null),n=rt(null),o=rt(null),s=rt(Fr([])),l=rt(Fr([])),u=rt(Fr([])),v=rt(Fr([])),c=rt(Fr([])),d=rt(Fr([])),f=rt(Fr([])),h=rt(Fr([])),g=rt(Fr([])),m=rt(Fr([])),x=rt(Fr([])),b=rt(Fr([])),w=rt(!1),S=rt(!1),C=rt(null);const T=q(()=>p(e).find(K=>K.strategy_context?.line==="rule_mainline"||K.artifact_type==="baseline")??null),k=q(()=>p(e).find(K=>K.artifact_type==="opening_30m_rl_workflow"&&K.name.includes("oos_candidate"))??p(e).find(K=>K.artifact_type==="opening_30m_rl_workflow")??null),D=q(()=>p(e).find(K=>K.artifact_type==="orderbook_rl_readiness")??null),M=q(()=>p(o)?.gate?.rows??[]),L=q(()=>p(n)?.overall_progress_pct??0),I=q(()=>p(i)?.strategy_context?.label??p(T)?.strategy_context?.label??"RULE MAINLINE");mn(()=>{E()});function P(K){return K.find(Ct=>Ct.artifact_type==="opening_30m_rule_filter")??K.find(Ct=>Ct.artifact_type==="opening_30m_rl_workflow"&&Ct.name.includes("oos_candidate"))??K.find(Ct=>Ct.artifact_type==="opening_30m_rl_workflow")??K.find(Ct=>Ct.strategy_context?.line==="rule_mainline")??K.find(Ct=>Ct.artifact_type==="baseline")??K.find(Ct=>Ct.artifact_type==="performance_leaderboard")??K[0]}async function E(){W(w,!0),W(C,null);try{const[K,Ct]=await Promise.all([lo.rlRuns(50),lo.rlProgress()]);W(e,K?.runs??[],!0),W(n,Ct,!0);const Mt=P(p(e));Mt&&await O(Mt.name,p(e))}catch(K){W(C,CP(K,"RL dashboard load failed"),!0)}finally{W(w,!1)}}async function N(K,Ct){try{return(await lo.rlTable(K,Ct,200)).rows??[]}catch(Mt){if(Mt instanceof Error)return[];throw Mt}}async function O(K,Ct=p(e)){W(a,K,!0),W(S,!0),W(C,null);try{const Mt=Ct.find(ye=>ye.artifact_type==="cost_gate"),zt=Ct.find(ye=>ye.artifact_type==="performance_leaderboard"),[Yt,we,Kt,qe,fr,re,ge]=await Promise.all([lo.rlRun(K),zt?lo.rlTable(zt.name,"leaderboard",200):Promise.resolve(null),lo.rlTrades(K,160),lo.rlActions(K,2e3),lo.rlEquity(K,360),lo.rlEpisodes(K,160),Mt?lo.rlCostGate(Mt.name,120):Promise.resolve(null)]);if(W(i,Yt,!0),W(m,[],!0),W(x,[],!0),W(b,[],!0),Yt.artifact_type==="opening_30m_rule_filter"){const[ye,se,St,Et,ue,Se,me,Xe]=await Promise.all([N(K,"rule_filter_equity_curve"),N(K,"rule_filter_time_buckets"),N(K,"rule_filter_controls"),N(K,"rule_filter_ablations"),N(K,"rule_filter_failure_reasons"),N(K,"rule_filter_opportunity_cost"),N(K,"rule_filter_proxy_availability"),N(K,"rule_filter_orderbook_persistence")]);W(s,[],!0),W(o,null),W(l,se,!0),W(u,ue,!0),W(v,ye,!0),W(c,Se,!0),W(d,me,!0),W(f,Xe,!0),W(h,[],!0),W(g,Et,!0),W(m,St,!0),W(x,Et,!0),W(b,ue,!0)}else if(Yt.artifact_type==="opening_30m_rl_workflow"){const[ye,se,St,Et,ue,Se,me,Xe]=await Promise.all([N(K,"candidate_equity_curve"),N(K,"candidate_time_buckets"),N(K,"candidate_controls"),N(K,"candidate_ablations"),N(K,"proxy_availability"),N(K,"orderbook_persistence"),N(K,"participant_study_groups"),N(K,"feature_ablation")]);W(s,[],!0),W(o,null),W(l,se,!0),W(u,[],!0),W(v,ye,!0),W(c,St,!0),W(d,ue,!0),W(f,Se,!0),W(h,me,!0),W(g,Et.length?Et:Xe,!0)}else W(s,we?.rows??[],!0),W(l,Kt?.rows??[],!0),W(u,qe?.rows??[],!0),W(v,fr?.rows??[],!0),W(c,re?.rows??[],!0),W(o,ge,!0),W(d,[],!0),W(f,[],!0),W(h,[],!0),W(g,[],!0),W(m,[],!0),W(x,[],!0),W(b,[],!0)}catch(Mt){W(C,CP(Mt,`${K} detail load failed`),!0)}finally{W(S,!1)}}var V=ovt(),B=lr(V);{let K=q(()=>Uut(p(M)));iut(B,{get progressPct(){return p(L)},get costGatePassCount(){return p(K)}})}var z=_(B,2);{var H=K=>{var Ct=ivt(),Mt=_(y(Ct),2),zt=y(Mt);U(()=>A(zt,p(C))),F(K,Ct)};It(z,K=>{p(C)&&K(H)})}var Y=_(z,2);{let K=q(()=>p(D)??p(k));out(Y,{get run(){return p(K)}})}var $=_(Y,2),Z=y($);cut(Z,{get runs(){return p(e)},get selectedName(){return p(a)},onSelect:K=>void O(K)});var Q=_(Z,2),at=y(Q);{var et=K=>{var Ct=nvt();F(K,Ct)};It(at,K=>{p(w)&&!p(e).length&&K(et)})}var _t=_(at,2);lut(_t,{get ruleRun(){return p(T)},get selectedLabel(){return p(I)}});var Ot=_(_t,2);put(Ot,{get run(){return p(i)},get loading(){return p(S)}});var xt=_(Ot,2);Rut(xt,{get run(){return p(i)},get progress(){return p(n)}});var mt=_(xt,2);But(mt,{get run(){return p(i)},get proxyRows(){return p(d)},get orderbookRows(){return p(f)},get studyRows(){return p(h)},get ablationRows(){return p(g)}});var wt=_(mt,2);Xut(wt,{get leaderboardRows(){return p(s)},get gateRows(){return p(M)},get equityRows(){return p(v)},get actionRows(){return p(u)},get episodeRows(){return p(c)},get tradeRows(){return p(l)},get selectedName(){return p(a)}});var ft=_(wt,2);avt(ft,{get leaderboardRows(){return p(s)},get tradeRows(){return p(l)},get ruleFilterControlRows(){return p(m)},get ruleFilterAblationRows(){return p(x)},get ruleFilterFailureRows(){return p(b)}}),F(r,V),Yr()}const Da={progress:()=>Qe("/api/daily-ohlcv/progress"),dbSummary:()=>Qe("/api/daily-ohlcv/db-summary?table_limit=25&flag_limit=25&window_limit=10"),universePreview:()=>Qe("/api/daily-ohlcv/universe/preview?limit=25"),artifacts:()=>Qe("/api/daily-ohlcv/artifacts?limit=25"),datasetLatest:()=>Qe("/api/daily-ohlcv/dataset/latest?limit=15"),datasetChart:()=>Qe("/api/daily-ohlcv/charts/dataset"),predictionLatest:()=>Qe("/api/daily-ohlcv/prediction/latest?limit=15"),portfolioLatest:()=>Qe("/api/daily-ohlcv/portfolio/latest?limit=15"),walkForwardLatest:()=>Qe("/api/daily-ohlcv/walk-forward/latest?limit=15"),registryLatest:()=>Qe("/api/daily-ohlcv/registry/latest?limit=15"),predictionChart:()=>Qe("/api/daily-ohlcv/charts/prediction"),portfolioChart:()=>Qe("/api/daily-ohlcv/charts/portfolio"),walkForwardChart:()=>Qe("/api/daily-ohlcv/charts/walk-forward"),gateLatest:()=>Qe("/api/daily-ohlcv/gate/latest"),decisionCockpitChart:()=>Qe("/api/daily-ohlcv/charts/decision-cockpit"),scenarios:()=>Qe("/api/daily-ohlcv/scenarios"),scenarioRuns:()=>Qe("/api/daily-ohlcv/scenario-runs?limit=25"),rlEnvGuide:()=>Qe("/api/daily-ohlcv/rl-env-guide"),researchWorkflows:()=>Qe("/api/daily-ohlcv/research-workflows"),researchWorkflowDetail:r=>Qe(`/api/daily-ohlcv/research-workflows/${encodeURIComponent(r)}`),researchJobIntents:(r,t)=>Qe(`/api/daily-ohlcv/research-workflows/${encodeURIComponent(r)}/job-intents`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t)}),researchJobs:()=>Qe("/api/daily-ohlcv/research-jobs?limit=25"),researchJobDetail:r=>Qe(`/api/daily-ohlcv/research-jobs/${encodeURIComponent(r)}`),rejectionAnalytics:()=>Qe("/api/daily-ohlcv/rejection-analytics?limit=25"),flowChart:()=>Qe("/api/daily-ohlcv/charts/flow"),glossaryChart:()=>Qe("/api/daily-ohlcv/charts/glossary"),researchDiagnosticsChart:()=>Qe("/api/daily-ohlcv/charts/research-diagnostics"),equityOverlayChart:()=>Qe("/api/daily-ohlcv/charts/equity-overlay"),walkForwardHeatmapChart:()=>Qe("/api/daily-ohlcv/charts/walk-forward-heatmap"),runScatterChart:()=>Qe("/api/daily-ohlcv/charts/run-scatter"),universeBreakdownChart:()=>Qe("/api/daily-ohlcv/charts/universe-breakdown"),symbolChart:(r,t=160)=>Qe(`/api/daily-ohlcv/charts/symbol/${encodeURIComponent(r)}?limit=${t}`),symbol:(r,t=20)=>Qe(`/api/daily-ohlcv/symbol/${encodeURIComponent(r)}?limit=${t}`)};var lvt=G('
D0-D9 NOT_STARTED
일봉 연구 진행 상태 대기
API payload가 도착하기 전에는 어떤 GO/수익/실거래 상태도 추론하지 않습니다.
'),uvt=G(' '),vvt=G('
'),cvt=G(" "),dvt=G('
검증
'),fvt=G('
'),hvt=G(' '),pvt=G('
'),gvt=G('
D0-D9 Progress

일봉 연구 진행 상태

D0-D9 provenance / exact checks
');function yvt(r,t){$r(t,!0);function e(g){return g==="PASS"?"success":g==="WATCH"||g==="RUNNING"||g==="RESEARCH_ONLY"?"warn":g==="NO-GO"||g==="BLOCKED"?"danger":""}var a=gvt(),i=y(a),n=_(y(i),2),o=_(y(n),1,!0),s=_(i,2),l=y(s),u=_(s,2),v=y(u);{var c=g=>{var m=lvt();F(g,m)},d=g=>{var m=jo(),x=lr(m);nt(x,17,()=>t.progress?.stages??[],ut,(b,w)=>{var S=fvt(),C=y(S),T=y(C),k=y(T),D=_(T,2),M=_(y(D),1,!0),L=_(C,2),I=y(L),P=_(L,2),E=y(P),N=_(P,2);{var O=z=>{var H=vvt();nt(H,21,()=>p(w).lock_labels,ut,(Y,$)=>{var Z=uvt(),Q=y(Z);U(()=>A(Q,p($))),F(Y,Z)}),F(z,H)};It(N,z=>{p(w).lock_labels?.length&&z(O)})}var V=_(N,2);{var B=z=>{var H=dvt(),Y=_(y(H),2);nt(Y,17,()=>p(w).verification_commands,ut,($,Z)=>{var Q=cvt(),at=y(Q);U(()=>A(at,p(Z))),F($,Q)}),F(z,H)};It(V,z=>{p(w).verification_commands?.length&&z(B)})}U(z=>{xe(S,"data-status",p(w).status),A(k,p(w).id),or(D,1,`pill ${z??""}`,"svelte-1fqdo8r"),A(M,p(w).status),A(I,p(w).label),A(E,p(w).evidence)},[()=>e(p(w).status)]),F(b,S)}),F(g,m)};It(v,g=>{(t.progress?.stages?.length??0)===0?g(c):g(d,-1)})}var f=_(u,2),h=_(y(f),2);nt(h,17,()=>t.progress?.provenance_matrix??[],ut,(g,m)=>{var x=pvt(),b=y(x),w=y(b),S=_(b,2),C=y(S),T=_(S,2),k=y(T),D=_(T,2);nt(D,21,()=>p(m).verification_commands??["verification pending"],ut,(M,L)=>{var I=hvt(),P=y(I);U(()=>A(P,p(L))),F(M,I)}),U(M=>{A(w,p(m).id),A(C,p(m).status),A(k,M)},[()=>(p(m).lock_labels??[]).join(" · ")]),F(g,x)}),U(()=>{A(o,t.progress?.overall_status??"LOADING"),A(l,t.progress?.guardrail??"일봉 대시보드는 읽기 전용 증거 화면입니다.")}),F(r,a),Yr()}var mvt=G(' '),_vt=G(' '),xvt=G(' '),bvt=G('
D0 DB Analysis

일봉 DB 분석 탭

테이블
총 행
최초/최신
가격 기준
보정 확정
수익률 라벨
가격 보정 상태:
차단 의미: adjusted/raw, split, dividend 기준이 증명되기 전 decision-grade return label과 model_build_allowed는 잠금 상태입니다.

가격 기준 사용 안내

sectioncan domust not donext

허용/차단 용도

allowed:
blocked:
required evidence:

분할/급등락 의심 창

대표 split-like/discontinuity evidence입니다. corporate action proof가 아니며 price_basis UNKNOWN_CONFIRMED를 지지하는 WATCH 근거입니다.

tabledateratio

품질 플래그

tableflagvalue
');function wvt(r,t){$r(t,!0);const e=ft=>typeof ft=="number"?ft.toLocaleString("ko-KR"):"—";var a=bvt(),i=y(a),n=_(y(i),2),o=_(y(n),1,!0),s=_(i,2),l=y(s),u=_(y(l)),v=y(u),c=_(l,2),d=_(y(c)),f=y(d),h=_(c,2),g=_(y(h)),m=y(g),x=_(h,2),b=_(y(x)),w=y(b),S=_(x,2),C=_(y(S)),T=y(C),k=_(S,2),D=_(y(k)),M=y(D),L=_(s,2),I=_(y(L)),P=_(L,2),E=y(P),N=_(y(E),2),O=y(N),V=_(y(O));nt(V,21,()=>t.summary?.price_basis_user_guidance??[],ut,(ft,K)=>{var Ct=mvt(),Mt=y(Ct),zt=y(Mt),Yt=_(Mt),we=y(Yt),Kt=_(Yt),qe=y(Kt),fr=_(Kt),re=y(fr);U(()=>{A(zt,p(K).section??"D0"),A(we,p(K).can_do??"read-only evidence inspection"),A(qe,p(K).must_not_do??"decision-grade return/model promotion"),A(re,p(K).next_action??"verify adjusted/raw/split/dividend policy")}),F(ft,Ct)});var B=_(E,2),z=_(y(B),2),H=_(y(z)),Y=_(H,4),$=_(Y,4),Z=_(P,2),Q=y(Z),at=_(y(Q),4),et=y(at),_t=_(y(et));nt(_t,21,()=>t.summary?.material_unknown_adjustment_windows??[],ut,(ft,K)=>{var Ct=_vt(),Mt=y(Ct),zt=y(Mt),Yt=_(Mt),we=y(Yt),Kt=_(Yt),qe=y(Kt);U(fr=>{A(zt,p(K).table),A(we,`${p(K).previous_date??""} → ${p(K).date??""}`),A(qe,fr)},[()=>Number(p(K).open_to_previous_close_ratio??0).toFixed(2)]),F(ft,Ct)});var Ot=_(Q,2),xt=_(y(Ot),2),mt=y(xt),wt=_(y(mt));nt(wt,21,()=>t.summary?.quality_flags??[],ut,(ft,K)=>{var Ct=xvt(),Mt=y(Ct),zt=y(Mt),Yt=_(Mt),we=y(Yt),Kt=_(Yt),qe=y(Kt);U(()=>{A(zt,p(K).table),A(we,p(K).flag),A(qe,p(K).value)}),F(ft,Ct)}),U((ft,K,Ct,Mt,zt)=>{A(o,t.summary?.decision_grade_status??"WATCH"),A(v,ft),A(f,K),A(m,`${t.summary?.first_date??"—"??""} → ${t.summary?.latest_date??"—"??""}`),A(w,t.summary?.price_basis??"unknown"),A(T,t.summary?.price_basis_status??"UNKNOWN_CONFIRMED"),A(M,t.summary?.decision_grade_return_status??"BLOCKED_UNTIL_PRICE_BASIS_VERIFIED"),A(I,` ${t.summary?.price_basis_evidence??"원천 DB에 수정주가/원시가 여부가 명시되지 않아 decision-grade 수익률은 WATCH입니다."??""} `),A(H,` ${Ct??""} `),A(Y,` ${Mt??""} `),A($,` ${zt??""}`)},[()=>e(t.summary?.table_count),()=>e(t.summary?.total_rows),()=>(t.summary?.price_basis_allowed_uses??[]).join(" · ")||"read_only_db_coverage_and_quality_inspection",()=>(t.summary?.price_basis_blocked_uses??[]).join(" · ")||"decision_grade_return_labels · model_build_or_candidate_promotion",()=>(t.summary?.price_basis_required_evidence??[]).join(" · ")||"official adjusted/raw/split/dividend policy evidence"]),F(r,a),Yr()}var Svt=G("
  • "),Tvt=G("
  • "),Avt=G("
  • "),Cvt=G(' '),kvt=G('
    '),Dvt=G(' '),Mvt=G('
    D1 Universe Management

    코스피·코스닥 보통주 유니버스

    ETF/ETN/펀드/스팩/리츠/우선주/미확인/Q상품은 기본 제외합니다. 공식 KRX 또는 수동 검토 전까지 포함 유니버스도 WATCH입니다.

    포함
    제외
    stockinfo 매칭
    미매칭 격리
    공식 메타데이터
    공식 미매칭
    격리 artifact
    공식 coverage
    검증 확정
    공식 검증: KRX/manual CSV ingestion contract는 code,name,market,instrument_type입니다. 현재 공식 메타데이터가 없으면 WATCH_HEURISTIC_UNIVERSE와 quarantine evidence를 유지합니다.
    허용 사용
      차단 사용
        필수 증거
          sectionmeaningaction

          제외 사유

          미리보기

          codenametypeincludereviewdrilldown
          ');function Lvt(r,t){$r(t,!0);const e=re=>typeof re=="number"?re.toLocaleString("ko-KR"):"—",a=["research_universe_preview","exclusion_reason_review","quarantine_backlog_triage","dashboard_evidence_navigation"],i=["model_build_or_candidate_promotion","paper_forward_or_live_readiness_claims","official_common_equity_certification_claims"],n=["official_or_manual_krx_csv_with_code_name_market_instrument_type","six_character_string_codes_preserving_leading_zeros","kospi_kosdaq_common_equity_instrument_type_review","quarantine_artifact_for_unmatched_or_excluded_symbols","dated_manifest_with_metadata_sha_and_review_status"],o=(re,ge)=>re?.length?re:ge,s=(re,ge)=>typeof re[ge]=="string"?re[ge]:String(re[ge]??"—");var l=Mvt(),u=y(l),v=_(y(u),2),c=_(y(v),1,!0),d=_(u,4),f=y(d),h=_(y(f)),g=y(h),m=_(f,2),x=_(y(m)),b=y(x),w=_(m,2),S=_(y(w)),C=y(S),T=_(w,2),k=_(y(T)),D=y(k),M=_(T,2),L=_(y(M)),I=y(L),P=_(M,2),E=_(y(P)),N=y(E),O=_(P,2),V=_(y(O)),B=y(V),z=_(O,2),H=_(y(z)),Y=y(H),$=_(z,2),Z=_(y($)),Q=y(Z),at=_(d,4),et=y(at),_t=_(y(et),2);nt(_t,21,()=>o(t.universe?.universe_allowed_uses,a),ut,(re,ge)=>{var ye=Svt(),se=y(ye);U(()=>A(se,p(ge))),F(re,ye)});var Ot=_(et,2),xt=_(y(Ot),2);nt(xt,21,()=>o(t.universe?.universe_blocked_uses,i),ut,(re,ge)=>{var ye=Tvt(),se=y(ye);U(()=>A(se,p(ge))),F(re,ye)});var mt=_(Ot,2),wt=_(y(mt),2);nt(wt,21,()=>o(t.universe?.universe_required_evidence,n),ut,(re,ge)=>{var ye=Avt(),se=y(ye);U(()=>A(se,p(ge))),F(re,ye)});var ft=_(at,2),K=y(ft),Ct=_(y(K));nt(Ct,21,()=>t.universe?.universe_user_guidance??[],ut,(re,ge)=>{var ye=Cvt(),se=y(ye),St=y(se),Et=_(se),ue=y(Et),Se=_(Et),me=y(Se);U((Xe,br,kt)=>{A(St,Xe),A(ue,br),A(me,kt)},[()=>s(p(ge),"section"),()=>s(p(ge),"meaning"),()=>s(p(ge),"action")]),F(re,ye)});var Mt=_(ft,2),zt=y(Mt),Yt=_(y(zt),2);nt(Yt,21,()=>Object.entries(t.universe?.counts_by_exclusion_reason??{}),ut,(re,ge)=>{var ye=q(()=>Ki(p(ge),2));let se=()=>p(ye)[0],St=()=>p(ye)[1];var Et=kvt(),ue=y(Et),Se=y(ue),me=_(ue),Xe=y(me);U(br=>{A(Se,se()),A(Xe,br)},[()=>e(St())]),F(re,Et)});var we=_(zt,2),Kt=_(y(we),2),qe=y(Kt),fr=_(y(qe));nt(fr,21,()=>t.universe?.symbols??[],ut,(re,ge)=>{var ye=Dvt(),se=y(ye),St=y(se),Et=_(se),ue=y(Et),Se=_(Et),me=y(Se),Xe=_(Se),br=y(Xe),kt=_(Xe),_e=y(kt),ke=_(kt),Tr=y(ke);U(()=>{A(St,p(ge).code),A(ue,p(ge).name??"—"),A(me,p(ge).instrument_type),A(br,p(ge).include?"IN":"OUT"),A(_e,p(ge).review_status)}),cr("click",Tr,()=>t.onSymbolSelect?.(String(p(ge).code??""))),F(re,ye)}),U((re,ge,ye,se,St,Et)=>{A(c,t.universe?.verdict??"WATCH"),A(g,re),A(b,ge),A(C,ye),A(D,se),A(I,t.universe?.official_metadata_status??"MISSING"),A(N,St),A(B,Et),A(Y,t.universe?.official_metadata_coverage_status??"MISSING"),A(Q,t.universe?.universe_certification_status??"BLOCKED")},[()=>e(t.universe?.include_count),()=>e(t.universe?.exclude_count),()=>e(t.universe?.stockinfo_matched_table_count),()=>e(t.universe?.unmatched_quarantine_count),()=>e(t.universe?.official_metadata_unmatched_table_count),()=>e(t.universe?.quarantine_artifact_count)]),F(r,l),Yr()}an(["click"]);var Ivt=G('
          D2 데이터셋 artifact가 아직 없습니다. D3/D4/D5는 잠금 상태입니다.
          '),Rvt=G(' '),Pvt=G('
          '),Evt=G('
          '),Nvt=G("
        • "),Ovt=G('
          '),zvt=G(' '),Bvt=G(' '),Vvt=G('
          run
          scope
          features
          eligible
          leakage
          split
          price_basis
          universe
          upstream blockers
          D1 certification
          상위 차단:
          sectionmeaningaction
          Manifest provenance
          manifest_sha
          universe_manifest_sha
          cost_round_trip_bp
          purge_days
          embargo_days
          Purge/embargo & blocked intervals
          Leakage report detail
          status
          forbidden_feature_columns
          split_chronology_status
            Normalization stats detail
            fit_split
            fit_row_count
            blocked datecodepreviousreason
            datecodespliteligibleblock
            ',1),Fvt=G('
            D2 Dataset Builder

            일봉 데이터셋·누수 점검

            D2는 학습/강화학습 실행이 아니라, 코스피·코스닥 보통주 유니버스에서 누수 없는 feature/label/split 증거를 만드는 단계입니다. 수익·실거래·주문 준비 주장이 아닙니다. no training/order/live/profit.

            ');function Gvt(r,t){$r(t,!0);const e=b=>typeof b=="number"?b.toLocaleString("ko-KR"):b??"—";function a(b,w){return b?.[w]??"—"}function i(b,w){const S=b?.[w];return Array.isArray(S)?S.map(C=>String(C)):[]}const n=b=>b?.length?b:["—"],o=(b,w)=>typeof b[w]=="string"?b[w]:String(b[w]??"—");function s(b){const w=b?.features;return!w||typeof w!="object"||Array.isArray(w)?[]:Object.entries(w).slice(0,8)}function l(b){return!b||typeof b!="object"||Array.isArray(b)?[]:Object.entries(b)}function u(b){if(!b||typeof b!="object"||Array.isArray(b))return[];const w=b.intervals;return Array.isArray(w)?w:[]}function v(b){return b==="PASS"?"success":b==="WATCH"||b==="RUNNING"?"warn":b==="NO-GO"||b==="BLOCKED"?"danger":""}var c=Fvt(),d=y(c),f=_(y(d),2),h=_(y(f),1,!0),g=_(d,4);{var m=b=>{var w=Ivt();F(b,w)},x=b=>{var w=Vvt(),S=lr(w),C=y(S),T=_(y(C)),k=y(T),D=_(C,2),M=_(y(D)),L=y(M),I=_(D,2),P=_(y(I)),E=y(P),N=_(I,2),O=_(y(N)),V=y(O),B=_(S,2),z=y(B),H=_(y(z)),Y=y(H),$=_(z,2),Z=_(y($)),Q=y(Z),at=_($,2),et=_(y(at)),_t=y(et),Ot=_(at,2),xt=_(y(Ot)),mt=y(xt),wt=_(Ot,2),ft=_(y(wt)),K=y(ft),Ct=_(wt,2),Mt=_(y(Ct)),zt=y(Mt),Yt=_(B,2),we=y(Yt),Kt=_(Yt,2),qe=_(y(Kt)),fr=_(qe),re=y(fr),ge=_(Kt,2),ye=y(ge),se=_(y(ye));nt(se,21,()=>t.dataset.dataset_user_guidance??[],ut,(te,ne)=>{var Te=Rvt(),rr=y(Te),hr=y(rr),vr=_(rr),st=y(vr),At=_(vr),ee=y(At);U((nr,wr,Xr)=>{A(hr,nr),A(st,wr),A(ee,Xr)},[()=>o(p(ne),"section"),()=>o(p(ne),"meaning"),()=>o(p(ne),"action")]),F(te,Te)});var St=_(ge,2),Et=y(St),ue=_(y(Et),2),Se=_(y(ue)),me=y(Se),Xe=_(ue,2),br=_(y(Xe)),kt=y(br),_e=_(Xe,2),ke=_(y(_e)),Tr=y(ke),Oe=_(_e,2),Hr=_(y(Oe)),Zr=y(Hr),da=_(Oe,2),ua=_(y(da)),Zt=y(ua),j=_(Et,2),ot=_(y(j),2);nt(ot,17,()=>l(t.dataset.split_summary?.date_ranges),ut,(te,ne)=>{var Te=q(()=>Ki(p(ne),2));let rr=()=>p(Te)[0],hr=()=>p(Te)[1];var vr=Pvt(),st=y(vr),At=y(st),ee=_(st,2),nr=y(ee);U(wr=>{A(At,rr()),A(nr,wr)},[()=>u(hr()).map(wr=>`${wr.start??"—"}→${wr.end??"—"}`).join(", ")||`${hr().start??"—"}→${hr().end??"—"}`]),F(te,vr)});var Tt=_(St,2);nt(Tt,21,()=>t.chart?.split_series??[],ut,(te,ne)=>{var Te=Evt(),rr=y(Te),hr=y(rr),vr=_(rr,2),st=y(vr),At=_(vr,2),ee=y(At);U((nr,wr)=>{A(hr,p(ne).label),ha(st,nr),A(ee,wr)},[()=>`width:${Math.min(100,Number(p(ne).value||0)/Math.max(1,Number(t.dataset.row_counts?.split_assignment_rows||p(ne).value||1))*100)}%`,()=>e(p(ne).value)]),F(te,Te)});var ce=_(Tt,2),ae=y(ce),Qt=_(y(ae),2),ie=_(y(Qt)),$e=y(ie),ze=_(Qt,2),De=_(y(ze)),Bt=y(De),Jt=_(ze,2),fe=_(y(Jt)),Ge=y(fe),Ve=_(Jt,2);nt(Ve,21,()=>i(t.dataset.leakage_report,"checks"),ut,(te,ne)=>{var Te=Nvt(),rr=y(Te);U(()=>A(rr,p(ne))),F(te,Te)});var je=_(ae,2),tr=_(y(je),2),yr=_(y(tr)),Me=y(yr),mr=_(tr,2),kr=_(y(mr)),zr=y(kr),ea=_(mr,2);nt(ea,21,()=>s(t.dataset.normalization_stats),ut,(te,ne)=>{var Te=q(()=>Ki(p(ne),2));let rr=()=>p(Te)[0],hr=()=>p(Te)[1];var vr=Ovt(),st=y(vr),At=y(st),ee=_(st),nr=y(ee);U((wr,Xr)=>{A(At,rr()),A(nr,`${wr??""} · n=${Xr??""}`)},[()=>String(hr().status??"—"),()=>e(hr().count)]),F(te,vr)});var vt=_(ce,2),bt=y(vt),Gt=_(y(bt));nt(Gt,21,()=>t.dataset.samples?.blocked_windows??[],ut,(te,ne)=>{var Te=zvt(),rr=y(Te),hr=y(rr),vr=_(rr),st=y(vr),At=_(vr),ee=y(At),nr=_(At),wr=y(nr);U(()=>{A(hr,p(ne).date),A(st,p(ne).code),A(ee,p(ne).previous_date),A(wr,p(ne).reason)}),F(te,Te)});var he=_(vt,2),Ye=y(he),He=_(y(Ye));nt(He,21,()=>t.dataset.samples?.split_assignments??[],ut,(te,ne)=>{var Te=Bvt(),rr=y(Te),hr=y(rr),vr=_(rr),st=y(vr),At=_(vr),ee=y(At),nr=_(At),wr=y(nr),Xr=_(nr),jr=y(Xr);U(()=>{A(hr,p(ne).date),A(st,p(ne).code),A(ee,p(ne).split),A(wr,p(ne).eligible_for_training),A(jr,p(ne).block_reason??"—")}),F(te,Te)}),U((te,ne,Te,rr,hr,vr,st,At,ee,nr,wr,Xr)=>{A(k,t.dataset.run_id??"—"),A(L,t.dataset.artifact_scope??"—"),A(E,te),A(V,ne),A(Y,t.dataset.leakage_status??"—"),A(Q,t.dataset.split_chronology_status??"—"),A(_t,t.dataset.price_basis??"unknown"),A(mt,t.dataset.universe_verdict??"—"),A(K,t.dataset.upstream_gate_blockers?.length??0),A(zt,t.dataset.universe_certification_status??"BLOCKED"),A(we,`${t.dataset.model_readiness??"DATASET_RESEARCH_PREVIEW_BLOCKED_BY_MISSING_READINESS_EVIDENCE"??""} · D3 베이스라인과 D5 워크포워드 전에는 RL 수익 모델 생성/GO 요약을 열지 않습니다.`),A(qe,` ${Te??""} · `),A(re,`blocked uses: ${rr??""}`),A(me,t.dataset.manifest_sha??"—"),A(kt,t.dataset.universe_manifest_sha??"—"),A(Tr,hr),A(Zr,vr),A(Zt,st),A($e,At),A(Bt,ee),A(Ge,nr),A(Me,wr),A(zr,Xr)},[()=>e(t.dataset.row_counts?.feature_rows),()=>e(t.dataset.row_counts?.eligible_rows),()=>n(t.dataset.upstream_gate_blockers).join(", "),()=>n(t.dataset.dataset_blocked_uses).join(", "),()=>e(t.dataset.cost_assumption_round_trip_bp),()=>e(t.dataset.split_policy?.purge_days),()=>e(t.dataset.split_policy?.embargo_days),()=>a(t.dataset.leakage_report,"status"),()=>i(t.dataset.leakage_report,"forbidden_feature_columns").join(", ")||"[]",()=>a(t.dataset.leakage_report,"split_chronology_status"),()=>a(t.dataset.normalization_stats,"fit_split"),()=>e(a(t.dataset.normalization_stats,"fit_row_count"))]),F(b,w)};It(g,b=>{t.dataset?.status==="NOT_STARTED"||!t.dataset?b(m):b(x,-1)})}U(b=>{or(f,1,`pill ${b??""}`,"svelte-1qhn0lo"),A(h,t.dataset?.status??"NOT_STARTED")},[()=>v(t.dataset?.status)]),F(r,c),Yr()}var Hvt=G('
            '),Wvt=G('
            '),Uvt=G('
            '),qvt=G('
            '),$vt=G('
            '),Yvt=G('
            '),Zvt=G('
            '),Xvt=G('
            '),jvt=G('
            '),Kvt=G('
            '),Qvt=G('
            '),Jvt=G('
            '),tct=G('
            '),ect=G("
          • "),rct=G("
          • "),act=G('
            '),ict=G('
            '),nct=G('
            '),oct=G('
            '),sct=G('
            '),lct=G(' '),uct=G('
            D3-D5 Model Evidence

            예측·포트폴리오 RL·워크포워드 게이트

            이 영역은 결과를 좋게 포장하는 화면이 아니라 실패/잠금 근거와 RESEARCH_ONLY diagnostics를 표시하는 읽기 전용 패널입니다. RESEARCH_ONLY, WATCH, NO-GO, PRICE_BASIS_UNKNOWN, UNIVERSE_WATCH_HEURISTIC, no live/broker/orders.

            D3 status
            D4 badge
            D5 gate
            model_build_allowed
            D3 baseline/ranker
            run
            best_strategy
            price_basis
            go_summary_allowed
            cost
            control
            best_rule
            best_supervised
            supervised vs shuffle
            readiness
            freeze
            D3 blockers
            blocked uses
            D4 constrained portfolio RL
            run
            gate_dependency
            implementation_unlocked
            readiness/model
            prediction_manifest_sha
            D4 artifact hashes
            upstream hashes
            delta_vs_best_d3
            invalid_action_rate
            training_status
            telemetry_stack
            state_contract
            model/go/telemetry
            state_observationscash · exposure · top candidate · future label exposed
            leakage_checksrequired missing/duplicate/failing checks must fail closed
            learning_curveepisode · total_reward · rolling_mean
            reward_return_curvedate · gross return · reward · equity · missing labels
            action_distributionsplit · action · invalid · rate
            invalid_actionsdate · action · invalid · mask
            reward_componentsgross/cost/exposure/concentration/invalid/churn/drawdown/reward
            turnover_drawdowndate · turnover · drawdown
            frozen_baseline_deltapolicy NAV/MDD/turnover vs D3 baselines
            portfolio_trajectorydate · NAV · MDD · concentration · turnover
            D5 walk-forward gate
            run
            strategy
            n_folds
            purge/embargo
            no_oos_retuning
            policy
            readiness/model
            manifest sha
            D5 artifact hashes
            upstream hashes
              D5 consumes D4 state contract
              status
              gate
              validation
              state rows
              D4 ablations/source hashes
              telemetry sufficient
                Controls · fold consistency
                price/universe
                positive folds
                beats no-trade/shuffle
                worst DD / mean turnover
                0/23/46bp sensitivity · RL folds
                bp ladder
                Forward-only fold windows
                foldstrategycontrolnetvs no-tradevs shuffledDDturnover
                ');function vct(r,t){$r(t,!0);const e=Rt=>typeof Rt=="number"?Rt.toLocaleString("ko-KR",{maximumFractionDigits:4}):Rt??"—",a=Rt=>typeof Rt=="number"?`${(Rt*100).toLocaleString("ko-KR",{maximumFractionDigits:2})}%`:Rt??"—",i=(Rt,dt=8)=>(Rt??[]).slice(0,dt),n=["D0_PRICE_BASIS_NOT_VERIFIED","D1_UNIVERSE_NOT_OFFICIAL_OR_MANUAL_REVIEWED","D5_WALK_FORWARD_NOT_PASS","D3_BASELINE_WATCH_RESEARCH_ONLY"],o=["model_build_or_candidate_promotion","go_summary_or_profit_claim","paper_forward_or_live_readiness_claims"],s=Rt=>Rt?.length?Rt:["—"],l=(Rt,dt)=>typeof Rt[dt]=="string"?Rt[dt]:String(Rt[dt]??"—"),u=(...Rt)=>Array.from(new Set(Rt.flatMap(dt=>dt??[]))),v=Rt=>typeof Rt=="string"&&Rt.length?Rt:"—",c=Rt=>Rt===!0?"true":Rt===!1?"false":"MISSING",d=(Rt,dt)=>{const jt=c(Rt),oe=c(dt);return jt===oe?jt:`chart=${jt} latest=${oe} MISMATCH`},f=(Rt,dt)=>{const jt=typeof Rt=="string"&&Rt.length?Rt:"MISSING",oe=typeof dt=="string"&&dt.length?dt:"MISSING";return jt===oe?jt:jt==="MISSING"&&oe!=="MISSING"?oe:oe==="MISSING"&&jt!=="MISSING"?jt:jt==="NOT_STARTED"||oe==="NOT_STARTED"?"NOT_STARTED":`${jt}/${oe} MISMATCH`},h=Rt=>Array.isArray(Rt)?Rt.filter(dt=>typeof dt=="string"&&dt.length>0):[],g=Rt=>typeof Rt=="number"&&Number.isFinite(Rt)&&Rt>0,m=q(()=>t.predictionChart?.baseline_delta_summary??t.prediction?.baseline_delta_summary??{}),x=q(()=>t.predictionChart?.d3_gate_blockers??t.prediction?.d3_gate_blockers??[]),b=q(()=>t.predictionChart?.d3_blocked_uses??t.prediction?.d3_blocked_uses??[]),w=q(()=>u(n,p(x))),S=q(()=>p(b).length?p(b):o),C=q(()=>String(p(m).readiness_status??t.predictionChart?.readiness_status??t.prediction?.readiness_status??"D3_WATCH_RESEARCH_ONLY")),T=q(()=>t.predictionChart?.d3_user_guidance??t.prediction?.d3_user_guidance??[]),k=q(()=>t.predictionChart?.baseline_freeze_contract??t.prediction?.baseline_freeze_contract??{}),D=q(()=>t.portfolioChart?.telemetry??t.portfolio?.telemetry??{}),M=q(()=>t.portfolioChart?.artifact_hashes??t.portfolio?.artifact_hashes??{}),L=q(()=>t.portfolioChart?.prediction_artifact_hashes??t.portfolio?.prediction_artifact_hashes??{}),I=q(()=>t.portfolioChart?.reward_component_summary?.by_split??[]),P=q(()=>t.portfolio?.samples??{}),E=q(()=>t.portfolioChart?.observation_manifest??t.portfolio?.observation_manifest??{}),N=q(()=>t.portfolioChart?.observation_manifest_validation??t.portfolio?.observation_manifest_validation??{}),O=q(()=>p(E).observation_fields??[]),V=q(()=>p(E).leakage_checks??[]),B=q(()=>t.portfolioChart?.state_observations??p(P).state_observations??[]),z=q(()=>t.portfolioChart?.invalid_actions??p(P).invalid_actions??[]),H=q(()=>t.portfolioChart?.reward_sample??p(P).reward_breakdown??[]),Y=q(()=>t.portfolioChart?.portfolio_trajectory??t.portfolioChart?.policy_nav??p(P).policy_nav??[]),$=q(()=>t.portfolioChart?.reward_stack??p(I)),Z=q(()=>t.walkForwardChart?.d4_state_contract??t.walkForward?.d4_state_contract??{}),Q=q(()=>p(Z).row_counts??{}),at=q(()=>t.walkForwardChart?.fold_windows??[]),et=q(()=>t.walkForwardChart?.selected_fold_metrics??[]),_t=q(()=>t.walkForwardChart?.no_trade_control??[]),Ot=q(()=>t.walkForwardChart?.cost_sensitivity??[]),xt=q(()=>t.walkForwardChart?.rl_fold_metrics??[]),mt=q(()=>t.walkForwardChart?.fold_consistency??{}),wt=q(()=>u(h(t.walkForwardChart?.reasons),h(t.walkForward?.verdict?.reasons))),ft=q(()=>p(wt).length?p(wt):["D5_REASONS_MISSING_OR_STALE"]),K=q(()=>t.walkForwardChart?.d4_artifact_issues??[]),Ct=q(()=>String(t.walkForwardChart?.d4_state_contract_status??p(Z).status??"MISSING_D4_STATE_CONTRACT_STATUS")),Mt=q(()=>String(t.walkForwardChart?.d4_observation_manifest_gate??p(Z).gate??"MISSING_D4_STATE_CONTRACT_GATE")),zt=q(()=>String(t.walkForwardChart?.d4_observation_manifest_validation_status??p(Z).observation_manifest_validation_status??"MISSING_D4_OBSERVATION_VALIDATION_STATUS")),Yt=q(()=>t.walkForwardChart?.d4_state_observation_rows??p(Q).state_observations),we=q(()=>t.walkForwardChart?.d4_reward_action_ablation_rows??p(Q).reward_action_ablations),Kt=q(()=>t.walkForwardChart?.d4_source_hash_count??p(Q).source_hashes),qe=q(()=>t.walkForwardChart?.d4_state_contract_artifacts_consumed===!0&&p(Ct)==="PASS"&&p(Mt)==="D4_OBSERVATION_STATE_MANIFEST"&&p(zt)==="PASS"&&g(p(Yt))&&g(p(we))&&g(p(Kt))),fr=q(()=>p(qe)&&p(K).length===0?["D4_OBSERVATION_STATE_MANIFEST_CONSUMED"]:["D4_STATE_CONTRACT_EVIDENCE_MISSING_OR_STALE",...p(K).map(Rt=>String(Rt))]),re=q(()=>t.walkForwardChart?.artifact_hashes??t.walkForward?.artifact_hashes??{}),ge=q(()=>t.walkForwardChart?.prediction_artifact_hashes??t.walkForward?.prediction_artifact_hashes??{}),ye=q(()=>t.walkForwardChart?.portfolio_artifact_hashes??t.walkForward?.portfolio_artifact_hashes??{}),se=q(()=>d(t.portfolioChart?.model_build_allowed,t.portfolio?.model_build_allowed)),St=q(()=>d(t.portfolioChart?.go_summary_allowed,t.portfolio?.go_summary_allowed)),Et=q(()=>d(t.portfolioChart?.paper_forward_allowed,t.portfolio?.paper_forward_allowed)),ue=q(()=>d(t.portfolioChart?.live_broker_order_allowed,t.portfolio?.live_broker_order_allowed)),Se=q(()=>d(t.walkForwardChart?.model_build_allowed,t.walkForward?.model_build_allowed)),me=q(()=>d(t.walkForwardChart?.go_summary_allowed,t.walkForward?.go_summary_allowed)),Xe=q(()=>d(t.walkForwardChart?.paper_forward_allowed,t.walkForward?.paper_forward_allowed)),br=q(()=>d(t.walkForwardChart?.live_broker_order_allowed,t.walkForward?.live_broker_order_allowed)),kt=q(()=>d(t.walkForwardChart?.no_live_broker_order_readiness,t.walkForward?.no_live_broker_order_readiness)),_e=q(()=>f(t.walkForwardChart?.status,t.walkForward?.status));function ke(Rt,dt){return Rt?.[dt]??"—"}function Tr(Rt){return Rt==="PASS"?"success":Rt==="WATCH"||Rt==="RESEARCH_ONLY"?"warn":Rt==="NO-GO"||Rt==="BLOCKED"?"danger":""}var Oe=uct(),Hr=y(Oe),Zr=_(y(Hr),2),da=_(y(Zr),1,!0),ua=_(Hr,4),Zt=y(ua),j=_(y(Zt)),ot=y(j),Tt=_(Zt,2),ce=_(y(Tt)),ae=y(ce),Qt=_(Tt,2),ie=_(y(Qt)),$e=y(ie),ze=_(Qt,2),De=_(y(ze)),Bt=y(De),Jt=_(ua,2),fe=y(Jt),Ge=_(Jt,2),Ve=y(Ge),je=_(y(Ve),2),tr=_(y(je)),yr=y(tr),Me=_(je,2),mr=_(y(Me)),kr=y(mr),zr=_(Me,2),ea=_(y(zr)),vt=y(ea),bt=_(zr,2),Gt=_(y(bt)),he=y(Gt),Ye=_(bt,2),He=_(y(Ye)),te=y(He),ne=_(Ye,2),Te=_(y(ne)),rr=y(Te),hr=_(ne,2),vr=_(y(hr)),st=y(vr),At=_(hr,2),ee=_(y(At)),nr=y(ee),wr=_(At,2),Xr=_(y(wr)),jr=y(Xr),Aa=_(wr,2),ba=_(y(Aa)),_a=y(ba),Pa=_(Aa,2),Ga=_(y(Pa)),_i=y(Ga),vi=_(Pa,2),pr=y(vi),Na=_(y(pr)),qi=y(Na),Ut=_(pr,2),Ir=_(y(Ut)),Wr=y(Ir),va=_(vi,2);nt(va,21,()=>i(p(T),3),ut,(Rt,dt)=>{var jt=Hvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr)=>{A(Sr,Pr),A(Dr,Kr)},[()=>l(p(dt),"section"),()=>l(p(dt),"action")]),F(Rt,jt)});var ja=_(va,2);nt(ja,21,()=>i(t.predictionChart?.baseline_series,5),ut,(Rt,dt)=>{var jt=Wvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr,Sa)=>{A(Sr,`${p(dt).strategy??""} · ${p(dt).strategy_family??"—"??""}`),A(Dr,`${Pr??""} · vs shuffle ${Kr??""} · DD ${Sa??""}`)},[()=>a(p(dt).total_net_return),()=>a(p(dt).delta_vs_shuffle_control_total_net_return),()=>a(p(dt).max_drawdown)]),F(Rt,jt)});var Li=_(Ve,2),ci=_(y(Li),2),nn=_(y(ci)),_n=y(nn),to=_(ci,2),xn=_(y(to)),Mo=y(xn),eo=_(to,2),Lo=_(y(eo)),Io=y(Lo),bn=_(eo,2),Ro=_(y(bn)),hs=y(Ro),wn=_(bn,2),ro=y(wn),ao=_(y(ro)),ql=y(ao),ps=_(ro,2),Zs=_(y(ps)),$l=y(Zs),Yl=_(ps,2),Zl=_(y(Yl)),Xl=y(Zl),yt=_(wn,2),J=_(y(yt)),qt=y(J),Xt=_(yt,2),de=_(y(Xt)),Ht=y(de),Re=_(Xt,2),Fe=_(y(Re)),Le=y(Fe),Pe=_(Re,2),gr=_(y(Pe)),Br=y(gr),Ca=_(Pe,2),Oa=y(Ca),ka=_(y(Oa)),ni=y(ka),wa=_(Oa,2),Ra=_(y(wa)),$i=y(Ra),gs=_(wa,2);nt(gs,17,()=>i(p(O),4),ut,(Rt,dt)=>{var jt=Uvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U(()=>{A(Sr,`${p(dt).name??""} · ${p(dt).timing??"—"??""}`),A(Dr,p(dt).source??p(dt).leakage_status??"—")}),F(Rt,jt)});var Po=_(Ca,2),jl=_(y(Po),2);nt(jl,17,()=>i(p(B),4),ut,(Rt,dt)=>{var jt=qvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr,Sa)=>{A(Sr,`${p(dt).date??""} · pos ${p(dt).observation_position_count??""}`),A(Dr,`cash ${Pr??""} · exp ${Kr??""} · top ${p(dt).top_candidate_code??"—"??""} · future=${Sa??""}`)},[()=>e(p(dt).cash_fraction),()=>e(p(dt).exposure_fraction),()=>String(p(dt).future_label_exposed)]),F(Rt,jt)});var Xs=_(Po,2),Kl=_(y(Xs),2);nt(Kl,17,()=>i(p(V),5),ut,(Rt,dt)=>{var jt=$vt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr)=>{A(Sr,p(dt).check),A(Dr,`${Pr??""} · ${Kr??""}`)},[()=>String(p(dt).status),()=>String(p(dt).evidence??"—")]),F(Rt,jt)});var Nv=_(Xs,2),_d=_(y(Nv),2);nt(_d,17,()=>i(t.portfolioChart?.learning_curve,5),ut,(Rt,dt)=>{var jt=Yvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr,Sa)=>{A(Sr,`EP ${p(dt).episode??""}`),A(Dr,`${Pr??""} · roll ${Kr??""} · best ${Sa??""}`)},[()=>e(p(dt).total_reward),()=>e(p(dt).rolling_mean_reward),()=>e(p(dt).best_total_reward)]),F(Rt,jt)});var xd=_(Nv,2),ip=_(y(xd),2);nt(ip,17,()=>i(p(H),5),ut,(Rt,dt)=>{var jt=Zvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr,Sa,on)=>{A(Sr,`${p(dt).split??""} · ${p(dt).date??""} · ${p(dt).action??""}`),A(Dr,`gross ${Pr??""} · reward ${Kr??""} · equity ${Sa??""} · missing ${on??""}`)},[()=>a(p(dt).gross_return),()=>e(p(dt).reward),()=>e(p(dt).equity),()=>e(p(dt).missing_reward_label_count)]),F(Rt,jt)});var np=_(xd,2),W_=_(y(np),2);nt(W_,17,()=>i(t.portfolioChart?.action_distribution,5),ut,(Rt,dt)=>{var jt=Xvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr,Sa)=>{A(Sr,`${p(dt).split??""} · ${p(dt).action??""} · invalid=${Pr??""}`),A(Dr,`${Kr??""} · ${Sa??""}`)},[()=>String(p(dt).invalid_action),()=>e(p(dt).count),()=>a(p(dt).action_rate)]),F(Rt,jt)});var bd=_(np,2),op=_(y(bd),2);nt(op,17,()=>i(p(z),4),ut,(Rt,dt)=>{var jt=jvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U(Pr=>{A(Sr,`${p(dt).split??""} · ${p(dt).date??""}`),A(Dr,`${p(dt).action??""} · invalid=${Pr??""} · mask ${p(dt).action_mask_hold_buy_add_sell_reduce??"—"??""}`)},[()=>String(p(dt).invalid_action)]),F(Rt,jt)});var sp=_(bd,2),U_=_(y(sp),2);nt(U_,17,()=>i(p($),4),ut,(Rt,dt)=>{var jt=Kvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr,Sa,on,js,Ql,Ks,Qs)=>{A(Sr,p(dt).split),A(Dr,`${Pr??""} / cost ${Kr??""} / exp ${Sa??""} / conc ${on??""} / invalid ${js??""} / churn ${Ql??""} / DD ${Ks??""} / reward ${Qs??""}`)},[()=>e(p(dt).gross_return),()=>e(p(dt).cost),()=>e(p(dt).exposure_penalty),()=>e(p(dt).concentration_penalty),()=>e(p(dt).invalid_action_penalty),()=>e(p(dt).churn_penalty),()=>e(p(dt).drawdown_penalty),()=>e(p(dt).reward)]),F(Rt,jt)});var wd=_(sp,2),q_=_(y(wd),2);nt(q_,17,()=>i(t.portfolioChart?.turnover_series,3),ut,(Rt,dt,jt)=>{var oe=Qvt(),Sr=y(oe),Ar=y(Sr),Dr=_(Sr),Pr=y(Dr);U((Kr,Sa)=>{A(Ar,`${p(dt).split??""} · ${p(dt).date??""}`),A(Pr,`turn ${Kr??""} · DD ${Sa??""}`)},[()=>e(p(dt).turnover),()=>a((t.portfolioChart?.drawdown_series??[])[jt]?.current_drawdown)]),F(Rt,oe)});var lp=_(wd,2),up=_(y(lp),2);nt(up,17,()=>i(t.portfolioChart?.policy_baseline_comparison,6),ut,(Rt,dt)=>{var jt=Jvt(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr,Sa,on)=>{A(Sr,p(dt).baseline_strategy),A(Dr,`NAV ${Pr??""} vs ${Kr??""} · Δ ${Sa??""} · DD ${on??""}`)},[()=>e(p(dt).policy_nav),()=>e(p(dt).baseline_nav),()=>a(p(dt).baseline_delta_total_net_return),()=>a(p(dt).policy_max_drawdown)]),F(Rt,jt)});var $_=_(lp,2),Y_=_(y($_),2);nt(Y_,17,()=>i(p(Y),4),ut,(Rt,dt)=>{var jt=tct(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr,Sa,on)=>{A(Sr,p(dt).date),A(Dr,`NAV ${Pr??""} · MDD ${Kr??""} · conc ${Sa??""} · turn ${on??""}`)},[()=>e(p(dt).policy_nav),()=>a(p(dt).policy_current_drawdown),()=>e(p(dt).policy_concentration),()=>e(p(dt).policy_turnover)]),F(Rt,jt)});var vp=_(Li,2),cp=_(y(vp),2),Z_=_(y(cp)),dp=y(Z_),Sd=_(cp,2),X_=_(y(Sd)),j_=y(X_),fp=_(Sd,2),K_=_(y(fp)),hp=y(K_),pp=_(fp,2),gp=_(y(pp)),Q_=y(gp),yp=_(pp,2),J_=_(y(yp)),t0=y(J_),Td=_(yp,2),e0=_(y(Td)),mp=y(e0),_p=_(Td,2),r0=_(y(_p)),a0=y(r0),xp=_(_p,2),Ad=y(xp),bp=_(y(Ad)),i0=y(bp),wp=_(Ad,2),Sp=_(y(wp)),n0=y(Sp),Tp=_(wp,2),Ap=_(y(Tp)),o0=y(Ap),s0=_(xp,2);nt(s0,21,()=>p(ft).slice(0,8),ut,(Rt,dt)=>{var jt=ect(),oe=y(jt);U(()=>A(oe,p(dt))),F(Rt,jt)});var Cd=_(vp,2),Cp=_(y(Cd),2),l0=_(y(Cp)),kp=y(l0),Dp=_(Cp,2),u0=_(y(Dp)),v0=y(u0),Mp=_(Dp,2),c0=_(y(Mp)),d0=y(c0),kd=_(Mp,2),f0=_(y(kd)),h0=y(f0),Lp=_(kd,2),p0=_(y(Lp)),Ip=y(p0),Rp=_(Lp,2),Pp=_(y(Rp)),Ep=y(Pp),g0=_(Rp,2);nt(g0,21,()=>p(fr).slice(0,5),ut,(Rt,dt)=>{var jt=rct(),oe=y(jt);U(Sr=>A(oe,Sr),[()=>String(p(dt))]),F(Rt,jt)});var Np=_(Cd,2),Dd=_(y(Np),2),Op=_(y(Dd)),y0=y(Op),zp=_(Dd,2),m0=_(y(zp)),_0=y(m0),Bp=_(zp,2),Vp=_(y(Bp)),Fp=y(Vp),Md=_(Bp,2),Gp=_(y(Md)),x0=y(Gp),Hp=_(Md,2),Wp=y(Hp);nt(Wp,17,()=>i(p(et),3),ut,(Rt,dt)=>{var jt=act(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr)=>{A(Sr,`${p(dt).fold_id??""} selected`),A(Dr,`${Pr??""} · vs shuffle ${Kr??""}`)},[()=>a(p(dt).total_net_return),()=>a(p(dt).delta_vs_shuffled_total_net_return)]),F(Rt,jt)});var Up=_(Wp,2);nt(Up,17,()=>i(p(_t),3),ut,(Rt,dt)=>{var jt=ict(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr)=>{A(Sr,`${p(dt).fold_id??""} no-trade`),A(Dr,`${Pr??""} · DD ${Kr??""}`)},[()=>a(p(dt).total_net_return),()=>a(p(dt).max_drawdown)]),F(Rt,jt)});var qp=_(Np,2),Ld=_(y(qp),2),b0=_(y(Ld)),w0=y(b0),S0=_(Ld,2),$p=y(S0);nt($p,17,()=>i(p(Ot),6),ut,(Rt,dt)=>{var jt=nct(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr,Sa)=>{A(Sr,`${p(dt).fold_id??""} · ${Pr??""}bp`),A(Dr,`${Kr??""} · DD ${Sa??""}`)},[()=>e(p(dt).cost_bp),()=>a(p(dt).total_net_return),()=>a(p(dt).max_drawdown)]),F(Rt,jt)});var T0=_($p,2);nt(T0,17,()=>i(p(xt),3),ut,(Rt,dt)=>{var jt=oct(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U((Pr,Kr)=>{A(Sr,`${p(dt).fold_id??""} RL`),A(Dr,`${Pr??""} · invalid ${Kr??""}`)},[()=>a(p(dt).total_net_return),()=>a(p(dt).invalid_action_rate)]),F(Rt,jt)});var A0=_(qp,2),Yp=_(y(A0),2);nt(Yp,21,()=>i(p(at),5),ut,(Rt,dt)=>{var jt=sct(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar);U(Pr=>{A(Sr,`${p(dt).fold_id??""} test ${p(dt).test_start_date??""}→${p(dt).test_end_date??""}`),A(Dr,`purge ${(p(dt).purge_start_date||"—")??""}→${(p(dt).purge_end_date||"—")??""} · embargo ${(p(dt).embargo_start_date||"—")??""}→${(p(dt).embargo_end_date||"—")??""} · retune=${Pr??""}`)},[()=>String(p(dt).retuned_on_oos)]),F(Rt,jt)});var Zp=_(Ge,2),C0=y(Zp),k0=_(y(C0));nt(k0,21,()=>i(t.walkForwardChart?.fold_metrics,12),ut,(Rt,dt)=>{var jt=lct(),oe=y(jt),Sr=y(oe),Ar=_(oe),Dr=y(Ar),Pr=_(Ar),Kr=y(Pr),Sa=_(Pr),on=y(Sa),js=_(Sa),Ql=y(js),Ks=_(js),Qs=y(Ks),Ov=_(Ks),Id=y(Ov),zv=_(Ov),Bv=y(zv);U((Rd,Pd,Vv,Ed,Nd)=>{A(Sr,p(dt).fold_id),A(Dr,p(dt).strategy),A(Kr,p(dt).control),A(on,Rd),A(Ql,Pd),A(Qs,Vv),A(Id,Ed),A(Bv,Nd)},[()=>a(p(dt).total_net_return),()=>a(p(dt).delta_vs_no_trade_total_net_return),()=>a(p(dt).delta_vs_shuffled_total_net_return),()=>a(p(dt).max_drawdown),()=>e(p(dt).mean_turnover)]),F(Rt,jt)}),U((Rt,dt,jt,oe,Sr,Ar,Dr,Pr,Kr,Sa,on,js,Ql,Ks,Qs,Ov,Id,zv,Bv,Rd,Pd,Vv,Ed,Nd,D0,Xp,jp,M0,L0,Kp,I0,R0,Qp,Jp,P0,E0,tg,eg,N0,O0,z0,B0,rg,ag,V0,F0,G0,ig,ng,H0,og,W0,sg,lg,U0,q0,ug,$0,Y0,vg,Z0,X0,cg,j0)=>{or(Zr,1,`pill ${Rt??""}`,"svelte-1pobwna"),A(da,p(_e)),A(ot,t.prediction?.status??"NOT_STARTED"),A(ae,dt),A($e,p(_e)),A(Bt,p(Se)),A(fe,`결정 패널: model_build_allowed=${p(Se)??""} · go_summary_allowed=${p(me)??""} · paper_forward_allowed=${p(Xe)??""} · live_broker_order_allowed=${p(br)??""} · 현재 결론은 수익 모델 생성 GO가 아니라 NO-GO/RESEARCH_ONLY 증거입니다.`),A(yr,t.prediction?.run_id??"—"),A(kr,jt),A(vt,t.prediction?.price_basis??"unknown"),A(he,oe),A(te,`${Sr??""}bp round trip`),A(rr,Ar),A(st,Dr),A(nr,Pr),A(jr,Kr),A(_a,p(C)),A(_i,`${Sa??""} · fit=${on??""} · eval=${js??""}`),A(qi,Ql),A(Wr,Ks),A(_n,t.portfolio?.run_id??"—"),A(Mo,Qs),A(Io,Ov),A(hs,`${Id??""} · model=${p(se)??""} · go=${p(St)??""} · paper=${p(Et)??""} · live=${p(ue)??""}`),A(ql,zv),A($l,`policy_metrics=${Bv??""} · policy_nav=${Rd??""} · policy_baseline=${Pd??""}`),A(Xl,`predictions=${Vv??""} · baseline=${Ed??""} · verdict=${Nd??""}`),A(qt,D0),A(Ht,Xp),A(Le,jp),A(Br,M0),A(ni,`${L0??""} · validation=${Kp??""}`),A($i,`model=${I0??""} · go=${R0??""} · telemetry_sufficient=${Qp??""}`),A(dp,t.walkForward?.run_id??"—"),A(j_,Jp),A(hp,`${P0??""} / ${E0??""}`),A(Q_,`${tg??""}d / ${eg??""}d · min ${N0??""}d / ${O0??""}d`),A(t0,z0),A(mp,B0),A(a0,`${rg??""} · model=${p(Se)??""} · go=${p(me)??""} · paper=${p(Xe)??""} · live=${p(br)??""} · no_live_ready=${p(kt)??""}`),A(i0,`prediction=${ag??""} · portfolio=${V0??""}`),A(n0,`fold_metrics=${F0??""} · cost=${G0??""} · rl=${ig??""} · gate=${ng??""}`),A(o0,`predictions=${H0??""} · D4=${og??""} · state=${W0??""}`),A(kp,p(Ct)),A(v0,p(Mt)),A(d0,p(zt)),A(h0,sg),A(Ip,`${lg??""} / ${U0??""}`),A(Ep,q0),A(y0,`${ug??""} · ${$0??""}`),A(_0,Y0),A(Fp,`${vg??""} / ${Z0??""}`),A(x0,`${X0??""} / ${cg??""}`),A(w0,j0)},[()=>Tr(p(_e)),()=>String(ke(t.portfolio?.verdict,"ui_badge")),()=>String(ke(t.prediction?.verdict,"best_strategy_by_total_net_return")),()=>String(ke(t.prediction?.verdict,"go_summary_allowed")),()=>e(ke(p(m),"cost_round_trip_bp")),()=>String(ke(p(m),"shuffle_control_strategy")),()=>String(ke(p(m),"best_rule_baseline_strategy")),()=>String(ke(p(m),"best_supervised_strategy")),()=>a(ke(p(m),"best_supervised_delta_vs_shuffle_control")),()=>String(p(k).deterministic_shuffle_method??"sha256(date:code)_ascending"),()=>String(p(k).fit_split??"train"),()=>String(p(k).evaluation_splits?.join("+")??"val+test"),()=>s(p(w)).join(", "),()=>s(p(S)).join(", "),()=>String(ke(t.portfolio?.verdict,"gate_dependency")),()=>String(ke(t.portfolio?.verdict,"implementation_unlocked")),()=>String(t.portfolioChart?.readiness_status??t.portfolio?.verdict?.readiness_status??t.portfolio?.readiness_status??"D4_RESEARCH_ONLY_DIAGNOSTICS"),()=>v(t.portfolioChart?.prediction_manifest_sha??t.portfolio?.prediction_manifest_sha),()=>v(p(M).policy_metrics),()=>v(p(M).policy_nav),()=>v(p(M).policy_baseline_comparison),()=>v(p(L).predictions),()=>v(p(L).baseline_metrics),()=>v(p(L).verdict),()=>a(t.portfolio?.baseline_comparison?.delta_vs_best_d3_total_net_return),()=>a(ke(t.portfolio?.verdict,"invalid_action_rate")),()=>String(t.portfolioChart?.training_status??p(D).training_status??"—"),()=>String(p(D).visualization_stack?.join(" · ")??"csv/dashboard"),()=>String(p(E).gate??"MISSING_D4_OBSERVATION_STATE_MANIFEST_GATE"),()=>String(p(N).status??"MISSING_D4_OBSERVATION_VALIDATION_STATUS"),()=>String(p(E).model_build_allowed),()=>String(p(E).go_summary_allowed),()=>String(p(E).reward_action_telemetry_sufficient_for_d4),()=>String(t.walkForwardChart?.selected_strategy??t.walkForward?.selected_strategy??ke(t.walkForward?.verdict,"selected_strategy")),()=>e(t.walkForwardChart?.n_folds),()=>e(t.walkForwardChart?.required_min_folds),()=>e(t.walkForwardChart?.purge_days),()=>e(t.walkForwardChart?.embargo_days),()=>e(t.walkForwardChart?.min_required_purge_days),()=>e(t.walkForwardChart?.min_required_embargo_days),()=>String(t.walkForwardChart?.no_oos_retuning),()=>String(t.walkForwardChart?.strategy_selection_policy??t.walkForward?.strategy_selection_policy??ke(t.walkForward?.verdict,"strategy_selection_policy")),()=>String(t.walkForwardChart?.readiness_status??t.walkForward?.readiness_status??"D5_NO_GO_RESEARCH_ONLY_GATE"),()=>v(t.walkForwardChart?.prediction_manifest_sha??t.walkForward?.prediction_manifest_sha),()=>v(t.walkForwardChart?.portfolio_manifest_sha??t.walkForward?.portfolio_manifest_sha),()=>v(p(re).fold_metrics),()=>v(p(re).cost_sensitivity),()=>v(p(re).rl_fold_metrics),()=>v(p(re).gate_verdict),()=>v(p(ge).predictions),()=>v(p(ye).rl_manifest),()=>v(p(ye).state_observations),()=>e(p(Yt)),()=>e(p(we)),()=>e(p(Kt)),()=>String(t.walkForwardChart?.d4_reward_action_telemetry_sufficient_for_d4??p(Z).reward_action_telemetry_sufficient_for_d4),()=>String(t.walkForwardChart?.price_basis),()=>String(t.walkForwardChart?.universe_review_status),()=>e(ke(p(mt),"positive_folds")),()=>e(ke(p(mt),"folds_beating_no_trade")),()=>e(ke(p(mt),"folds_beating_shuffle")),()=>a(ke(p(mt),"worst_fold_max_drawdown")),()=>e(ke(p(mt),"mean_fold_turnover")),()=>(t.walkForwardChart?.cost_sensitivity_bp??[]).join(" / ")]),F(r,Oe),Yr()}var cct=G('
                '),dct=G('
                '),fct=G('
                '),hct=G(''),pct=G('
                ',1),gct=G('
                '),yct=G('
                '),mct=G('
                '),_ct=G(''),xct=G(''),bct=G('
                '),wct=G('
                '),Sct=G(" "),Tct=G(''),Act=G(""),Cct=G('
                '),kct=G('
                '),Dct=G('paper_selected '),Mct=G('realized_returns '),Lct=G('drawdown '),Ict=G('decision_log '),Rct=G('
                '),Pct=G('
                '),Ect=G(''),Nct=G(''),Oct=G('
                금지
                다음
                ',1),zct=G('
                '),Bct=G('
                ',1),Vct=G('
                종목을 선택하면 OHLCV 막대 미리보기가 표시됩니다.
                '),Fct=G('
                STOM-inspired Visual Lab

                검토·게이트·성과 착시 방지 시각화

                STOM 대시보드의 Research Criteria, Metric Glossary, Active Strategy, Equity Overlay, Heatmap, Run Compare 아이디어를 Kronos 일봉 연구 증거에 맞게 읽기 전용으로 반영했습니다. 수익 보장·실거래·브로커·주문 준비 상태가 아닙니다. D6는 증거를 읽는 화면이고 D7은 실패 원인과 다음 가설을 좁히는 연구 노트입니다.

                D6/D7 사용 안내 · 할 수 있는 것 / 금지 / 다음 증거
                Decision Cockpit
                Evidence Flow
                Metric Glossary
                D7 Research Diagnostics

                Equity Overlay
                Walk-forward Heatmap
                Run Compare Scatter
                MDD worse ← return ↑
                D8/D9 Registry · Paper-forward
                model_build_allowed strict D5 gate controls promotion
                paper_forward_allowed paper-only planning, never orders
                live_broker_order_allowed no live/broker/orders
                no_live_broker_order_readiness true means explicitly not broker/order ready; missing/false is unsafe
                run
                config_hash
                data_hash
                code_hash
                effective_gate_blockers
                invariant_errors
                read_only_note
                paper_selected_hidden
                realized_returns_hidden
                drawdown_hidden
                drift_hidden
                decision_log_hidden
                surfacestatus/datevaluereason/source
                drawdown source: research_policy_nav_not_live_account · returns source: policy_nav_research_artifact_not_live_trade
                Universe Breakdown
                Symbol OHLCV Preview
                ');function Gct(r,t){$r(t,!0);const e=yt=>{if(yt==null||yt==="")return null;const J=typeof yt=="number"?yt:Number(yt);return Number.isFinite(J)?J:null},a=yt=>{const J=e(yt);return J===null?"—":`${(J*100).toLocaleString("ko-KR",{maximumFractionDigits:2})}%`},i=yt=>Array.isArray(yt)?yt.join(";"):yt&&typeof yt=="object"?JSON.stringify(yt):String(yt??"—"),n=yt=>Array.isArray(yt)?yt.filter(J=>!!J&&typeof J=="object"):[],o=[{id:"D7_FEATURE_DIAGNOSTICS",label:"Feature diagnostics",status:"PLACEHOLDER_READY",summary:"feature/regime/correlation/failure diagnostics require explicit read-only artifacts before claims.",next_artifact:"feature_importance_by_fold.csv",guardrail:"feature importance is explanatory only; no profit/live/broker/order claim.",allowed_use:"feature별 fold 기여도와 drift를 비교해 D3/D4 실패 원인을 설명합니다.",blocked_use:"feature 중요도를 종목 선택, 수익 주장, live signal로 사용하지 않습니다.",how_to_read_ko:"fold마다 같은 feature가 반복되는지와 price_basis unknown 민감도를 먼저 봅니다.",current_gap:"feature_importance_by_fold.csv가 생성되기 전까지 PLACEHOLDER_READY입니다."},{id:"D7_REGIME_DIAGNOSTICS",label:"Regime diagnostics",status:"PLACEHOLDER_READY",summary:"regime buckets must be forward-only and cannot retune OOS folds.",next_artifact:"regime_bucket_metrics.csv",guardrail:"regime labels are research diagnostics only.",allowed_use:"추세·변동성·유동성 regime별 baseline/RL 실패 구간을 찾습니다.",blocked_use:"OOS fold를 보고 regime 정의를 재튜닝하지 않습니다.",how_to_read_ko:"forward-only regime 규칙으로 한 구간 좋은 결과를 전체 성과처럼 말하지 않습니다.",current_gap:"regime_bucket_metrics.csv가 생성되기 전까지 PLACEHOLDER_READY입니다."},{id:"D7_CORRELATION_RISK",label:"Correlation and concentration",status:"PLACEHOLDER_READY",summary:"correlation/concentration diagnostics prevent single-theme exposure from being hidden.",next_artifact:"correlation_cluster_summary.csv",guardrail:"correlation views are risk diagnostics, not selection proof.",allowed_use:"상관·집중도·테마 쏠림을 보며 포트폴리오 리스크를 설명합니다.",blocked_use:"상관 클러스터를 종목 추천이나 배포 가능한 allocation으로 사용하지 않습니다.",how_to_read_ko:"손실 fold와 고상관 cluster가 겹치는지 확인하고 penalty 가설로만 사용합니다.",current_gap:"correlation_cluster_summary.csv가 생성되기 전까지 PLACEHOLDER_READY입니다."},{id:"D7_FAILURE_ANALYSIS",label:"Failure analysis",status:"PLACEHOLDER_READY",summary:"NO-GO reasons, invalid actions, drawdown spikes, and fold failures stay visible.",next_artifact:"failure_reason_attribution.csv",guardrail:"failure visibility is mandatory; weak or flat RL outcomes must not be hidden.",allowed_use:"NO-GO reason, invalid action, drawdown spike, fold failure를 다음 실험 가설로 묶습니다.",blocked_use:"실패 fold를 숨기거나 성공 fold만 골라 GO처럼 표현하지 않습니다.",how_to_read_ko:"D0/D1/D3/D5 blocker를 먼저 확인하고 reward/action 변경은 사전등록합니다.",current_gap:"failure_reason_attribution.csv가 생성되기 전까지 PLACEHOLDER_READY입니다."}],s=(yt,J)=>J?{...yt,...J,allowed_use:J.allowed_use||yt.allowed_use,blocked_use:J.blocked_use||yt.blocked_use,how_to_read_ko:J.how_to_read_ko||yt.how_to_read_ko,current_gap:J.current_gap||yt.current_gap,next_artifact:J.next_artifact||yt.next_artifact,guardrail:J.guardrail||yt.guardrail,summary:J.summary||yt.summary}:yt,l=()=>{const yt=n(t.researchDiagnostics?.cards),J=new Map(yt.map(Xt=>[String(Xt.id),Xt])),qt=new Set(o.map(Xt=>String(Xt.id)));return[...o.map(Xt=>s(Xt,J.get(String(Xt.id)))),...yt.filter(Xt=>!qt.has(String(Xt.id)))]},u=yt=>{if(!yt||typeof yt!="object")return!1;const J=String(yt.stage??"");return J==="D6"||J==="D7"},v=()=>{const yt=n(t.decision?.usage_guide);return yt.length>0?yt:n(t.flow?.nodes).map(J=>J.usage_guide).filter(u)},c=()=>n(t.symbolChart?.usage_guide),d=(yt,J=12)=>(yt??[]).slice(0,J),f=(yt,J,qt="none")=>Array.isArray(yt)?yt.length?yt.join(" · "):qt:J,h=yt=>typeof yt=="boolean"?String(yt):"MISSING_REGISTRY_FLAG_UNSAFE",g=yt=>/BLOCK|NO-GO|UNSAFE|INVALID|MISSING/i.test(JSON.stringify(yt))?1:0,m=(yt,J=12)=>!Array.isArray(yt)||yt.length===0?[{evidence_status:"MISSING_SAMPLE_EVIDENCE",reason:"registry sample field missing or empty"}]:[...n(yt)].sort((qt,Xt)=>g(Xt)-g(qt)).slice(0,J),x=(yt,J=12)=>Array.isArray(yt)&&yt.length>0?String(Math.max(0,n(yt).length-J)):"MISSING_SAMPLE_EVIDENCE",b=(yt,J=8)=>Object.entries(yt??{}).sort((qt,Xt)=>Xt[1]-qt[1]).slice(0,J),w=yt=>Math.max(1,...Object.values(yt??{}).map(J=>Math.abs(J)));function S(yt){const J=String(yt??"");return J==="PASS"?"success":J==="WATCH"||J==="RESEARCH_ONLY"||J==="REFERENCE_ONLY"?"warn":J==="NO-GO"||J==="BLOCKED"||J==="LOCKED"?"danger":""}function C(yt){const J=String(yt??"neutral");return J==="pass"?"pass":J==="watch"?"watch":J==="block"?"block":"neutral"}function T(yt){const J=e(yt);return J===null?null:Math.max(8,Math.min(96,12+J*70))}function k(yt){const J=e(yt);return J===null?null:Math.max(4,Math.min(96,96+J*260))}function D(yt){const J=e(yt);return J===null?null:Math.max(4,Math.min(96,78-J*90))}function M(yt,J=3){const qt=e(yt);return qt===null?"—":qt.toLocaleString("ko-KR",{maximumFractionDigits:J})}var L=Fct(),I=y(L),P=_(y(I),2),E=_(y(P),1,!0),N=_(I,4),O=_(y(N),2);nt(O,21,()=>d(v(),6),ut,(yt,J)=>{var qt=cct(),Xt=y(qt),de=y(Xt),Ht=_(Xt,2),Re=y(Ht),Fe=_(Ht,2),Le=y(Fe),Pe=_(Fe,2),gr=y(Pe);U(()=>{A(de,`${p(J).stage??""} · ${p(J).page??""}`),A(Re,`가능: ${p(J).can_do??""}`),A(Le,`금지: ${p(J).must_not??""}`),A(gr,`다음: ${p(J).next_action??""}`)}),F(yt,qt)});var V=_(N,2),B=y(V),z=y(B),H=_(y(z)),Y=y(H),$=_(z,2);nt($,21,()=>d(t.decision?.cards,4),ut,(yt,J)=>{var qt=dct(),Xt=y(qt),de=y(Xt),Ht=_(Xt,2),Re=y(Ht),Fe=_(Ht,2),Le=y(Fe);U(Pe=>{or(qt,1,`decision-card ${Pe??""}`,"svelte-1dxayfu"),A(de,p(J).id),A(Re,p(J).status),A(Le,p(J).label)},[()=>C(p(J).severity)]),F(yt,qt)});var Z=_($,2);nt(Z,21,()=>n(t.decision?.blockers),ut,(yt,J)=>{var qt=fct(),Xt=y(qt),de=y(Xt),Ht=_(Xt,2),Re=y(Ht),Fe=_(Ht,2),Le=y(Fe);U(Pe=>{or(qt,1,`blocker ${Pe??""}`,"svelte-1dxayfu"),A(de,p(J).id),A(Re,p(J).title),A(Le,p(J).required_fix)},[()=>C(p(J).severity)]),F(yt,qt)});var Q=_(B,2),at=y(Q),et=_(y(at)),_t=y(et),Ot=_(at,2);nt(Ot,21,()=>d(t.flow?.nodes,10),ut,(yt,J,qt)=>{var Xt=pct(),de=lr(Xt),Ht=y(de),Re=y(Ht),Fe=_(Ht,2),Le=y(Fe),Pe=_(Fe,2),gr=y(Pe),Br=_(de,2);{var Ca=ka=>{var ni=hct();F(ka,ni)},Oa=q(()=>qt{p(Oa)&&ka(Ca)})}U(ka=>{or(de,1,`flow-node ${ka??""}`,"svelte-1dxayfu"),A(Re,p(J).id),A(Le,p(J).label),A(gr,p(J).status)},[()=>C(p(J).severity)]),F(yt,Xt)});var xt=_(Q,2),mt=y(xt),wt=_(y(mt)),ft=y(wt),K=_(mt,2);nt(K,21,()=>d(t.glossary?.items,12),ut,(yt,J)=>{var qt=gct(),Xt=y(qt),de=y(Xt),Ht=_(Xt,2),Re=y(Ht),Fe=_(Ht,2),Le=y(Fe);U(()=>{A(de,p(J).term),A(Re,p(J).meaning),A(Le,p(J).guardrail)}),F(yt,qt)});var Ct=_(xt,2),Mt=y(Ct),zt=_(y(Mt)),Yt=y(zt),we=_(Mt,2),Kt=y(we),qe=_(we,2);nt(qe,21,()=>d(l(),8),ut,(yt,J)=>{var qt=yct(),Xt=y(qt),de=y(Xt),Ht=_(Xt,2),Re=y(Ht),Fe=_(Ht,2),Le=y(Fe),Pe=_(Fe,2),gr=y(Pe),Br=_(Pe,2),Ca=y(Br),Oa=_(Br,2),ka=y(Oa),ni=_(Oa,2),wa=y(ni),Ra=_(ni,2),$i=y(Ra),gs=_(Ra,2),Po=y(gs);U(()=>{A(de,p(J).id),A(Re,`${p(J).label??""} · ${p(J).status??""}`),A(Le,p(J).summary),A(gr,`next: ${p(J).next_artifact??""}`),A(Ca,p(J).guardrail),A(ka,`allowed: ${p(J).allowed_use??""}`),A(wa,`blocked: ${p(J).blocked_use??""}`),A($i,p(J).how_to_read_ko),A(Po,`gap: ${p(J).current_gap??""}`)}),F(yt,qt)});var fr=_(qe,2);nt(fr,21,()=>Object.entries(t.researchDiagnostics?.summary??{}).slice(0,5),ut,(yt,J)=>{var qt=q(()=>Ki(p(J),2));let Xt=()=>p(qt)[0],de=()=>p(qt)[1];var Ht=mct(),Re=y(Ht),Fe=y(Re),Le=_(Re),Pe=y(Le);U(gr=>{A(Fe,Xt()),A(Pe,gr)},[()=>i(de())]),F(yt,Ht)});var re=_(fr,2),ge=y(re),ye=_(Ct,2),se=y(ye),St=_(y(se)),Et=y(St),ue=_(se,2);nt(ue,21,()=>d(t.equityOverlay?.curves,6),ut,(yt,J)=>{var qt=bct(),Xt=y(qt),de=y(Xt),Ht=y(de),Re=_(de),Fe=y(Re),Le=_(Xt,2);nt(Le,21,()=>n(p(J).points).slice(0,42),ut,(Pe,gr)=>{const Br=q(()=>T(p(gr).y));var Ca=jo(),Oa=lr(Ca);{var ka=wa=>{var Ra=_ct();F(wa,Ra)},ni=wa=>{var Ra=xct();U(()=>ha(Ra,`height:${p(Br)}%`)),F(wa,Ra)};It(Oa,wa=>{p(Br)===null?wa(ka):wa(ni,-1)})}F(Pe,Ca)}),U(()=>{A(Ht,p(J).label),A(Fe,`${p(J).kind??""} · ${p(J).status??""}`),xe(Le,"aria-label",`${p(J).label??""} equity preview`)}),F(yt,qt)});var Se=_(ue,2),me=y(Se),Xe=_(ye,2),br=y(Xe),kt=_(y(br)),_e=y(kt),ke=_(br,2);nt(ke,21,()=>d(t.heatmap?.cells,48),ut,(yt,J)=>{var qt=wct(),Xt=y(qt),de=y(Xt),Ht=_(Xt,2),Re=y(Ht),Fe=_(Ht,2),Le=y(Fe);U((Pe,gr)=>{or(qt,1,`heat-cell ${Pe??""}`,"svelte-1dxayfu"),A(de,p(J).fold_id),A(Re,p(J).metric),A(Le,gr)},[()=>C(p(J).severity),()=>p(J).metric==="mean_turnover"?M(p(J).value,3):a(p(J).value)]),F(yt,qt)});var Tr=_(ke,2);nt(Tr,21,()=>d(t.heatmap?.cost_series,12),ut,(yt,J)=>{var qt=Sct(),Xt=y(qt);U((de,Ht)=>A(Xt,`${p(J).fold_id??""}:${de??""}bp ${Ht??""}`),[()=>M(p(J).cost_bp,0),()=>a(p(J).total_net_return)]),F(yt,qt)});var Oe=_(Xe,2),Hr=y(Oe),Zr=_(y(Hr)),da=y(Zr),ua=_(Hr,2),Zt=_(y(ua),4);nt(Zt,17,()=>d(t.runScatter?.points,18),ut,(yt,J)=>{const qt=q(()=>k(p(J).x_max_drawdown)),Xt=q(()=>D(p(J).y_total_net_return));var de=jo(),Ht=lr(de);{var Re=Le=>{var Pe=Tct();U(()=>xe(Pe,"title",`${p(J).label}: INCOMPLETE_NUMERIC_EVIDENCE`)),F(Le,Pe)},Fe=Le=>{var Pe=Act();U((gr,Br)=>{or(Pe,1,`scatter-point ${gr??""}`,"svelte-1dxayfu"),ha(Pe,`left:${p(qt)}%; top:${p(Xt)}%`),xe(Pe,"title",Br)},[()=>S(p(J).status),()=>`${p(J).label}: ${a(p(J).y_total_net_return)} / DD ${a(p(J).x_max_drawdown)}`]),F(Le,Pe)};It(Ht,Le=>{p(qt)===null||p(Xt)===null?Le(Re):Le(Fe,-1)})}F(yt,de)});var j=_(ua,2);nt(j,21,()=>d(t.runScatter?.points,6),ut,(yt,J)=>{var qt=Cct(),Xt=y(qt),de=y(Xt),Ht=_(Xt),Re=y(Ht);U(Fe=>{A(de,p(J).kind),A(Re,`${p(J).label??""} · ${Fe??""}`)},[()=>a(p(J).y_total_net_return)]),F(yt,qt)});var ot=_(Oe,2),Tt=y(ot),ce=_(y(Tt)),ae=y(ce),Qt=_(Tt,2),ie=y(Qt),$e=_(y(ie),2),ze=y($e),De=_(ie,2),Bt=_(y(De),2),Jt=y(Bt),fe=_(De,2),Ge=_(y(fe),2),Ve=y(Ge),je=_(fe,2),tr=_(y(je),2),yr=y(tr),Me=_(Qt,2),mr=y(Me),kr=_(y(mr)),zr=y(kr),ea=_(mr,2),vt=_(y(ea)),bt=y(vt),Gt=_(ea,2),he=_(y(Gt)),Ye=y(he),He=_(Gt,2),te=_(y(He)),ne=y(te),Te=_(Me,2),rr=y(Te),hr=_(y(rr)),vr=y(hr),st=_(rr,2),At=_(y(st)),ee=y(At),nr=_(st,2),wr=_(y(nr)),Xr=y(wr),jr=_(Te,2),Aa=y(jr),ba=_(y(Aa)),_a=y(ba),Pa=_(Aa,2),Ga=_(y(Pa)),_i=y(Ga),vi=_(Pa,2),pr=_(y(vi)),Na=y(pr),qi=_(vi,2),Ut=_(y(qi)),Ir=y(Ut),Wr=_(qi,2),va=_(y(Wr)),ja=y(va),Li=_(jr,2);nt(Li,21,()=>m(t.registry?.samples?.drift,6),ut,(yt,J)=>{var qt=kct(),Xt=y(qt),de=y(Xt),Ht=_(Xt,2),Re=y(Ht),Fe=_(Ht,2),Le=y(Fe);U(()=>{A(de,p(J).metric??p(J).evidence_status),A(Re,`${p(J).value??p(J).reason??"MISSING_SAMPLE_EVIDENCE"??""} · ${p(J).status??p(J).evidence_status??""}`),A(Le,p(J).action??p(J).reason??"registry drift sample missing or empty")}),F(yt,qt)});var ci=_(Li,2),nn=y(ci),_n=_(y(nn)),to=y(_n);nt(to,17,()=>m(t.registry?.samples?.paper_selected,3),ut,(yt,J)=>{var qt=Dct(),Xt=_(y(qt)),de=y(Xt),Ht=_(Xt),Re=y(Ht),Fe=_(Ht),Le=y(Fe);U((Pe,gr,Br)=>{A(de,Pe),A(Re,gr),A(Le,Br)},[()=>i(p(J).selection_status),()=>i(p(J).strategy),()=>i(p(J).reason)]),F(yt,qt)});var xn=_(to);nt(xn,17,()=>m(t.registry?.samples?.realized_returns,6),ut,(yt,J)=>{var qt=Mct(),Xt=_(y(qt)),de=y(Xt),Ht=_(Xt),Re=y(Ht),Fe=_(Ht),Le=y(Fe);U((Pe,gr,Br)=>{A(de,Pe),A(Re,gr),A(Le,Br)},[()=>i(p(J).date),()=>a(p(J).realized_return),()=>i(p(J).source??p(J).evidence_status??p(J).numeric_error)]),F(yt,qt)});var Mo=_(xn);nt(Mo,17,()=>m(t.registry?.samples?.drawdown,6),ut,(yt,J)=>{var qt=Lct(),Xt=_(y(qt)),de=y(Xt),Ht=_(Xt),Re=y(Ht),Fe=_(Ht),Le=y(Fe);U((Pe,gr,Br)=>{A(de,Pe),A(Re,gr),A(Le,Br)},[()=>i(p(J).date),()=>a(p(J).paper_forward_drawdown),()=>i(p(J).source??p(J).evidence_status??p(J).numeric_error)]),F(yt,qt)});var eo=_(Mo);nt(eo,17,()=>m(t.registry?.samples?.decision_log,3),ut,(yt,J)=>{var qt=Ict(),Xt=_(y(qt)),de=y(Xt),Ht=_(Xt),Re=y(Ht),Fe=_(Ht),Le=y(Fe);U((Pe,gr,Br)=>{A(de,Pe),A(Re,gr),A(Le,Br)},[()=>i(p(J).event),()=>i(p(J).status),()=>i(p(J).detail??p(J).reasons)]),F(yt,qt)});var Lo=_(ci,4),Io=y(Lo),bn=_(ot,2),Ro=y(bn),hs=_(y(Ro)),wn=y(hs),ro=_(Ro,2);nt(ro,21,()=>b(t.universeBreakdown?.counts_by_type,7),ut,(yt,J)=>{var qt=q(()=>Ki(p(J),2));let Xt=()=>p(qt)[0],de=()=>p(qt)[1];var Ht=Rct(),Re=y(Ht),Fe=y(Re),Le=_(Re),Pe=_(Le),gr=y(Pe);U((Br,Ca)=>{A(Fe,Xt()),ha(Le,Br),A(gr,Ca)},[()=>`width:${Math.max(4,de()/w(t.universeBreakdown?.counts_by_type)*100)}%`,()=>de().toLocaleString("ko-KR")]),F(yt,Ht)});var ao=_(ro,2);nt(ao,21,()=>Object.entries(t.universeBreakdown?.summary??{}).slice(0,6),ut,(yt,J)=>{var qt=q(()=>Ki(p(J),2));let Xt=()=>p(qt)[0],de=()=>p(qt)[1];var Ht=Pct(),Re=y(Ht),Fe=y(Re),Le=_(Re),Pe=y(Le);U(gr=>{A(Fe,Xt()),A(Pe,gr)},[()=>String(de()??"—")]),F(yt,Ht)});var ql=_(bn,2),ps=y(ql),Zs=_(y(ps)),$l=y(Zs),Yl=_(ps,2);{var Zl=yt=>{var J=Bct(),qt=lr(J);nt(qt,21,()=>d(t.symbolChart.ohlcv,60),ut,(Le,Pe)=>{const gr=q(()=>e(p(Pe).close)),Br=q(()=>e(p(Pe).high));var Ca=jo(),Oa=lr(Ca);{var ka=wa=>{var Ra=Ect();U(()=>xe(Ra,"title",`${p(Pe).date} INCOMPLETE_NUMERIC_EVIDENCE`)),F(wa,Ra)},ni=wa=>{var Ra=Nct();U($i=>{ha(Ra,$i),xe(Ra,"title",`${p(Pe).date} close ${p(Pe).close}`)},[()=>`height:${Math.max(8,Math.min(96,p(gr)/p(Br)*92))}%`]),F(wa,Ra)};It(Oa,wa=>{p(gr)===null||p(Br)===null||p(Br)===0?wa(ka):wa(ni,-1)})}F(Le,Ca)});var Xt=_(qt,2),de=y(Xt),Ht=_(Xt,2);{var Re=Le=>{var Pe=zct();nt(Pe,21,()=>d(c(),3),ut,(gr,Br)=>{var Ca=Oct(),Oa=lr(Ca),ka=y(Oa),ni=y(ka),wa=_(ka),Ra=y(wa),$i=_(Oa,2),gs=_(y($i)),Po=y(gs),jl=_($i,2),Xs=_(y(jl)),Kl=y(Xs);U(()=>{A(ni,p(Br).section??"symbol guide"),A(Ra,p(Br).can_do),A(Po,p(Br).must_not),A(Kl,p(Br).next_action)}),F(gr,Ca)}),F(Le,Pe)},Fe=q(()=>c().length);It(Ht,Le=>{p(Fe)&&Le(Re)})}U(()=>A(de,t.symbolChart.guardrail)),F(yt,J)},Xl=yt=>{var J=Vct();F(yt,J)};It(Yl,yt=>{t.symbolChart?.ohlcv?.length?yt(Zl):yt(Xl,-1)})}U((yt,J,qt,Xt,de,Ht,Re,Fe,Le,Pe,gr,Br,Ca,Oa,ka)=>{or(P,1,`pill ${yt??""}`,"svelte-1dxayfu"),A(E,t.decision?.status??"NOT_STARTED"),A(Y,t.decision?.model_build_allowed?"UNLOCKED":"LOCKED"),A(_t,t.flow?.model_build_allowed?"GO":"NO-GO"),A(ft,t.glossary?.status??"REFERENCE_ONLY"),A(Yt,t.researchDiagnostics?.status??"WATCH"),A(Kt,J),A(ge,t.researchDiagnostics?.guardrail??"D7 diagnostics are explanatory research surfaces only; no profit, live, broker, order, or deployable model claim."),A(Et,t.equityOverlay?.status??"NOT_STARTED"),A(me,t.equityOverlay?.guardrail),A(_e,t.heatmap?.model_build_allowed?"MODEL BUILD GO":"MODEL BUILD LOCK"),A(da,t.runScatter?.status??"NOT_STARTED"),A(ae,t.registry?.promotion_status??t.registry?.status??"NOT_STARTED"),or(ie,1,`decision-card ${t.registry?.model_build_allowed===!0?"pass":"block"}`,"svelte-1dxayfu"),A(ze,qt),or(De,1,`decision-card ${t.registry?.paper_forward_allowed===!0?"watch":"block"}`,"svelte-1dxayfu"),A(Jt,Xt),A(Ve,de),A(yr,t.registry?.no_live_broker_order_readiness===!0?"true":"UNKNOWN_OR_UNSAFE"),A(zr,t.registry?.run_id??"—"),A(bt,Ht),A(Ye,Re),A(ne,Fe),A(vr,Le),A(ee,Pe),A(Xr,t.registry?.read_only_dashboard_note??"GET-only D8/D9 evidence surface"),A(_a,gr),A(_i,Br),A(Na,Ca),A(Ir,Oa),A(ja,ka),A(Io,t.registry?.guardrail??"D8/D9 registry is research-only; no live/broker/orders or profit claim."),A(wn,t.universeBreakdown?.status??"WATCH"),A($l,t.symbolChart?.code??"no symbol")},[()=>S(t.decision?.status),()=>String(t.researchDiagnostics?.summary?.korean??"D7 연구 진단은 feature/regime/correlation/failure 원인 분석을 위한 읽기 전용 확장 영역입니다."),()=>h(t.registry?.model_build_allowed),()=>h(t.registry?.paper_forward_allowed),()=>h(t.registry?.live_broker_order_allowed),()=>String(t.registry?.config_hash??"—").slice(0,16),()=>String(t.registry?.data_hash??"—").slice(0,16),()=>String(t.registry?.code_hash??"—").slice(0,16),()=>f(t.registry?.effective_gate_blockers,"MISSING_EFFECTIVE_GATE_EVIDENCE"),()=>f(t.registry?.invariant_errors,"MISSING_INVARIANT_EVIDENCE"),()=>x(t.registry?.samples?.paper_selected,3),()=>x(t.registry?.samples?.realized_returns,6),()=>x(t.registry?.samples?.drawdown,6),()=>x(t.registry?.samples?.drift,6),()=>x(t.registry?.samples?.decision_log,3)]),F(r,L),Yr()}var Hct=G(' '),Wct=G('
                Scenario Lab · SCENARIO_GENERATOR_MVP

                가정·시나리오 생성 플랫폼

                여러 비용·데이터 수리·후보 모델 계약 가정을 한 번에 생성해 비교합니다. 현재 단계는 read-only scenario generation이며 model_run_generation_available=false 입니다. 수익 보장, 실거래, 브로커, 주문 준비 상태가 아니고 no live/broker/orders 가드레일을 유지합니다.

                scenario_count
                scenario_generation_available
                model_run_generation_available
                read_only
                Model input contract
                scenariocoststatusassumptionsblockersnext artifacts

                핵심 current scenario marker: cost_23bp_current_evidence. 모델 후보 생성은 scenario_manifest.json과 fresh_oos_walk_forward_manifest.json이 생긴 뒤에만 비교 가능합니다.

                ');function Uct(r,t){$r(t,!0);const e=(B,z=10)=>(B??[]).slice(0,z),a=B=>Array.isArray(B)?B.join(" · "):String(B??"—"),i=B=>B===!0?"true":B===!1?"false":"—",n=B=>{const z=String(B.status??"");return z==="NO-GO"||z==="BLOCKED"?"danger":z==="HYPOTHESIS_ONLY"||z==="RESEARCH_ONLY"?"warn":""};var o=Wct(),s=y(o),l=_(y(s),2),u=_(y(l),1,!0),v=_(s,4),c=y(v),d=_(y(c)),f=y(d),h=_(c,2),g=_(y(h)),m=y(g),x=_(h,2),b=_(y(x)),w=y(b),S=_(x,2),C=_(y(S)),T=y(C),k=_(v,2),D=_(y(k),2),M=y(D),L=_(D,2),I=y(L),P=_(L,2),E=y(P),N=_(k,2),O=y(N),V=_(y(O));nt(V,21,()=>e(t.scenarioLab?.scenario_rows,12),ut,(B,z)=>{var H=Hct(),Y=y(H),$=y(Y),Z=_(Y),Q=y(Z),at=_(Z),et=y(at),_t=_(at),Ot=y(_t),xt=_(_t),mt=y(xt),wt=_(xt),ft=y(wt);U((K,Ct,Mt,zt)=>{or(H,1,K,"svelte-1wjfvo4"),A($,p(z).scenario_id),A(Q,`${p(z).cost_bps??""}bp`),A(et,p(z).status),A(Ot,Ct),A(mt,Mt),A(ft,zt)},[()=>cn(n(p(z))),()=>a(p(z).assumption_changes),()=>a(p(z).blocking_reasons),()=>a(p(z).required_next_artifacts)]),F(B,H)}),U((B,z,H,Y,$,Z)=>{A(u,t.scenarioLab?.status??"RESEARCH_ONLY"),A(f,t.scenarioLab?.scenario_count??0),A(m,B),A(w,z),A(T,H),A(M,`allowed_inputs: ${Y??""}`),A(I,`required_outputs: ${$??""}`),A(E,`must_not_generate: ${Z??""}`)},[()=>i(t.scenarioLab?.scenario_generation_available),()=>i(t.scenarioLab?.model_run_generation_available),()=>i(t.scenarioLab?.read_only),()=>a(t.scenarioLab?.model_input_contract?.allowed_inputs),()=>a(t.scenarioLab?.model_input_contract?.required_outputs),()=>a(t.scenarioLab?.model_input_contract?.must_not_generate)]),F(r,o),Yr()}var qct=G(' '),$ct=G(' scenario_batch_manifest.json'),Yct=G(' '),Zct=G('
                Scenario Run Ledger · SCENARIO_BATCH_RUNNER_MVP

                실행된 가정·시나리오 모델 비교

                대시보드는 read-only 입니다. 여러 모델/가정 실행은 명시적 CLI인 stom_rl.daily_scenario_batch 또는 stom_rl.daily_scenario_runner 로만 생성하고, 여기서는 scenario_batch_manifest.json 과 scenario_manifest.json 을 비교합니다. no live/broker/orders 및 수익 보장 금지 가드레일은 항상 유지합니다.

                scenario runs
                batches
                model_run_generation_available
                dashboard_mutation_available
                가장 빠른 실행 명령
                batchstatusscenariosgate countsartifact
                runstatusstrategyfold/purge/embargocostsblockers

                comparison_rows 는 배치 manifest 안에 저장되며, GO/NO-GO는 D5 게이트와 0/23/46bp 비용 민감도 기준으로만 읽습니다.

                ');function Xct(r,t){$r(t,!0);const e=(z,H=8)=>(z??[]).slice(0,H),a=z=>Array.isArray(z)?z.join(" · "):String(z??"—"),i=z=>z===!0?"true":z===!1?"false":"—",n=z=>!z||typeof z!="object"||Array.isArray(z)?"—":Object.entries(z).map(([H,Y])=>`${H}:${Y}`).join(" · "),o=z=>{const H=String(z??"").toUpperCase();return H.includes("ERROR")||H.includes("NO-GO")?"danger":H.includes("RESEARCH")||H.includes("DRY_RUN")?"warn":""};var s=Zct(),l=y(s),u=_(y(l),2),v=_(y(u),1,!0),c=_(l,4),d=y(c),f=_(y(d)),h=y(f),g=_(d,2),m=_(y(g)),x=y(m),b=_(g,2),w=_(y(b)),S=y(w),C=_(b,2),T=_(y(C)),k=y(T),D=_(c,2),M=_(y(D),2);nt(M,17,()=>t.ledger?.quick_start_commands??["py -3.11 -m stom_rl.daily_scenario_batch --emit-template"],ut,(z,H)=>{var Y=qct(),$=y(Y);U(()=>A($,p(H))),F(z,Y)});var L=_(M,2),I=y(L),P=_(D,2),E=y(P),N=_(y(E));nt(N,21,()=>e(t.ledger?.batches),ut,(z,H)=>{var Y=$ct(),$=y(Y),Z=y($),Q=_($),at=y(Q),et=_(Q),_t=y(et),Ot=_(et),xt=y(Ot);U((mt,wt)=>{or(Y,1,mt,"svelte-1l699pf"),A(Z,p(H).batch_id),A(at,p(H).status),A(_t,p(H).scenario_count),A(xt,wt)},[()=>cn(o(p(H).status)),()=>n(p(H).gate_status_counts)]),F(z,Y)});var O=_(P,2),V=y(O),B=_(y(V));nt(B,21,()=>e(t.ledger?.runs,10),ut,(z,H)=>{var Y=Yct(),$=y(Y),Z=y($),Q=_($),at=y(Q),et=_(Q),_t=y(et),Ot=_(et),xt=y(Ot),mt=_(Ot),wt=y(mt),ft=_(mt),K=y(ft);U((Ct,Mt,zt)=>{or(Y,1,Ct,"svelte-1l699pf"),A(Z,p(H).run_id),A(at,p(H).status),A(_t,p(H).selected_strategy??"—"),A(xt,`${p(H).n_folds??"—"??""} / ${p(H).purge_days??"—"??""} / ${p(H).embargo_days??"—"??""}`),A(wt,Mt),A(K,zt)},[()=>cn(o(p(H).status)),()=>a(p(H).cost_sensitivity_bp),()=>a(p(H).blocking_reasons)]),F(z,Y)}),U((z,H,Y)=>{A(v,t.ledger?.status??"RESEARCH_ONLY"),A(h,t.ledger?.scenario_run_count??0),A(x,t.ledger?.batch_count??0),A(S,z),A(k,H),A(I,`required_controls: ${Y??""}`)},[()=>i(t.ledger?.model_run_generation_available),()=>i(t.ledger?.dashboard_mutation_available),()=>a(t.ledger?.required_controls)]),F(r,s),Yr()}var jct=G('
                '),Kct=G('
                '),Qct=G('
                code
                table
                rows
                range
                '),Jct=G('
                아직 선택된 종목이 없습니다. 유니버스 미리보기에서 상세 버튼을 누르세요.
                '),tdt=G(' '),edt=G('
                Daily OHLCV Research READ_ONLY · WATCH no live/broker/orders

                일봉 기반 딥러닝·강화학습 준비 대시보드

                현재 화면은 D0 DB 분석, D1 유니버스, D2 데이터셋, D3 예측 베이스라인, D4 포트폴리오 RL, D5 워크포워드/게이트, D6 시각화, D7 연구 진단, D8/D9 레지스트리·페이퍼 포워드 잠금 증거를 표시합니다. 수익 보장, 실거래, 주문, 브로커 준비 상태가 아니며 현재 모델 생성 GO가 아니라 NO-GO/RESEARCH_ONLY 상태를 그대로 노출합니다.

                D0 Symbol Drilldown

                종목 상세 조회

                Artifacts

                생성 증거 파일

                GET-only
                kindrunfilebytes
                ',1);function rdt(r,t){$r(t,!0);let e=rt(null),a=rt(null),i=rt(null),n=rt(null),o=rt(null),s=rt(null),l=rt(null),u=rt(null),v=rt(null),c=rt(null),d=rt(null),f=rt(null),h=rt(null),g=rt(null),m=rt(null),x=rt(null),b=rt(null),w=rt(null),S=rt(null),C=rt(null),T=rt(null),k=rt(null),D=rt(null),M=rt(null),L=rt(null),I=rt(null),P=rt(Fr([])),E=rt(!1);async function N(){W(E,!0);try{const[se,St,Et,ue,Se,me,Xe,br,kt,_e,ke,Tr,Oe,Hr,Zr,da,ua,Zt,j,ot,Tt,ce,ae]=await Promise.all([Da.progress(),Da.dbSummary(),Da.universePreview(),Da.artifacts(),Da.datasetLatest(),Da.datasetChart(),Da.predictionLatest(),Da.portfolioLatest(),Da.walkForwardLatest(),Da.registryLatest(),Da.predictionChart(),Da.portfolioChart(),Da.walkForwardChart(),Da.decisionCockpitChart(),Da.scenarios(),Da.scenarioRuns(),Da.flowChart(),Da.glossaryChart(),Da.researchDiagnosticsChart(),Da.equityOverlayChart(),Da.walkForwardHeatmapChart(),Da.runScatterChart(),Da.universeBreakdownChart()]);W(P,[["progress",se],["db-summary",St],["universe",Et],["artifacts",ue],["dataset",Se],["dataset-chart",me],["prediction",Xe],["portfolio",br],["walk-forward",kt],["registry",_e],["prediction-chart",ke],["portfolio-chart",Tr],["walk-forward-chart",Oe],["decision-cockpit",Hr],["scenarios",Zr],["scenario-runs",da],["flow-chart",ua],["glossary-chart",Zt],["research-diagnostics",j],["equity-overlay",ot],["walk-forward-heatmap",Tt],["run-scatter",ce],["universe-breakdown",ae]].filter(([,ie])=>ie===null).map(([ie])=>ie),!0),W(e,se,!0),W(a,St,!0),W(i,Et,!0),W(n,ue,!0),W(o,Se,!0),W(s,me,!0),W(l,Xe,!0),W(u,br,!0),W(v,kt,!0),W(c,_e,!0),W(d,ke,!0),W(f,Tr,!0),W(h,Oe,!0),W(g,Hr,!0),W(m,Zr,!0),W(x,da,!0),W(b,ua,!0),W(w,Zt,!0),W(S,j,!0),W(C,ot,!0),W(T,Tt,!0),W(k,ce,!0),W(D,ae,!0)}finally{W(E,!1)}}async function O(se){W(I,null);const St=String(se??"").trim();if(!St)return;const[Et,ue]=await Promise.all([Da.symbol(St,20),Da.symbolChart(St,160)]);Et?(W(M,Et,!0),W(L,ue,!0)):(W(L,null),W(I,`${St} 종목 상세를 불러오지 못했습니다.`))}mn(()=>{N()});var V=edt(),B=lr(V),z=_(y(B),6),H=y(z),Y=y(H),$=_(z,2);{var Z=se=>{var St=jct(),Et=y(St);U(ue=>A(Et,`API_UNAVAILABLE: ${ue??""} · 데이터 없음(NOT_STARTED)과 API 실패를 분리합니다. decision locks remain false; no model/profit/live readiness is inferred.`),[()=>p(P).join(", ")]),F(se,St)};It($,se=>{p(P).length>0&&se(Z)})}var Q=_(B,2);yvt(Q,{get progress(){return p(e)}});var at=_(Q,2);Uct(at,{get scenarioLab(){return p(m)}});var et=_(at,2);Xct(et,{get ledger(){return p(x)}});var _t=_(et,2);wvt(_t,{get summary(){return p(a)}});var Ot=_(_t,2);Lvt(Ot,{get universe(){return p(i)},onSymbolSelect:se=>void O(se)});var xt=_(Ot,2);Gvt(xt,{get dataset(){return p(o)},get chart(){return p(s)}});var mt=_(xt,2);vct(mt,{get prediction(){return p(l)},get portfolio(){return p(u)},get walkForward(){return p(v)},get predictionChart(){return p(d)},get portfolioChart(){return p(f)},get walkForwardChart(){return p(h)}});var wt=_(mt,2);Gct(wt,{get decision(){return p(g)},get flow(){return p(b)},get glossary(){return p(w)},get researchDiagnostics(){return p(S)},get equityOverlay(){return p(C)},get heatmap(){return p(T)},get runScatter(){return p(k)},get universeBreakdown(){return p(D)},get registry(){return p(c)},get symbolChart(){return p(L)}});var ft=_(wt,2),K=y(ft),Ct=_(y(K),2),Mt=_(y(Ct),1,!0),zt=_(K,2);zt.textContent="유니버스 미리보기의 상세 버튼은 `/api/daily-ohlcv/symbol/{code}`를 호출하며 000250 같은 선행 0 코드를 문자열로 유지합니다.";var Yt=_(zt,2);{var we=se=>{var St=Kct(),Et=y(St);U(()=>A(Et,p(I))),F(se,St)},Kt=se=>{var St=Qct(),Et=y(St),ue=_(y(Et)),Se=y(ue),me=_(Et,2),Xe=_(y(me)),br=y(Xe),kt=_(me,2),_e=_(y(kt)),ke=y(_e),Tr=_(kt,2),Oe=_(y(Tr)),Hr=y(Oe);U(Zr=>{A(Se,p(M).code),A(br,p(M).table),A(ke,Zr),A(Hr,`${p(M).first_date??"—"??""} → ${p(M).last_date??"—"??""}`)},[()=>p(M).row_count?.toLocaleString("ko-KR")??"—"]),F(se,St)},qe=se=>{var St=Jct();F(se,St)};It(Yt,se=>{p(I)?se(we):p(M)?se(Kt,1):se(qe,-1)})}var fr=_(ft,2),re=_(y(fr),2),ge=y(re),ye=_(y(ge));nt(ye,21,()=>p(n)?.artifacts??[],ut,(se,St)=>{var Et=tdt(),ue=y(Et),Se=y(ue),me=_(ue),Xe=y(me),br=_(me),kt=y(br),_e=_(br),ke=y(_e);U(()=>{A(Se,p(St).kind),A(Xe,p(St).run_id),A(kt,p(St).primary_file),A(ke,p(St).size_bytes)}),F(se,Et)}),U(()=>{H.disabled=p(E),A(Y,p(E)?"갱신 중…":"새로고침"),A(Mt,p(M)?.price_basis??"unknown")}),cr("click",H,()=>void N()),F(r,V),Yr()}an(["click"]);var adt=G(''),idt=G('

                ',1),ndt=G(''),odt=G('
                '),sdt=G('
                ',1),ldt=G('

                '),udt=G('

                '),vdt=G('

                '),cdt=G('

                '),ddt=G('

                '),fdt=G(' '),hdt=G(' '),pdt=G(''),gdt=G('

                '),ydt=G('

                '),mdt=G(' '),_dt=G('
                intentworkflowstatusapprovalhash
                '),xdt=G('

                아직 기록된 intent가 없습니다. 유효한 승인과 SHA가 없으면 생성 요청은 fail-closed 됩니다.

                '),bdt=G(' '),wdt=G('

                '),Sdt=G('

                '),Tdt=G('
                '),Adt=G(' '),Cdt=G(' '),kdt=G(''),Ddt=G(' '),Mdt=G(' '),Ldt=G('
                '),Idt=G(' '),Rdt=G('
                '),Pdt=G('

                '),Edt=G('

                '),Ndt=G('

                한계:

                다음 행동:

                Gate:

                '),Odt=G('
                '),zdt=G(' '),Bdt=G('
                '),Vdt=G('
                MISSING_POLICY_ARTIFACT · MISSING_REPLAY_ARTIFACT
                '),Fdt=G('

                수익률
                수익금
                환산 평가금
                MDD
                Turnover
                '),Gdt=G('
                '),Hdt=G(''),Wdt=G(''),Udt=G('
                ',1),qdt=G('

                '),$dt=G(' '),Ydt=G("

                "),Zdt=G(' '),Xdt=G(`
                Daily RL Environment Guide RL_ENV_VISUAL_GUIDE_MVP read-only · no live/broker/orders

                일봉 강화학습 환경 설명서

                강화학습을 모르는 상태에서도 “무엇을 보고(state), 무엇을 할 수 있고(action), 어떤 점수를 받는지(reward), 왜 아직 실거래가 아닌지”를 한 화면에서 이미지처럼 읽도록 만든 설명서입니다.

                핵심 상태 필드 marker: position_count · top_score_bucket. 행동 marker: hold · buy · add · sell · reduce. 보상 label marker: future_return_1d.

                현재 구축 상태

                환경은 구축되어 있지만 연구 전용입니다

                environment_built
                cost
                state shape
                status
                Image-like Process

                Agent가 하루씩 배우는 순환 구조

                Agent ↔ Environment ↔ Reward
                일봉 강화학습 환경 순환 다이어그램D2/D3 데이터가 상태를 만들고, 에이전트가 행동을 고르면 환경이 마스크와 비용을 적용해 보상을 돌려주고 D5가 검증합니다.INPUTD2/D3 데이터feature · split · rankSTATE현재 관측position_counttop_score_bucketAGENT / POLICY행동 선택hold · buy · addsell · reduceENVIRONMENTAction Mask불가능한 행동 차단REWARD점수 계산future_return_1d- 23bp - penaltiesD5 GATE워크포워드5-fold · no retuneNO-GO면 승격 금지mask 적용 후 체결/보유 상태 갱신보상은 다음 state 학습 신호실거래 주문이 아니라, 연구용 일봉 데이터로 “상태 → 행동 → 보상 → 검증”을 반복하는 폐쇄 루프입니다.
                Research Process Selector

                전체 연구 프로세스를 선택해서 한계와 개선 방향 확인

                State

                Action

                Reward

                Metrics to watch
                Required artifacts
                AI-readable / AI 개선 지시 고정 포맷

                선택한 연구 lane의 한계·개선 방향을 AI Agent가 그대로 읽고 다음 실험 계획으로 사용할 수 있는 고정 JSON입니다.

                 
                Research Workflow Center · dashboard-first

                CLI 대신 대시보드에서 연구 workflow와 blocker를 확인

                이 영역은 연구 workflow를 보고·검사하고·설정 초안을 준비하기 위한 화면입니다. 브라우저 실행은 막혀 있으며 실거래/브로커/주문/모델빌드/수익 주장으로 이어지지 않습니다.

                Workflow markers: D0_D1_DATA_GOVERNANCE_REVIEW · D3_D4_SIGNAL_QUALITY_AUDIT · PAST_ONLY_MARKET_REGIME_AUDIT · D4_RL_OVERLAY_ABLATION · SCENARIO_BATCH_RESEARCH_ONLY · HYPOTHESIS_REJECTION_AUDIT.

                workflows
                completion
                browser execution
                default cost
                23bp

                no live/broker/orders

                Blockers

                Artifacts

                Guardrail

                Approval-aware trigger surface · intent record only

                승인된 연구 의도만 기록하고 실행은 하지 않음

                APPROVAL_GATED_INTENT_RECORD_ONLY no shell / no worker spawn model·paper·live locks false
                Job / artifact ledger · immutable research intents

                연구 intent ledger

                ledger status
                intent count
                browser execution
                live/model/paper
                0%

                Hypothesis rejection analytics · research QA

                가설 탈락과 조기 dropout이 과한지 검토

                gate funnel, rejection taxonomy, calibration, threshold sensitivity, false-negative 후보를 한 화면에서 봅니다. 후보는 REVIEW_ONLY이며 NO-GO를 뒤집거나 모델/페이퍼/실거래를 unlock하지 않습니다.

                audit run
                rejected
                early dropout
                promotion allowed

                Gate funnel metrics

                gatedenominatorenteredwatchrejectdropout

                Rejection taxonomy

                False-negative review queue

                Guardrail

                Completion report · dashboard-first research platform

                비실거래 연구 플랫폼 완료 성과와 남은 lock

                NON_LIVE_RESEARCH_PLATFORM_COMPLETE live/model/paper readiness 0%
                non_live_goal_completion_pct
                live_trading_readiness_pct
                model_build_readiness_pct
                paper_forward_readiness_pct

                완료된 기능 표면

                surfacecompletionevidence

                이 완료율은 workflow center, inspector, safe config builder, intent ledger, rejection analytics, 문서/검증 표면에만 적용됩니다. 실거래·브로커 주문·페이퍼 포워드·모델 빌드·수익성 주장은 계속 0%/blocked입니다.

                Scenario Generator · read-only draft

                여러 가정/시나리오를 선택하고 고정 JSON 초안 확인

                이 패널은 실행 버튼이 아니라 시나리오 계획 JSON 초안을 만드는 설명/검토 화면입니다. 실제 연구 실행은 preregistration과 CLI batch manifest로만 진행합니다.

                Template markers: D3_D4_SIGNAL_QUALITY_AUDIT · PAST_ONLY_MARKET_REGIME_AUDIT · D4_RL_OVERLAY_ABLATION.

                templates
                default cost
                23bp
                execution
                export
                JSON

                Required artifacts

                Latest D3/D4 Signal-quality Audit

                최신 신호 품질 결과와 시나리오 비교

                Run: · verdict:

                Row counts
                Baselines / cost

                Artifact links

                Next research readiness

                Past-only 시장 국면 데이터 품질 감사 준비도

                AI-readable next-action guidance
                 
                AI-readable Improvement Queue

                한계점 → 다음 행동 → 필요 산출물 → 수락 gate

                 
                Numeric maturity report

                전체 페이지 성숙도와 연구 준비도

                priorityfeaturecompletionstatusevidence

                Artifact-backed RL Replay

                저장된 산출물로 움직이는 강화학습 리플레이

                실제 계좌/주문 실시간 화면이 아니라, 저장된 D4 산출물의 state_observations · reward_breakdown · policy_nav · action_distribution을 + 1.6초 단위로 재생합니다. 현재 tabular Q telemetry는 신경망 화면이나 가짜 확률처럼 꾸미지 않고 산출물 action rate와 보상 항목만 보여줍니다.

                position_count
                top_score_bucket
                cash_fraction
                exposure_fraction
                top_candidate_code
                future_label_exposed

                Policy representation status
                state
                Q-table
                artifact telemetry
                action
                Action distribution from artifact rows
                Action · reward feedback

                Action Mask
                Reward
                net_return_after_cost
                turnover_cost
                drawdown_penalty
                episode reward
                policy_nav
                Learning Outcome Example

                학습 성과 예시: 수익금 · 수익률 · 리스크

                아래 금액은 실거래 수익이 아니라 에 + 연구용 평가 수익률을 곱해 이해하기 쉽게 환산한 모의 성과입니다.

                Training curve preview

                episode별 reward / final equity

                Portfolio NAV preview

                연구용 평가금 흐름

                Visual Map

                D2 → D5 일봉 RL 흐름

                state · action · reward · gate
                RL 구성 요소

                Agent · State · Action · Reward

                환경 계약

                상태, 행동, 보상 공식

                State 관측

                Action space

                Action mask

                Reward

                잘 구축되어 있는지 확인

                PASS/WATCH 체크리스트

                checkstatus의미

                가능한 용도

                아직 불가능한 용도

                `,1);function jdt(r,t){$r(t,!0);let e=rt(null),a=rt(!1),i=rt(0),n=rt("D4_RL_RISK_OVERLAY"),o=rt("D3_D4_SIGNAL_QUALITY_AUDIT"),s=rt("PAST_ONLY_MARKET_REGIME_AUDIT");const l=it=>Array.isArray(it)?it.filter(X=>!!X&&typeof X=="object"&&!Array.isArray(X)):[],u=it=>Array.isArray(it)?it.join(" · "):String(it??"—"),v=it=>Array.isArray(it)?it.map(X=>String(X)):[],c=it=>JSON.stringify(it??{},null,2),d=it=>it===!0?"true":it===!1?"false":"—",f=it=>it&&typeof it=="object"&&!Array.isArray(it)?it:{},h=(it,X)=>f(it)[X],g=(it,X)=>f(h(it,X)),m=it=>{const X=Number(it);return Number.isFinite(X)?X:null},x=(it,X=2)=>{const ct=m(it);return ct===null?"—":`${ct>0?"+":""}${ct.toFixed(X)}%`},b=it=>{const X=m(it);return X===null?"—":new Intl.NumberFormat("ko-KR",{style:"currency",currency:"KRW",maximumFractionDigits:0}).format(X)},w=(it,X=3)=>{const ct=m(it);return ct===null?"—":ct.toFixed(X)},S=it=>{const X=m(it);return X===null?"—":`${X.toFixed(0)}%`},C=(it,X=100)=>{const ct=m(it);return`--bar-width:${ct===null?0:Math.max(4,Math.min(100,Math.abs(ct)*X))}%`},T=it=>{const X=m(it);return`--bar-width:${X===null?0:Math.max(3,Math.min(100,X*100))}%`},k=it=>{const X=m(it);return X===null?"—":`${Math.round(X*100)}%`},D=it=>Object.entries(f(it)),M=it=>l(it).length>0,L=()=>l(h(p(e)?.research_process_catalog,"lanes")),I=()=>{const it=L();return it.find(X=>String(h(X,"id"))===p(n))??it[0]??{}},P=()=>l(h(p(e)?.active_replay,"frames")),E=()=>{const it=P();return it.length>0?it[p(i)%it.length]??{}:{}},N=()=>g(E(),"state"),O=()=>g(E(),"action"),V=()=>g(E(),"reward"),B=()=>g(E(),"learning"),z=()=>g(E(),"nav"),H=()=>l(h(p(e)?.active_replay,"action_distribution")),Y=()=>{const it=String(h(O(),"executed")??""),X=H(),ct=X.filter(Dt=>String(h(Dt,"action"))===it);return ct.length>0?ct:X.slice(0,5)},$=(it,X)=>v(h(it,X)),Z=()=>c(h(I(),"ai_guidance_format")),Q=()=>l(h(p(e)?.scenario_generator,"templates")),at=()=>{const it=Q();return it.find(X=>String(h(X,"template_id"))===p(o))??it[0]??{}},et=()=>c(h(at(),"plan_json_draft")),_t=()=>D(h(p(e)?.signal_quality_audit_summary,"row_counts")),Ot=()=>D(h(p(e)?.signal_quality_audit_summary,"required_artifacts")),xt=()=>l(h(p(e)?.scenario_comparison,"cards")),mt=()=>l(h(p(e)?.market_regime_audit_readiness,"readiness_checks")),wt=()=>l(h(p(e)?.improvement_queue,"items")),ft=()=>l(h(p(e)?.page_maturity_report,"priority_completion")),K=()=>l(h(p(e)?.research_workflow_catalog,"workflows")),Ct=()=>l(h(p(e)?.research_job_intent_ledger,"intents")),Mt=()=>l(h(p(e)?.rejection_analytics,"gate_funnel_metrics")),zt=()=>l(h(p(e)?.rejection_analytics,"rejection_reason_taxonomy")),Yt=()=>l(h(p(e)?.rejection_analytics,"false_negative_candidates")),we=()=>l(h(p(e)?.dashboard_first_completion_report,"completed_surfaces")),Kt=()=>{const it=K();return it.find(X=>String(h(X,"workflow_id"))===p(s))??it[0]??{}},qe=()=>v(h(Kt(),"blocked_by")),fr=()=>v(h(Kt(),"artifact_dependencies")),re=it=>{const X=String(it??"").toUpperCase();return X==="PASS"||X==="INPUT"?"pass":X.includes("NO-GO")||X.includes("FAIL")||X.includes("BLOCK")?"danger":X.includes("WATCH")||X.includes("RESEARCH")?"warn":"neutral"},ge=[{no:"01",title:"데이터 준비",detail:"D2 일봉 feature/label/split과 D3 후보 점수를 입력으로 고정합니다.",tone:"input"},{no:"02",title:"State 생성",detail:"미래 수익률 없이 position_count와 top_score_bucket만 관측합니다.",tone:"pass"},{no:"03",title:"Agent 판단",detail:"정책 모델이 현재 상태를 보고 hold/buy/add/sell/reduce 중 하나를 고릅니다.",tone:"warn"},{no:"04",title:"Action Mask",detail:"보유/미보유/최대 포지션 조건으로 불가능한 행동을 차단합니다.",tone:"pass"},{no:"05",title:"Reward 계산",detail:"다음날 연구용 future_return_1d에서 23bp 비용과 위험 벌점을 뺍니다.",tone:"warn"},{no:"06",title:"D5 검증",detail:"5-fold walk-forward, cost sensitivity, no-retune 조건으로 GO/NO-GO를 냅니다.",tone:"danger"}];async function ye(){W(a,!0);try{W(e,await Da.rlEnvGuide(),!0)}finally{W(a,!1)}}mn(()=>{ye();const it=window.setInterval(()=>{W(i,(p(i)+1)%240)},1600);return()=>window.clearInterval(it)});var se=Xdt(),St=lr(se),Et=_(y(St),8),ue=y(Et),Se=y(ue),me=_(St,2),Xe=y(me),br=_(y(Xe),2),kt=_(y(br),1,!0),_e=_(Xe,2),ke=y(_e),Tr=_(_e,2),Oe=y(Tr),Hr=_(y(Oe)),Zr=y(Hr),da=_(Oe,2),ua=_(y(da)),Zt=y(ua),j=_(da,2),ot=_(y(j)),Tt=y(ot),ce=_(j,2),ae=_(y(ce)),Qt=y(ae),ie=_(me,2),$e=_(y(ie),4);nt($e,21,()=>ge,ut,(it,X,ct)=>{var Dt=idt(),Ce=lr(Dt),$t=y(Ce),Ee=y($t),Ne=_($t,2),Ke=y(Ne),ar=_(Ne,2),Cr=y(ar),Er=_(Ce,2);{var Nr=sa=>{var La=adt();F(sa,La)};It(Er,sa=>{ct{xe(Ce,"data-step-tone",p(X).tone),A(Ee,p(X).no),A(Ke,p(X).title),A(Cr,p(X).detail)}),F(it,Dt)});var ze=_(ie,2),De=y(ze),Bt=_(y(De),2),Jt=_(y(Bt),1,!0),fe=_(De,2),Ge=y(fe),Ve=_(fe,2);nt(Ve,21,L,ut,(it,X)=>{var ct=ndt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt,2),Ee=y($t),Ne=_($t,2),Ke=y(Ne);U((ar,Cr,Er,Nr)=>{xe(ct,"data-active",ar),A(Ce,Cr),A(Ee,Er),A(Ke,Nr)},[()=>String(h(p(X),"id"))===String(h(I(),"id")),()=>String(h(p(X),"stage")??"stage"),()=>String(h(p(X),"title_ko")??h(p(X),"id")??"연구 lane"),()=>String(h(p(X),"status")??"RESEARCH_ONLY")]),cr("click",ct,()=>{W(n,String(h(p(X),"id")??"D4_RL_RISK_OVERLAY"),!0)}),F(it,ct)});var je=_(Ve,2),tr=y(je),yr=y(tr),Me=y(yr),mr=_(yr,2),kr=y(mr),zr=_(mr,2),ea=y(zr),vt=_(zr,2);nt(vt,21,()=>l(h(I(),"visual_nodes")),ut,(it,X,ct)=>{var Dt=sdt(),Ce=lr(Dt),$t=y(Ce),Ee=y($t),Ne=_($t,2),Ke=y(Ne),ar=_(Ce,2);{var Cr=Nr=>{var sa=odt();F(Nr,sa)},Er=q(()=>ct{p(Er)&&Nr(Cr)})}U((Nr,sa,La)=>{xe(Ce,"data-tone",Nr),A(Ee,sa),A(Ke,La)},[()=>re(h(p(X),"status")),()=>String(h(p(X),"label")??"node"),()=>String(h(p(X),"detail")??"—")]),F(it,Dt)});var bt=_(vt,2),Gt=y(bt),he=_(y(Gt));nt(he,17,()=>$(I(),"state_setup"),ut,(it,X)=>{var ct=ldt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var Ye=_(Gt,2),He=_(y(Ye));nt(He,17,()=>$(I(),"action_setup"),ut,(it,X)=>{var ct=udt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var te=_(Ye,2),ne=_(y(te));nt(ne,17,()=>$(I(),"reward_setup"),ut,(it,X)=>{var ct=vdt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var Te=_(tr,2),rr=_(y(Te),2);nt(rr,17,()=>$(I(),"current_limitations"),ut,(it,X)=>{var ct=cdt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var hr=_(rr,4);nt(hr,17,()=>$(I(),"improvement_directions"),ut,(it,X)=>{var ct=ddt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var vr=_(je,2),st=y(vr),At=_(y(st),2);nt(At,17,()=>$(I(),"metrics_to_watch"),ut,(it,X)=>{var ct=fdt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var ee=_(At,4);nt(ee,17,()=>$(I(),"required_artifacts"),ut,(it,X)=>{var ct=hdt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var nr=_(st,2),wr=_(y(nr),4),Xr=y(wr),jr=_(ze,2),Aa=y(jr),ba=_(y(Aa),2),_a=_(y(ba),1,!0),Pa=_(Aa,6),Ga=y(Pa),_i=_(y(Ga)),vi=y(_i),pr=_(Ga,2),Na=_(y(pr)),qi=y(Na),Ut=_(pr,2),Ir=_(y(Ut)),Wr=y(Ir),va=_(Pa,2);nt(va,21,K,ut,(it,X)=>{var ct=pdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt,2),Ee=y($t),Ne=_($t,2),Ke=y(Ne);U((ar,Cr,Er,Nr)=>{xe(ct,"data-active",ar),A(Ce,Cr),A(Ee,Er),A(Ke,Nr)},[()=>String(h(p(X),"workflow_id"))===String(h(Kt(),"workflow_id")),()=>String(h(p(X),"stage")??"stage"),()=>String(h(p(X),"title_ko")??h(p(X),"workflow_id")??"workflow"),()=>String(h(p(X),"status")??"WATCH")]),cr("click",ct,()=>{W(s,String(h(p(X),"workflow_id")??"PAST_ONLY_MARKET_REGIME_AUDIT"),!0)}),F(it,ct)});var ja=_(va,2),Li=y(ja),ci=y(Li),nn=y(ci),_n=_(ci,2),to=y(_n),xn=_(_n,2),Mo=y(xn),eo=_(xn,2),Lo=y(eo),Io=y(Lo),bn=_(Lo,2),Ro=y(bn),hs=_(eo,2),wn=y(hs),ro=_(y(wn));nt(ro,17,qe,ut,(it,X)=>{var ct=gdt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var ao=_(wn,2),ql=_(y(ao));nt(ql,17,()=>fr().slice(0,6),ut,(it,X)=>{var ct=ydt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var ps=_(ao,2),Zs=_(y(ps)),$l=y(Zs),Yl=_(Li,2),Zl=_(y(Yl),2),Xl=y(Zl),yt=_(ja,2),J=y(yt),qt=_(y(J),4),Xt=y(qt),de=_(J,2),Ht=_(y(de),4),Re=y(Ht),Fe=_(yt,2),Le=_(y(Fe),4),Pe=y(Le),gr=_(Le,2),Br=y(gr),Ca=_(y(Br)),Oa=y(Ca),ka=_(Br,2),ni=_(y(ka)),wa=y(ni),Ra=_(ka,2),$i=_(y(Ra)),gs=y($i),Po=_(gr,2);{var jl=it=>{var X=_dt(),ct=y(X),Dt=_(y(ct));nt(Dt,21,()=>Ct().slice(0,6),ut,(Ce,$t)=>{var Ee=mdt(),Ne=y(Ee),Ke=y(Ne),ar=_(Ne),Cr=y(ar),Er=_(ar),Nr=y(Er),sa=_(Er),La=y(sa),za=_(sa),Ha=y(za);U((di,xi,io,ys,Jl)=>{A(Ke,di),A(Cr,xi),A(Nr,io),A(La,ys),A(Ha,Jl)},[()=>String(h(p($t),"intent_id")??"—"),()=>String(h(p($t),"workflow_id")??"—"),()=>String(h(p($t),"status")??"—"),()=>String(h(p($t),"approval_status")??"—"),()=>String(h(p($t),"config_hash")??"—").slice(0,12)]),F(Ce,Ee)}),F(it,X)},Xs=q(()=>Ct().length>0),Kl=it=>{var X=xdt();F(it,X)};It(Po,it=>{p(Xs)?it(jl):it(Kl,-1)})}var Nv=_(Fe,2),_d=y(Nv),xd=_(jr,2),ip=y(xd),np=_(y(ip),2),W_=_(y(np),1,!0),bd=_(ip,4),op=y(bd),sp=_(y(op)),U_=y(sp),wd=_(op,2),q_=_(y(wd)),lp=y(q_),up=_(wd,2),$_=_(y(up)),Y_=y($_),vp=_(up,2),cp=_(y(vp)),Z_=y(cp),dp=_(bd,2),Sd=y(dp),X_=_(y(Sd),2),j_=y(X_),fp=_(y(j_));nt(fp,21,()=>Mt().slice(0,6),ut,(it,X)=>{var ct=bdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt),Ee=y($t),Ne=_($t),Ke=y(Ne),ar=_(Ne),Cr=y(ar),Er=_(ar),Nr=y(Er),sa=_(Er),La=y(sa);U((za,Ha,di,xi,io,ys)=>{A(Ce,za),A(Ee,Ha),A(Ke,di),A(Cr,xi),A(Nr,io),A(La,ys)},[()=>String(h(p(X),"gate_id")??"—"),()=>String(h(p(X),"denominator_count")??"—"),()=>String(h(p(X),"entered_count")??"—"),()=>String(h(p(X),"watch_count")??"—"),()=>String(h(p(X),"rejected_count")??"—"),()=>String(h(p(X),"early_dropout_count")??"—")]),F(it,ct)});var K_=_(Sd,2),hp=_(y(K_),2),pp=_(y(hp)),gp=_(hp,2),Q_=_(y(gp)),yp=_(gp,2),J_=_(y(yp)),t0=_(dp,2),Td=y(t0),e0=_(y(Td),2);nt(e0,17,()=>zt().slice(0,4),ut,(it,X)=>{var ct=wdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt);U((Ee,Ne,Ke)=>{A(Ce,Ee),A($t,` ${Ne??""} · ${Ke??""}`)},[()=>String(h(p(X),"reason_id")??"—"),()=>String(h(p(X),"reason_family")??"—"),()=>String(h(p(X),"severity")??"—")]),F(it,ct)});var mp=_(Td,2),_p=_(y(mp),2);nt(_p,17,()=>Yt().slice(0,4),ut,(it,X)=>{var ct=Sdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt);U((Ee,Ne,Ke)=>{A(Ce,Ee),A($t,` ${Ne??""} · promotion_allowed=${Ke??""}`)},[()=>String(h(p(X),"candidate_id")??"—"),()=>String(h(p(X),"review_status")??"REVIEW_ONLY"),()=>String(h(p(X),"promotion_allowed")??!1)]),F(it,ct)});var r0=_(mp,2),a0=_(y(r0),2),xp=y(a0),Ad=_(xd,2),bp=y(Ad),i0=_(y(bp),2),wp=_(y(i0),1,!0),Sp=_(bp,2),n0=y(Sp),Tp=_(Sp,4),Ap=y(Tp),o0=_(y(Ap)),s0=y(o0),Cd=_(Ap,2),Cp=_(y(Cd)),l0=y(Cp),kp=_(Cd,2),Dp=_(y(kp)),u0=y(Dp),v0=_(kp,2),Mp=_(y(v0)),c0=y(Mp),d0=_(Tp,2),kd=y(d0),f0=_(y(kd),2),h0=y(f0),Lp=_(y(h0));nt(Lp,21,we,ut,(it,X)=>{var ct=Tdt(),Dt=y(ct),Ce=y(Dt),$t=y(Ce),Ee=_(Ce,2),Ne=y(Ee),Ke=_(Dt),ar=y(Ke),Cr=_(Ke),Er=y(Cr);U((Nr,sa,La,za)=>{A($t,Nr),A(Ne,sa),A(ar,`${La??""}%`),A(Er,za)},[()=>String(h(p(X),"id")??"surface"),()=>String(h(p(X),"label_ko")??"—"),()=>String(h(p(X),"completion_pct")??0),()=>String(h(p(X),"evidence")??"—")]),F(it,ct)});var p0=_(kd,2),Ip=_(y(p0),4);nt(Ip,17,()=>v(h(p(e)?.dashboard_first_completion_report,"can_do")),ut,(it,X)=>{var ct=Adt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var Rp=_(Ip,4);nt(Rp,17,()=>v(h(p(e)?.dashboard_first_completion_report,"cannot_do")),ut,(it,X)=>{var ct=Cdt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var Pp=_(Ad,2),Ep=y(Pp),g0=_(y(Ep),2),Np=_(y(g0),1,!0),Dd=_(Ep,6),Op=y(Dd),y0=_(y(Op)),zp=y(y0),m0=_(Op,4),_0=_(y(m0)),Bp=y(_0),Vp=_(Dd,2);nt(Vp,21,Q,ut,(it,X)=>{var ct=kdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt,2),Ee=y($t),Ne=_($t,2),Ke=y(Ne);U((ar,Cr,Er,Nr)=>{xe(ct,"data-active",ar),A(Ce,Cr),A(Ee,Er),A(Ke,Nr)},[()=>String(h(p(X),"template_id"))===String(h(at(),"template_id")),()=>String(h(p(X),"lane_id")??"lane"),()=>String(h(p(X),"title_ko")??h(p(X),"template_id")??"template"),()=>String(h(p(X),"status")??"DRAFT")]),cr("click",ct,()=>{W(o,String(h(p(X),"template_id")??"D3_D4_SIGNAL_QUALITY_AUDIT"),!0)}),F(it,ct)});var Fp=_(Vp,2),Md=y(Fp),Gp=y(Md),x0=y(Gp),Hp=_(Gp,2),Wp=y(Hp),Up=_(Hp,2),qp=y(Up),Ld=_(Up,2);nt(Ld,21,()=>v(h(at(),"assumption_tags")),ut,(it,X)=>{var ct=Ddt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var b0=_(Ld,4);nt(b0,17,()=>v(h(at(),"required_artifacts")),ut,(it,X)=>{var ct=Mdt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var w0=_(Md,2),S0=_(y(w0),2),$p=y(S0),T0=_(Fp,2),A0=y(T0),Yp=_(Pp,2),Zp=y(Yp),C0=_(y(Zp),2),k0=_(y(C0),1,!0),Rt=_(Zp,2),dt=_(y(Rt)),jt=y(dt),oe=_(dt,2),Sr=y(oe),Ar=_(Rt,2),Dr=y(Ar),Pr=_(y(Dr),2);nt(Pr,17,_t,ut,(it,X)=>{var ct=q(()=>Ki(p(X),2));let Dt=()=>p(ct)[0],Ce=()=>p(ct)[1];var $t=Ldt(),Ee=y($t),Ne=y(Ee),Ke=_(Ee),ar=y(Ke);U(Cr=>{A(Ne,Dt()),A(ar,Cr)},[()=>String(Ce())]),F(it,$t)});var Kr=_(Dr,2),Sa=_(y(Kr),2);nt(Sa,17,()=>v(h(p(e)?.signal_quality_audit_summary,"baseline_controls")),ut,(it,X)=>{var ct=Idt(),Dt=y(ct);U(()=>A(Dt,p(X))),F(it,ct)});var on=_(Sa,2),js=y(on),Ql=_(Kr,2),Ks=_(y(Ql),2);nt(Ks,17,Ot,ut,(it,X)=>{var ct=q(()=>Ki(p(X),2));let Dt=()=>p(ct)[0],Ce=()=>p(ct)[1];var $t=Rdt(),Ee=y($t),Ne=y(Ee),Ke=_(Ee),ar=y(Ke);U(Cr=>{A(Ne,Dt()),A(ar,Cr)},[()=>String(Ce())]),F(it,$t)});var Qs=_(Ar,2);nt(Qs,21,xt,ut,(it,X)=>{var ct=Pdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt,2),Ee=y($t),Ne=_($t,2),Ke=y(Ne),ar=_(Ne,2),Cr=y(ar),Er=y(Cr),Nr=_(Cr,2),sa=y(Nr);U((La,za,Ha,di,xi,io)=>{xe(ct,"data-tone",La),A(Ce,za),A(Ee,Ha),A(Ke,di),A(Er,xi),A(sa,io)},[()=>re(h(p(X),"status")),()=>String(h(p(X),"scenario_id")??"scenario"),()=>String(h(p(X),"diagnostic_focus")??"diagnostic"),()=>String(h(p(X),"hypothesis")??"—"),()=>String(h(p(X),"status")??"WATCH"),()=>String(h(p(X),"promotion_status")??"NO-GO_RESEARCH_ONLY")]),F(it,ct)});var Ov=_(Qs,2),Id=y(Ov),zv=_(Yp,2),Bv=y(zv),Rd=_(y(Bv),2),Pd=_(y(Rd),1,!0),Vv=_(Bv,2);nt(Vv,21,mt,ut,(it,X)=>{var ct=Edt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt,2),Ee=y($t),Ne=_($t,2),Ke=y(Ne),ar=_(Ne,2),Cr=y(ar),Er=_(ar,2),Nr=y(Er);U((sa,La,za,Ha,di,xi)=>{xe(ct,"data-tone",sa),A(Ce,La),A(Ee,za),ha(Ke,Ha),A(Cr,di),A(Nr,xi)},[()=>re(h(p(X),"status")),()=>String(h(p(X),"status")??"WATCH"),()=>String(h(p(X),"check")??"check"),()=>C(h(p(X),"completion_pct"),1),()=>S(h(p(X),"completion_pct")),()=>String(h(p(X),"evidence")??"—")]),F(it,ct)});var Ed=_(Vv,2),Nd=_(y(Ed),2),D0=y(Nd),Xp=_(zv,2),jp=y(Xp),M0=_(y(jp),2),L0=_(y(M0),1,!0),Kp=_(jp,2);nt(Kp,21,wt,ut,(it,X)=>{var ct=Ndt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt,2),Ee=y($t),Ne=_($t,2),Ke=_(y(Ne)),ar=_(Ne,2),Cr=_(y(ar)),Er=_(ar,2),Nr=_(y(Er)),sa=_(Er,2),La=y(sa);U((za,Ha,di,xi,io,ys,Jl,Od)=>{xe(ct,"data-tone",za),A(Ce,`P${Ha??""} · ${di??""}`),A(Ee,xi),A(Ke,` ${io??""}`),A(Cr,` ${ys??""}`),A(Nr,` ${Jl??""}`),A(La,Od)},[()=>re(h(p(X),"blocker_status")),()=>String(h(p(X),"priority")??"—"),()=>String(h(p(X),"id")??"IQ"),()=>String(h(p(X),"title_ko")??"개선 항목"),()=>String(h(p(X),"source_limitation")??"—"),()=>String(h(p(X),"next_action")??"—"),()=>String(h(p(X),"acceptance_gate")??"—"),()=>String(h(p(X),"blocker_status")??"WATCH")]),F(it,ct)});var I0=_(Kp,2),R0=y(I0),Qp=_(Xp,2),Jp=y(Qp),P0=_(y(Jp),2),E0=_(y(P0),1,!0),tg=_(Jp,2);nt(tg,20,()=>[["implementation_completion_pct","구현 완료율"],["page_maturity_pct","페이지 성숙도"],["scenario_platform_maturity_pct","시나리오 플랫폼"],["research_readiness_pct","연구 준비도"],["data_governance_maturity_pct","데이터 거버넌스"],["live_trading_readiness_pct","실거래 준비도"]],ut,(it,X)=>{var ct=q(()=>Ki(X,2));let Dt=()=>p(ct)[0],Ce=()=>p(ct)[1];var $t=Odt(),Ee=y($t),Ne=y(Ee),Ke=_(Ee,2),ar=y(Ke),Cr=_(Ke,2),Er=y(Cr);U((Nr,sa)=>{A(Ne,Ce()),A(ar,Nr),ha(Er,sa)},[()=>S(h(p(e)?.page_maturity_report,Dt())),()=>C(h(p(e)?.page_maturity_report,Dt()),1)]),F(it,$t)});var eg=_(tg,2),N0=y(eg),O0=_(y(N0));nt(O0,21,ft,ut,(it,X)=>{var ct=zdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt),Ee=y($t),Ne=_($t),Ke=y(Ne),ar=_(Ne),Cr=y(ar),Er=_(ar),Nr=y(Er);U((sa,La,za,Ha,di,xi)=>{or(ct,1,sa,"svelte-et5yd4"),A(Ce,La),A(Ee,za),A(Ke,Ha),A(Cr,di),A(Nr,xi)},[()=>cn(re(h(p(X),"status"))),()=>String(h(p(X),"priority")??"—"),()=>String(h(p(X),"feature")??"—"),()=>S(h(p(X),"completion_pct")),()=>String(h(p(X),"status")??"—"),()=>String(h(p(X),"evidence")??"—")]),F(it,ct)});var z0=_(eg,2),B0=y(z0),rg=_(Qp,2),ag=y(rg),V0=_(y(ag),2),F0=_(y(V0),1,!0),G0=_(ag,4),ig=y(G0),ng=y(ig),H0=y(ng),og=_(ng,2),W0=y(og),sg=_(og,2),lg=y(sg),U0=_(y(lg)),q0=y(U0),ug=_(lg,2),$0=_(y(ug)),Y0=y($0),vg=_(ug,2),Z0=_(y(vg)),X0=y(Z0),cg=_(vg,2),j0=_(y(cg)),WB=y(j0),kA=_(cg,2),UB=_(y(kA)),qB=y(UB),$B=_(kA,2),YB=_(y($B)),ZB=y(YB),XB=_(sg,2),jB=y(XB),DA=_(ig,2),MA=_(y(DA),2),LA=y(MA),KB=y(LA),IA=_(LA,2),QB=y(IA),JB=_(IA,2),RA=y(JB),tV=_(y(RA),2),eV=_(RA,8),rV=_(y(eV),2,!0),aV=_(MA,2),iV=_(y(aV),2);{var nV=it=>{var X=jo(),ct=lr(X);nt(ct,17,Y,ut,(Dt,Ce)=>{var $t=Bdt(),Ee=y($t),Ne=y(Ee),Ke=_(Ee,2),ar=y(Ke),Cr=_(Ke,2),Er=y(Cr);U((Nr,sa,La,za)=>{xe($t,"data-selected",Nr),A(Ne,sa),ha(ar,La),A(Er,za)},[()=>String(h(p(Ce),"action"))===String(h(O(),"executed")),()=>String(h(p(Ce),"action")??"—"),()=>T(h(p(Ce),"action_rate")),()=>k(h(p(Ce),"action_rate"))]),F(Dt,$t)}),F(it,X)},oV=q(()=>M(Y())),sV=it=>{var X=Vdt();F(it,X)};It(iV,it=>{p(oV)?it(nV):it(sV,-1)})}var lV=_(DA,2),PA=_(y(lV),2),uV=y(PA),EA=_(PA,2),vV=y(EA),cV=_(EA,2),NA=y(cV),dV=_(y(NA)),fV=y(dV),OA=_(NA,2),hV=_(y(OA)),pV=y(hV),zA=_(OA,2),gV=_(y(zA)),yV=y(gV),BA=_(zA,2),mV=_(y(BA)),_V=y(mV),VA=_(BA,2),xV=_(y(VA)),bV=y(xV),FA=_(VA,2),wV=_(y(FA)),SV=y(wV),TV=_(FA,2),AV=_(y(TV)),CV=y(AV),GA=_(rg,2),HA=y(GA),kV=_(y(HA),2),DV=_(y(kV),1,!0),WA=_(HA,2),MV=_(y(WA)),LV=y(MV),UA=_(WA,2);nt(UA,21,()=>[g(p(e)?.learning_performance,"policy"),g(p(e)?.learning_performance,"best_d3_baseline"),g(p(e)?.learning_performance,"delta_vs_best_d3")],ut,(it,X)=>{var ct=Fdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt,2),Ee=y($t),Ne=_($t,2),Ke=y(Ne),ar=y(Ke),Cr=_(Ne,2),Er=_(Cr,2),Nr=y(Er),sa=_(y(Nr)),La=y(sa),za=_(Nr,2),Ha=_(y(za)),di=y(Ha),xi=_(za,2),io=_(y(xi)),ys=y(io),Jl=_(xi,2),Od=_(y(Jl)),K0=y(Od);U((Q0,J0,t1,e1,r1,a1,i1,n1,o1)=>{xe(ct,"data-card-tone",Q0),A(Ce,J0),A(Ee,t1),A(ar,e1),ha(Cr,r1),A(La,a1),A(di,i1),A(ys,n1),A(K0,o1)},[()=>m(h(p(X),"total_return_pct"))!==null&&Number(h(p(X),"total_return_pct"))<0?"danger":"pass",()=>String(h(p(X),"split")??"—"),()=>String(h(p(X),"label")??"—"),()=>x(h(p(X),"total_return_pct")),()=>C(h(p(X),"total_return_pct"),2),()=>b(h(p(X),"simulated_profit_krw")),()=>b(h(p(X),"simulated_final_capital_krw")),()=>x(h(p(X),"max_drawdown_pct")),()=>x(h(p(X),"mean_turnover_pct"))]),F(it,ct)});var qA=_(UA,2),IV=y(qA),$A=_(qA,2),YA=y($A),RV=_(y(YA),4);nt(RV,17,()=>l(h(p(e)?.learning_performance,"learning_curve_preview")),ut,(it,X)=>{var ct=Gdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt,2),Ee=y($t),Ne=_($t,2),Ke=y(Ne);U((ar,Cr,Er,Nr)=>{xe(ct,"data-tone",ar),A(Ce,`EP ${Cr??""}`),ha(Ee,Er),A(Ke,Nr)},[()=>m(h(p(X),"total_reward"))!==null&&Number(h(p(X),"total_reward"))<0?"danger":"pass",()=>String(h(p(X),"episode")??"—"),()=>C(h(p(X),"final_equity"),100),()=>w(h(p(X),"total_reward"),3)]),F(it,ct)});var PV=_(YA,2),EV=_(y(PV),4);nt(EV,17,()=>l(h(p(e)?.learning_performance,"portfolio_nav_preview")),ut,(it,X)=>{var ct=Hdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt,2),Ee=y($t),Ne=_($t,2),Ke=y(Ne);U((ar,Cr,Er)=>{A(Ce,ar),ha(Ee,Cr),A(Ke,Er)},[()=>String(h(p(X),"date")??"—"),()=>C(h(p(X),"nav"),100),()=>b(h(p(X),"simulated_capital_krw"))]),F(it,ct)});var NV=_($A,2),OV=y(NV),ZA=_(GA,2),zV=_(y(ZA),2);nt(zV,21,()=>l(p(e)?.visual_flow),ut,(it,X,ct)=>{var Dt=Udt(),Ce=lr(Dt),$t=y(Ce),Ee=y($t),Ne=_($t,2),Ke=y(Ne),ar=_(Ne,2),Cr=y(ar),Er=_(ar,2),Nr=y(Er),sa=_(Ce,2);{var La=Ha=>{var di=Wdt();F(Ha,di)},za=q(()=>ct{p(za)&&Ha(La)})}U(Ha=>{xe(Ce,"data-tone",Ha),A(Ee,p(X).id),A(Ke,p(X).label),A(Cr,p(X).summary),A(Nr,p(X).status)},[()=>re(p(X).status)]),F(it,Dt)});var XA=_(ZA,2),BV=_(y(XA),2);nt(BV,21,()=>D(p(e)?.what_rl_means_here),ut,(it,X)=>{var ct=q(()=>Ki(p(X),2));let Dt=()=>p(ct)[0],Ce=()=>p(ct)[1];var $t=qdt(),Ee=y($t),Ne=y(Ee),Ke=_(Ee,2),ar=y(Ke);U(()=>{A(Ne,Dt()),A(ar,Ce())}),F(it,$t)});var jA=_(XA,2),VV=_(y(jA),2),KA=y(VV),QA=_(y(KA),2),FV=y(QA),GV=_(QA,2),HV=y(GV),JA=_(KA,2),WV=_(y(JA),2);nt(WV,17,()=>D(p(e)?.action_space),ut,(it,X)=>{var ct=q(()=>Ki(p(X),2));let Dt=()=>p(ct)[0],Ce=()=>p(ct)[1];var $t=$dt(),Ee=y($t);U(()=>A(Ee,`${Dt()??""}: ${Ce()??""}`)),F(it,$t)});var tC=_(JA,2),UV=_(y(tC),2);nt(UV,17,()=>D(p(e)?.action_mask),ut,(it,X)=>{var ct=q(()=>Ki(p(X),2));let Dt=()=>p(ct)[0],Ce=()=>p(ct)[1];var $t=Ydt(),Ee=y($t),Ne=y(Ee),Ke=_(Ee);U(()=>{A(Ne,Dt()),A(Ke,`: ${Ce()??""}`)}),F(it,$t)});var qV=_(tC,2),eC=_(y(qV),2),$V=y(eC),rC=_(eC,2),YV=y(rC),ZV=_(rC,2),XV=y(ZV),jV=_(jA,2),aC=_(y(jV),2),KV=y(aC),QV=_(y(KV));nt(QV,21,()=>l(p(e)?.well_built_checks),ut,(it,X)=>{var ct=Zdt(),Dt=y(ct),Ce=y(Dt),$t=_(Dt),Ee=y($t),Ne=_($t),Ke=y(Ne);U(ar=>{or(ct,1,ar,"svelte-et5yd4"),A(Ce,p(X).check),A(Ee,p(X).status),A(Ke,p(X).meaning_ko)},[()=>cn(re(p(X).status))]),F(it,ct)});var iC=_(aC,2),nC=y(iC),JV=_(y(nC)),t6=y(JV),e6=_(nC,2),r6=_(y(e6)),a6=y(r6),i6=_(iC,2),n6=y(i6);U((it,X,ct,Dt,Ce,$t,Ee,Ne,Ke,ar,Cr,Er,Nr,sa,La,za,Ha,di,xi,io,ys,Jl,Od,K0,Q0,J0,t1,e1,r1,a1,i1,n1,o1,o6,s6,l6,u6,v6,c6,d6,f6,h6,p6,g6,y6,m6,_6,x6,b6,w6,S6,T6,A6,C6,k6,D6,M6,L6,I6,R6,P6,E6,N6,O6,z6,B6,V6,F6,G6,H6,W6,U6,q6,$6,Y6,Z6,X6,j6,K6,Q6,J6,tF,eF,rF,aF,iF,nF,oF,sF,lF,uF,vF,cF,dF)=>{ue.disabled=p(a),A(Se,p(a)?"갱신 중…":"새로고침"),A(kt,p(e)?.maturity??"RESEARCH_ONLY_ENV_BUILT_NOT_PROFIT_READY"),A(ke,p(e)?.plain_language_verdict??"환경 상태를 불러오는 중입니다."),A(Zr,it),A(Zt,`${p(e)?.cost_round_trip_bp??23??""}bp`),A(Tt,X),A(Qt,p(e)?.status??"RESEARCH_ONLY"),A(Jt,ct),A(Ge,Dt),A(Me,Ce),A(kr,$t),A(ea,Ee),A(Xr,Ne),A(_a,Ke),A(vi,ar),A(qi,Cr),A(Wr,Er),A(nn,Nr),A(to,sa),A(Mo,La),A(Io,za),A(Ro,Ha),A($l,di),A(Xl,xi),A(Xt,`POST /api/daily-ohlcv/research-workflows/${io??""}/job-intents 는 + approval_ref, approval_ref_sha256, approval_status, idempotency_key를 검증한 뒤 immutable intent.json만 생성합니다.`),A(Re,ys),A(Pe,Jl),A(Oa,Od),A(wa,K0),A(gs,Q0),A(_d,J0),A(W_,t1),A(U_,e1),A(lp,r1),A(Y_,a1),A(Z_,i1),A(pp,` ${n1??""}`),A(Q_,` ${o1??""}`),A(J_,` ${o6??""}`),A(xp,s6),A(wp,l6),A(n0,u6),A(s0,`${v6??""}%`),A(l0,`${c6??""}%`),A(u0,`${d6??""}%`),A(c0,`${f6??""}%`),A(Np,h6),A(zp,p6),A(Bp,g6),A(x0,y6),A(Wp,m6),A(qp,_6),A($p,x6),A(A0,b6),A(k0,w6),A(jt,S6),A(Sr,T6),A(js,`cost sensitivity: ${A6??""}bp`),A(Id,C6),A(Pd,k6),A(D0,D6),A(L0,M6),A(R0,L6),A(E0,I6),A(B0,R6),A(F0,P6),A(H0,`State input · frame ${E6??""}`),A(W0,`${N6??""} · ${O6??""}`),A(q0,z6),A(Y0,B6),A(X0,V6),A(WB,F6),A(qB,G6),A(ZB,H6),A(jB,W6),A(KB,`policy_type: ${U6??""}`),A(QB,`policy_network_status: ${q6??""}`),A(tV,`${$6??""}:${Y6??""}`),A(rV,Z6),A(uV,X6),A(vV,`requested: ${j6??""}`),A(fV,K6),A(pV,Q6),A(yV,J6),A(_V,tF),A(bV,eF),A(SV,rF),A(CV,aF),A(DV,iF),A(LV,`가정 원금 ${nF??""}`),A(IV,oF),A(OV,sF),A(FV,`fields: ${lF??""}`),A(HV,uF),A($V,p(e)?.reward_formula??"net_return_after_cost - penalties"),A(YV,`components: ${vF??""}`),A(XV,`fill: ${p(e)?.fill_assumption??"daily research label only"??""}`),A(t6,cF),A(a6,dF),A(n6,p(e)?.guardrail??"no profit guarantee, no live/broker/orders")},[()=>d(p(e)?.environment_built),()=>u(p(e)?.state_contract?.shape),()=>String(h(p(e)?.research_process_catalog,"status")??"RESEARCH_ONLY_PROCESS_GUIDE"),()=>String(h(p(e)?.research_process_catalog,"selector_help_ko")??"연구 lane을 선택하면 환경/상태/행동/보상/한계/개선 방향을 같은 포맷으로 확인합니다."),()=>String(h(I(),"id")??"MISSING_PROCESS_LANE"),()=>String(h(I(),"title_ko")??"연구 프로세스"),()=>String(h(I(),"goal_ko")??"선택된 연구 lane을 불러오는 중입니다."),Z,()=>String(h(p(e)?.research_workflow_catalog,"job_intent_mode")??"APPROVAL_GATED_INTENT_RECORD_ONLY"),()=>String(h(p(e)?.research_workflow_catalog,"workflow_count")??"—"),()=>S(h(p(e)?.research_workflow_catalog,"completion_pct")),()=>d(h(p(e)?.research_workflow_catalog,"execution_allowed_from_browser")),()=>String(h(Kt(),"workflow_id")??"MISSING_WORKFLOW"),()=>String(h(Kt(),"title_ko")??"Research workflow"),()=>String(h(Kt(),"next_allowed_action")??"승인된 연구 의도 기록 전 blocker와 artifact를 확인합니다."),()=>String(h(Kt(),"approval_status")??"APPROVAL_REQUIRED"),()=>String(h(Kt(),"trigger_status")??"INTENT_ONLY_NOT_EXECUTED_BY_BROWSER"),()=>String(h(Kt(),"guardrail")??"research-only intent surface"),()=>c({workflow_id:h(Kt(),"workflow_id"),default_cost_bp:h(Kt(),"default_cost_bp")??23,cost_sensitivity_bp:h(Kt(),"cost_sensitivity_bp")??[0,23,46],approval_required:h(Kt(),"approval_required")??!0,execution_allowed_from_browser:h(Kt(),"execution_allowed_from_browser")??!1,forbidden_fields:h(p(e)?.research_workflow_catalog,"forbidden_fields")}),()=>String(h(Kt(),"workflow_id")??"{workflow_id}"),()=>c({schema_version:"daily_ohlcv_research_job_intent.v1",approval_status:"APPROVED_FOR_RESEARCH_INTENT",idempotency_key:"safe-operator-key",config:{workflow_id:h(Kt(),"workflow_id"),default_cost_bp:23,cost_sensitivity_bp:[0,23,46],controls:["no_trade","shuffle_control","frozen_d3_baseline"]}}),()=>String(h(p(e)?.research_job_intent_ledger,"guardrail")??"Intent ledger records approval-gated research requests only."),()=>String(h(p(e)?.research_job_intent_ledger,"status")??"EMPTY"),()=>String(h(p(e)?.research_job_intent_ledger,"count")??0),()=>d(h(p(e)?.research_job_intent_ledger,"execution_allowed_from_browser")),()=>String(h(p(e)?.research_workflow_catalog,"guardrail")??"workflow catalog is read-only"),()=>String(h(p(e)?.rejection_analytics,"status")??"MISSING_REJECTION_AUDIT_ARTIFACTS"),()=>String(h(p(e)?.rejection_analytics,"run_id")??"—"),()=>String(h(h(p(e)?.rejection_analytics,"summary"),"rejected_total")??0),()=>String(h(h(p(e)?.rejection_analytics,"summary"),"early_dropout_total")??0),()=>d(h(p(e)?.rejection_analytics,"promotion_allowed")),()=>String(h(p(e)?.rejection_analytics,"denominator_policy")??"fail closed"),()=>String(h(p(e)?.rejection_analytics,"timing_policy")??"decision-time only"),()=>String(h(p(e)?.rejection_analytics,"independent_evidence_policy")??"later independent evidence only"),()=>String(h(p(e)?.rejection_analytics,"guardrail")??"No NO-GO reversal; new preregistration required."),()=>String(h(p(e)?.dashboard_first_completion_report,"status")??"NON_LIVE_RESEARCH_PLATFORM_INCOMPLETE"),()=>String(h(p(e)?.dashboard_first_completion_report,"guardrail")??"Non-live research/dashboard completion can be 100%; live/model/paper readiness remains 0%."),()=>String(h(p(e)?.dashboard_first_completion_report,"non_live_goal_completion_pct")??0),()=>String(h(p(e)?.dashboard_first_completion_report,"live_trading_readiness_pct")??0),()=>String(h(p(e)?.dashboard_first_completion_report,"model_build_readiness_pct")??0),()=>String(h(p(e)?.dashboard_first_completion_report,"paper_forward_readiness_pct")??0),()=>String(h(p(e)?.scenario_generator,"status")??"READ_ONLY_DRAFT_GENERATOR"),()=>String(h(p(e)?.scenario_generator,"template_count")??"—"),()=>d(h(p(e)?.scenario_generator,"execution_allowed")),()=>String(h(at(),"template_id")??"D3_D4_SIGNAL_QUALITY_AUDIT"),()=>String(h(at(),"title_ko")??"시나리오 초안"),()=>String(h(at(),"hypothesis_ko")??"가설을 불러오는 중입니다."),et,()=>String(h(p(e)?.scenario_generator,"guardrail")??"read-only scenario generator"),()=>String(h(p(e)?.signal_quality_audit_summary,"promotion_status")??"LOADING_OR_MISSING_SIGNAL_QUALITY_AUDIT"),()=>String(h(p(e)?.signal_quality_audit_summary,"run_id")??"MISSING_SIGNAL_QUALITY_AUDIT"),()=>String(h(p(e)?.signal_quality_audit_summary,"result_verdict")??"LOADING_OR_MISSING_SIGNAL_QUALITY_AUDIT"),()=>u(h(p(e)?.signal_quality_audit_summary,"cost_sensitivity_bp")),()=>String(h(p(e)?.scenario_comparison,"guardrail")??"WATCH/NO-GO diagnostics only"),()=>S(h(p(e)?.market_regime_audit_readiness,"maturity_score_pct")),()=>c(h(p(e)?.market_regime_audit_readiness,"ai_guidance_format")),()=>String(h(p(e)?.improvement_queue,"status")??"AI_READABLE_QUEUE_AVAILABLE"),()=>c(h(p(e)?.improvement_queue,"ai_guidance_format")),()=>String(h(p(e)?.page_maturity_report,"status")??"FEATURES_IMPLEMENTED_RESEARCH_GATES_BLOCKED"),()=>String(h(p(e)?.page_maturity_report,"guardrail")??"page maturity is not trading readiness"),()=>String(h(p(e)?.active_replay,"status")??"MISSING_REPLAY_ARTIFACT"),()=>String(h(E(),"frame")??"—"),()=>String(h(E(),"date")??"MISSING_REPLAY_ARTIFACT"),()=>String(h(E(),"split")??"split"),()=>String(h(N(),"position_count")??"—"),()=>String(h(N(),"top_score_bucket")??"—"),()=>w(h(N(),"cash_fraction"),2),()=>w(h(N(),"exposure_fraction"),2),()=>String(h(N(),"top_candidate_code")??"—"),()=>String(h(N(),"future_label_exposed")??"UNKNOWN"),()=>String(h(p(e)?.active_replay,"policy_representation_ko")??"정책 표현 증거를 불러오는 중입니다."),()=>String(h(p(e)?.active_replay,"policy_type")??"tabular_q"),()=>String(h(p(e)?.active_replay,"policy_network_status")??"MISSING_POLICY_ARTIFACT"),()=>String(h(N(),"position_count")??"—"),()=>String(h(N(),"top_score_bucket")??"—"),()=>String(h(O(),"executed")??"hold"),()=>String(h(O(),"executed")??"hold"),()=>String(h(O(),"requested")??"—"),()=>String(h(O(),"mask")??"—"),()=>w(h(V(),"reward"),4),()=>w(h(V(),"net_return_after_cost"),4),()=>w(h(V(),"turnover_cost"),4),()=>w(h(V(),"drawdown_penalty"),4),()=>w(h(B(),"total_reward"),3),()=>w(h(z(),"policy_nav"),4),()=>String(h(p(e)?.learning_performance,"status")??"RESEARCH_ONLY_PERFORMANCE_DIAGNOSTIC"),()=>b(h(p(e)?.learning_performance,"display_capital_krw")??1e7),()=>String(h(p(e)?.learning_performance,"interpretation_ko")??"성과 데이터를 불러오는 중입니다."),()=>String(h(p(e)?.learning_performance,"guardrail")??"no profit guarantee, no live/broker/orders"),()=>u(p(e)?.state_contract?.fields),()=>String(p(e)?.state_contract?.lookahead_policy??"future_return_1d는 관측에 넣지 않습니다."),()=>u(p(e)?.reward_components),()=>u(p(e)?.good_enough_for),()=>u(p(e)?.not_good_enough_for)]),cr("click",ue,()=>void ye()),F(r,se),Yr()}an(["click"]);var Kdt=G('
                아티팩트 상태를 불러오는 중...
                '),Qdt=G('활성'),Jdt=G('대기'),tft=G(" "),eft=G('
                '),rft=G(' '),aft=G('
                '),ift=G('
                '),nft=G('
                사전학습 weight 파일 없음
                '),oft=G('완료'),sft=G('학습 중'),lft=G('미시작'),uft=G('완료 예측기 학습이 완료되었습니다',1),vft=G('진행 예측기 학습 중',1),cft=G('대기 Tokenizer 100% 도달 시 자동 시작',1),dft=G('
                아직 생성된 checkpoint 파일이 없습니다. tokenizer 또는 predictor 진행 시 자동으로 표시됩니다.
                '),fft=G(' '),hft=G(' '),pft=G('최신'),gft=G('
                '),yft=G('
                사전학습 weight 파일이 없습니다.
                '),mft=G(' '),_ft=G('
                read-only
                '),xft=G('
                CHECKPOINTS · Tokenizer
                학습 중 누적
                MODEL WEIGHTS · 사전학습
                로컬 baseline
                read-only
                finetune 시작점 · 학습 결과와 무관
                PREDICTOR · 결과물
                0

                Predictor 단계가 시작되면 약 4.7M step에 걸쳐 모델 weight 와 평가 metrics 가 이 영역에 누적됩니다.

                ',1),bft=G(`
                공식 기능 /api/training/artifacts · read-only

                아티팩트 & 모델

                현재 학습 단계에서 생성된 checkpoint와 사전학습 모델 weight 파일을 표시합니다. + Predictor 단계 시작 전에는 tokenizer checkpoint만 누적되며 모든 표시는 읽기 전용입니다.

                `,1);function wft(r,t){$r(t,!0);let e=rt(null);HE.subscribe(w=>W(e,w,!0));let a=q(()=>p(e)!=null),i=q(()=>p(e)?.checkpoint_file_count??0),n=q(()=>p(e)?.model_weight_file_count??0),o=q(()=>!!p(e)?.checkpoint_ready),s=q(()=>!!p(e)?.predictor_started),l=q(()=>!!p(e)?.stages?.predictor?.checkpoint_ready),u=q(()=>Array.isArray(p(e)?.recent_checkpoint_files)?p(e).recent_checkpoint_files:[]),v=q(()=>Array.isArray(p(e)?.recent_model_weight_files)?p(e).recent_model_weight_files:[]);function c(w){return typeof w=="string"?w:w?.path??w?.name??"-"}function d(w){return typeof w=="object"&&w?.size_mib!=null?Ft.bytes(w.size_mib):typeof w=="object"&&w?.size!=null?Ft.bytes(w.size/(1024*1024)):null}function f(w){return typeof w=="object"&&w?.modified?Ft.relative(w.modified):typeof w=="object"&&w?.mtime?Ft.relative(w.mtime):null}let h=rt("ckpt");var g=bft(),m=_(lr(g),2);{var x=w=>{var S=Kdt();F(w,S)},b=w=>{var S=xft(),C=lr(S),T=y(C);let k;var D=y(T),M=_(y(D),2);{var L=j=>{var ot=Qdt();F(j,ot)},I=j=>{var ot=Jdt();F(j,ot)};It(M,j=>{p(o)?j(L):j(I,-1)})}var P=_(D,2),E=y(P),N=y(E),O=_(P,2);{var V=j=>{var ot=eft();nt(ot,21,()=>p(u).slice(0,5),ut,(Tt,ce,ae)=>{var Qt=tft();or(Qt,1,`pill ${ae===0?"accent":""}`);var ie=y(Qt);U($e=>A(ie,$e),[()=>c(p(ce)).split(/[/\\]/).pop()]),F(Tt,Qt)}),F(j,ot)};It(O,j=>{p(u).length>0&&j(V)})}var B=_(O,2),z=y(B),H=_(T,2),Y=_(y(H),2),$=y(Y),Z=y($),Q=_(Y,2);{var at=j=>{var ot=ift();nt(ot,21,()=>p(v).slice(0,3),ut,(Tt,ce)=>{const ae=q(()=>d(p(ce)));var Qt=aft(),ie=y(Qt),$e=y(ie),ze=_(ie,2);{var De=Bt=>{var Jt=rft(),fe=y(Jt);U(()=>A(fe,p(ae))),F(Bt,Jt)};It(ze,Bt=>{p(ae)&&Bt(De)})}U(Bt=>A($e,Bt),[()=>c(p(ce)).split(/[/\\]/).pop()]),F(Tt,Qt)}),F(j,ot)},et=j=>{var ot=nft();F(j,ot)};It(Q,j=>{p(v).length>0?j(at):j(et,-1)})}var _t=_(H,2),Ot=y(_t),xt=y(Ot),mt=_(y(xt),2),wt=y(mt),ft=_(xt,2);{var K=j=>{var ot=oft();F(j,ot)},Ct=j=>{var ot=sft();F(j,ot)},Mt=j=>{var ot=lft();F(j,ot)};It(ft,j=>{p(l)?j(K):p(s)?j(Ct,1):j(Mt,-1)})}var zt=_(Ot,2),Yt=y(zt),we=_(zt,2),Kt=y(we),qe=y(Kt);{var fr=j=>{var ot=uft();F(j,ot)},re=j=>{var ot=vft();F(j,ot)},ge=j=>{var ot=cft();F(j,ot)};It(qe,j=>{p(l)?j(fr):p(s)?j(re,1):j(ge,-1)})}var ye=_(C,2),se=y(ye),St=y(se),Et=_(St,2),ue=_(se,2),Se=y(ue),me=_(ye,2),Xe=y(me),br=y(Xe),kt=y(br),_e=y(kt),ke=_(kt,2),Tr=y(ke),Oe=_(br,2),Hr=y(Oe),Zr=_(Xe,2),da=y(Zr);{var ua=j=>{var ot=jo(),Tt=lr(ot);{var ce=Qt=>{var ie=dft();F(Qt,ie)},ae=Qt=>{var ie=jo(),$e=lr(ie);nt($e,17,()=>p(u),ut,(ze,De,Bt)=>{const Jt=q(()=>d(p(De))),fe=q(()=>f(p(De))),Ge=q(()=>c(p(De)));var Ve=gft(),je=y(Ve),tr=y(je);Wn(tr,()=>Ko.package,!0);var yr=_(je,2),Me=y(yr),mr=y(Me),kr=_(Me,2),zr=y(kr),ea=_(yr,2),vt=y(ea);{var bt=te=>{var ne=fft(),Te=y(ne);U(()=>A(Te,p(Jt))),F(te,ne)};It(vt,te=>{p(Jt)&&te(bt)})}var Gt=_(vt,2);{var he=te=>{var ne=hft(),Te=y(ne);U(()=>A(Te,p(fe))),F(te,ne)};It(Gt,te=>{p(fe)&&te(he)})}var Ye=_(Gt,2);{var He=te=>{var ne=pft();F(te,ne)};It(Ye,te=>{Bt===0&&te(He)})}U(te=>{A(mr,te),A(zr,p(Ge))},[()=>p(Ge).split(/[/\\]/).pop()]),F(ze,Ve)}),F(Qt,ie)};It(Tt,Qt=>{p(u).length===0?Qt(ce):Qt(ae,-1)})}F(j,ot)},Zt=j=>{var ot=jo(),Tt=lr(ot);{var ce=Qt=>{var ie=yft();F(Qt,ie)},ae=Qt=>{var ie=jo(),$e=lr(ie);nt($e,17,()=>p(v),ut,(ze,De)=>{const Bt=q(()=>d(p(De))),Jt=q(()=>c(p(De)));var fe=_ft(),Ge=y(fe),Ve=y(Ge);Wn(Ve,()=>Ko.chip,!0);var je=_(Ge,2),tr=y(je),yr=y(tr),Me=_(tr,2),mr=y(Me),kr=_(je,2),zr=y(kr);{var ea=vt=>{var bt=mft(),Gt=y(bt);U(()=>A(Gt,p(Bt))),F(vt,bt)};It(zr,vt=>{p(Bt)&&vt(ea)})}U(vt=>{A(yr,vt),A(mr,p(Jt))},[()=>p(Jt).split(/[/\\]/).pop()]),F(ze,fe)}),F(Qt,ie)};It(Tt,Qt=>{p(v).length===0?Qt(ce):Qt(ae,-1)})}F(j,ot)};It(da,j=>{p(h)==="ckpt"?j(ua):j(Zt,-1)})}U(()=>{k=or(T,1,"card",null,k,{glow:p(o)}),ha(E,`font-size:56px;letter-spacing:-0.03em;color:${p(i)>0?"var(--fg-strong)":"var(--dim)"};line-height:1`),A(N,p(i)),A(z,p(e)?.message??"checkpoint 자동 저장 정책 적용 중"),ha($,`font-size:56px;letter-spacing:-0.03em;color:${p(n)>0?"var(--fg-strong)":"var(--dim)"};line-height:1`),A(Z,p(n)),A(wt,p(l)?"완료":p(s)?"학습 중":"대기 중"),ha(Yt,`font-size:56px;letter-spacing:-0.03em;color:${p(s)?"var(--fg-strong)":"var(--dim)"};line-height:1`),xe(St,"data-active",p(h)==="ckpt"?"true":"false"),xe(Et,"data-active",p(h)==="weight"?"true":"false"),A(Se,`총 ${(p(h)==="ckpt"?p(u).length:p(v).length)??""}개 표시 · 정렬 최신순`),A(_e,`${p(h)==="ckpt"?"RECENT_CHECKPOINT_FILES":"RECENT_MODEL_WEIGHT_FILES"} (${(p(h)==="ckpt"?p(u).length:p(v).length)??""})`),A(Tr,p(h)==="ckpt"?"Tokenizer/Predictor step별 체크포인트":"사전학습 weight 파일"),A(Hr,p(h)==="ckpt"?"정렬 · 최신순":"read-only · 학습 시작점")}),cr("click",St,()=>W(h,"ckpt")),cr("click",Et,()=>W(h,"weight")),F(w,S)};It(m,w=>{p(a)?w(b,-1):w(x)})}F(r,g),Yr()}an(["click"]);var Sft=G('
                run 목록을 불러오는 중...
                '),Tft=G('
                불러오기 실패
                '),Aft=G('
                '),Cft=G('
                RUN
                전체 진행률
                stages
                '),kft=G('
                '),Dft=G('
                정식 /api/training/runs · 60초 폴링

                기록 & 런

                finetune/outputs 디렉터리의 모든 학습 run 목록. 상태별 필터·정렬을 지원하며, 실시간 학습 탭의 손실 곡선은 에서 현재 진행 중인 run 만 표시합니다.

                총 RUN
                finetune/outputs 스캔 결과
                완료
                overall_percent = 100% + status=completed
                실패
                OOM / interrupted 등
                진행 중
                live 폴링 대상
                ',1);function Mft(r,t){$r(t,!0);let e=rt(Fr([])),a=rt(!1),i=rt(!1),n=rt(null),o=rt("all"),s=rt("recent");async function l(){if(!p(i)){W(i,!0),W(n,null);try{const St=await fetch("/api/training/runs");if(!St.ok){W(n,`HTTP ${St.status}`);return}const Et=await St.json();W(e,Array.isArray(Et.runs)?Et.runs:[],!0),W(a,!0)}catch(St){W(n,St?.message??"네트워크 오류",!0)}finally{W(i,!1)}}}mn(()=>{l()});let u;Ym(()=>(u!=null&&clearInterval(u),u=window.setInterval(l,6e4),()=>{u!=null&&clearInterval(u)}));let v=q(()=>{let St=p(e).slice();return p(o)!=="all"&&(St=St.filter(Et=>p(o)==="completed"?Et.status==="completed"||Et.status==="complete"||Et.status==="success":p(o)==="failed"?Et.status==="failed"||Et.status==="error":p(o)==="running"?Et.status==="running"||Et.status==="active":!0)),p(s)==="name"?St.sort((Et,ue)=>Et.name.localeCompare(ue.name)):p(s)==="progress"?St.sort((Et,ue)=>(ue.overall_percent??0)-(Et.overall_percent??0)):St.sort((Et,ue)=>(ue.updated_at_epoch??0)-(Et.updated_at_epoch??0)),St}),c=q(()=>{let St=0,Et=0,ue=0;for(const Se of p(e)){const me=Se.status;me==="completed"||me==="complete"||me==="success"?St++:me==="failed"||me==="error"?Et++:(me==="running"||me==="active")&&ue++}return{total:p(e).length,completed:St,failed:Et,running:ue}});function d(St){return St==="completed"||St==="complete"||St==="success"?"success":St==="failed"||St==="error"?"danger":St==="running"||St==="active"?"accent":""}function f(St){return St==="completed"||St==="complete"||St==="success"?"완료":St==="failed"||St==="error"?"실패":St==="running"||St==="active"?"진행 중":St||"미상"}var h=Dft(),g=lr(h),m=_(y(g),4),x=_(y(m)),b=_(g,2),w=y(b),S=_(y(w),2),C=y(S),T=_(w,2),k=y(T),D=_(y(k),2),M=_(y(D),1,!0),L=_(k,2),I=y(L),P=_(T,2),E=y(P),N=_(y(E),2),O=_(y(N),1,!0),V=_(E,2),B=y(V),z=_(P,2),H=y(z),Y=_(y(H),2),$=_(y(Y),1,!0),Z=_(H,2),Q=y(Z),at=_(b,2),et=y(at),_t=y(et),Ot=y(_t),xt=_(_t,2),mt=y(xt),wt=_(xt,2),ft=y(wt),K=_(wt,2),Ct=y(K),Mt=_(et,2),zt=y(Mt),Yt=_(zt,2),we=_(Yt,2),Kt=_(Mt,2),qe=y(Kt),fr=_(at,2);{var re=St=>{var Et=Sft();F(St,Et)},ge=St=>{var Et=Tft(),ue=y(Et),Se=_(y(ue),2),me=_(y(Se),1,!0),Xe=_(ue,2);U(()=>A(me,p(n))),cr("click",Xe,l),F(St,Et)},ye=St=>{var Et=Aft(),ue=y(Et),Se=y(ue);U(()=>A(Se,p(o)==="all"?"run 이 없습니다":`필터 "${p(o)}" 조건에 맞는 run 이 없습니다`)),F(St,Et)},se=St=>{var Et=kft();nt(Et,21,()=>p(v),ut,(ue,Se)=>{var me=Cft(),Xe=y(me),br=y(Xe),kt=_(y(br),2),_e=y(kt),ke=_(br,2),Tr=_(y(ke),1,!0),Oe=_(Xe,2),Hr=y(Oe),Zr=_(y(Hr),2),da=y(Zr),ua=_(Hr,2),Zt=y(ua);let j;var ot=_(Oe,2),Tt=y(ot),ce=_(y(Tt)),ae=y(ce),Qt=_(Tt,2),ie=y(Qt),$e=_(ot,2),ze=y($e);U((De,Bt,Jt,fe,Ge)=>{xe(me,"data-status",p(Se).status),A(_e,p(Se).name),or(ke,1,`pill ${De??""}`,"svelte-xhq32r"),A(Tr,Bt),A(da,Jt),xe(Zt,"data-status",fe),j=ha(Zt,"",j,{width:`${p(Se).overall_percent}%`}),A(ae,p(Se).stage_count),A(ie,Ge),A(ze,p(Se).path)},[()=>d(p(Se).status),()=>f(p(Se).status),()=>Ft.pct(p(Se).overall_percent,1),()=>d(p(Se).status),()=>Ft.relative(p(Se).updated_at)]),F(ue,me)}),F(St,Et)};It(fr,St=>{!p(a)&&p(i)?St(re):p(n)?St(ge,1):p(v).length===0?St(ye,2):St(se,-1)})}U(()=>{A(C,p(c).total),A(M,p(c).completed),A(I,p(c).completed),A(O,p(c).failed),A(B,p(c).failed),A($,p(c).running),A(Q,p(c).running),xe(_t,"data-active",p(o)==="all"?"true":"false"),A(Ot,`전체 (${p(c).total??""})`),xe(xt,"data-active",p(o)==="completed"?"true":"false"),A(mt,`완료 (${p(c).completed??""})`),xe(wt,"data-active",p(o)==="failed"?"true":"false"),A(ft,`실패 (${p(c).failed??""})`),xe(K,"data-active",p(o)==="running"?"true":"false"),A(Ct,`진행 중 (${p(c).running??""})`),xe(zt,"data-active",p(s)==="recent"?"true":"false"),xe(Yt,"data-active",p(s)==="name"?"true":"false"),xe(we,"data-active",p(s)==="progress"?"true":"false"),Kt.disabled=p(i),A(qe,p(i)?"갱신 중…":"새로고침")}),cr("click",x,()=>Wc.set("live-training")),cr("click",_t,()=>W(o,"all")),cr("click",xt,()=>W(o,"completed")),cr("click",wt,()=>W(o,"failed")),cr("click",K,()=>W(o,"running")),cr("click",zt,()=>W(s,"recent")),cr("click",Yt,()=>W(s,"name")),cr("click",we,()=>W(s,"progress")),cr("click",Kt,l),F(r,h),Yr()}an(["click"]);var Lft=G(' '),Ift=G(' '),Rft=G(' '),Pft=G(' '),Eft=G('
                nvidia-smi 가 현재 환경에서 power draw 를 보고하지 않습니다. 추정값은 표시하지 않습니다.
                '),Nft=G('
                공식 기능 /api/training/gpu · /api/training/system · 5초 폴링

                시스템 상태

                학습 호스트의 GPU·CPU·RAM과 Flask 폴링 상태입니다. GPU는 nvidia-smi, CPU/RAM은 시스템 endpoint에서 읽으며 온도는 OS 센서가 노출될 때만 표시합니다.

                GPU 활용률
                %
                온도
                °C
                VRAM
                CPU / RAM
                %
                /api/training/gpu · 시계열
                util % VRAM % 온도 °C
                평균 활용률
                최고 온도
                평균 VRAM
                전력 한계
                GPU · 디바이스
                하드웨어 상세
                OK
                모델명
                디바이스 수
                VRAM 총량
                VRAM 사용
                전력 한계
                실측 전력
                API 갱신 (KST)
                FLASK · :5070
                대시보드 폴링 상태
                up
                폴링 간격
                GPU ring buffer
                CPU/RAM endpoint/api/training/system
                버퍼 시간
                마지막 화면 갱신
                Artifacts 폴링30 초 (고정)
                대시보드 모드official dist (default)
                CPU/RAM 활성디스크 측정은 후속 단계

                CPU 사용률과 RAM 사용률은 현재 노출됩니다. CPU 온도는 Windows/메인보드 센서가 OS에 값을 제공하지 않으면 미측정으로 표시합니다.

                ',1);function Oft(r,t){$r(t,!0);let e=rt(null);oS.subscribe(Ut=>W(e,Ut,!0));let a=rt(null);jm.subscribe(Ut=>W(a,Ut,!0));let i=rt(Fr([]));sS.subscribe(Ut=>W(i,Ut,!0));let n=rt(5);tv.subscribe(Ut=>W(n,Ut,!0));let o=rt("-");Km.subscribe(Ut=>W(o,Ut,!0));let s=rt("light");mo.subscribe(Ut=>W(s,Ut,!0));let l=q(()=>p(e)?.gpus?.[0]),u=q(()=>p(a)?.cpu),v=q(()=>p(a)?.memory),c=q(()=>p(l)?.utilization_gpu_percent),d=q(()=>p(l)?.temperature_c),f=q(()=>p(l)?.memory_used_percent),h=q(()=>p(u)?.utilization_percent),g=q(()=>p(u)?.temperature_c),m=q(()=>p(u)?.temperature_percent),x=q(()=>p(v)?.used_percent);function b(){return p(l)?{util:p(l).utilization_gpu_percent??null,temp:p(l).temperature_c??null,vram:p(l).memory_used_percent??null,ts:Date.now()}:null}function w(Ut){return Ut?new Date(Ut).toLocaleTimeString("ko-KR",{timeZone:"Asia/Seoul",hour:"2-digit",minute:"2-digit",second:"2-digit",hour12:!1}):"—"}function S(Ut,Ir){const Wr=Array.isArray(Ut)?Ut:[Ut],va=Wr[0]?.dataIndex??0,ja=Ir[va],Li=Wr.map(ci=>{const nn=ci.value;if(nn==null||Number.isNaN(Number(nn)))return"";const _n=ci.seriesName?.includes("온도")?"°C":"%";return`
                + ${ci.marker??""}${ci.seriesName} + ${Number(nn).toFixed(1)}${_n} +
                `}).filter(Boolean).join("");return`
                ${w(ja?.ts)}
                ${Li}`}let C=q(()=>{if(p(i).length>0)return p(i);const Ut=b();return Ut?[Ut]:[]}),T=q(()=>{if(p(i).length===0)return{utilAvg:null,tempMax:null,vramAvg:null};const Ut=p(i).map(va=>va.util).filter(va=>va!=null),Ir=p(i).map(va=>va.temp).filter(va=>va!=null),Wr=p(i).map(va=>va.vram).filter(va=>va!=null);return{utilAvg:Ut.length?Ut.reduce((va,ja)=>va+ja,0)/Ut.length:null,tempMax:Ir.length?Math.max(...Ir):null,vramAvg:Wr.length?Wr.reduce((va,ja)=>va+ja,0)/Wr.length:null}}),k=q(()=>p(c)==null?"":p(c)>=90?"warn":"success"),D=q(()=>p(d)==null?"":p(d)>=80?"danger":p(d)>=70?"warn":"success"),M=q(()=>p(f)==null?"":p(f)>=90?"warn":"success"),L=q(()=>p(h)==null?"":p(h)>=95?"warn":"success"),I=q(()=>{if(p(s),typeof window>"u")return null;const Ut=getComputedStyle(document.documentElement);return{c2:Ut.getPropertyValue("--c-2").trim(),c3:Ut.getPropertyValue("--c-3").trim(),c4:Ut.getPropertyValue("--c-4").trim(),grid:Ut.getPropertyValue("--border-faint").trim(),border:Ut.getPropertyValue("--border").trim(),text:Ut.getPropertyValue("--fg").trim(),textDim:Ut.getPropertyValue("--dim").trim(),surface:Ut.getPropertyValue("--surface").trim()}}),P=q(()=>{if(p(s),!p(I))return{};const Ut=p(C),Ir=Ut.length<=2;return{backgroundColor:"transparent",textStyle:{color:p(I).text,fontFamily:"Pretendard Variable, sans-serif"},grid:{left:48,right:24,top:20,bottom:32},xAxis:{type:"category",data:Ut.map(Wr=>w(Wr.ts)),axisLabel:{show:!1},axisLine:{lineStyle:{color:p(I).border}},axisTick:{show:!1}},yAxis:{type:"value",min:0,max:100,axisLabel:{color:p(I).textDim,fontSize:10,formatter:"{value}%"},splitLine:{lineStyle:{color:p(I).grid}},axisLine:{show:!1}},tooltip:{trigger:"axis",appendToBody:!0,confine:!0,axisPointer:{type:"line",lineStyle:{color:p(I).border,type:"dashed"}},backgroundColor:p(I).surface,borderColor:p(I).border,textStyle:{color:p(I).text,fontSize:12},formatter:Wr=>S(Wr,Ut)},graphic:Ut.length===0?[{type:"text",left:"center",top:"middle",style:{text:"GPU 데이터 수신 대기 중…",fill:p(I).textDim,fontSize:12,fontFamily:"Pretendard Variable, sans-serif"}}]:[],series:[{name:"GPU util %",type:"line",smooth:.5,symbol:"circle",showSymbol:Ir,symbolSize:5,data:Ut.map(Wr=>Wr.util),lineStyle:{color:p(I).c3,width:1.8}},{name:"VRAM %",type:"line",smooth:.5,symbol:"circle",showSymbol:Ir,symbolSize:5,data:Ut.map(Wr=>Wr.vram),lineStyle:{color:p(I).c2,width:1.8}},{name:"온도 °C",type:"line",smooth:.5,symbol:"circle",showSymbol:Ir,symbolSize:5,data:Ut.map(Wr=>Wr.temp),lineStyle:{color:p(I).c4,width:1.8}}]}});var E=Nft(),N=_(lr(E),2),O=y(N);let V;var B=y(O),z=_(y(B),2);{var H=Ut=>{var Ir=Lft(),Wr=_(y(Ir));U(()=>{or(Ir,1,`pill ${p(k)??""}`,"svelte-1upeacm"),A(Wr,` ${p(k)==="warn"?"높음":"정상"}`)}),F(Ut,Ir)};It(z,Ut=>{p(k)&&Ut(H)})}var Y=_(B,2),$=y(Y),Z=_(Y,2),Q=y(Z),at=_(O,2),et=y(at),_t=_(y(et),2);{var Ot=Ut=>{var Ir=Ift(),Wr=_(y(Ir));U(()=>{or(Ir,1,`pill ${p(D)??""}`,"svelte-1upeacm"),A(Wr,` ${p(D)==="danger"?"경고":p(D)==="warn"?"주의":"정상"}`)}),F(Ut,Ir)};It(_t,Ut=>{p(D)&&Ut(Ot)})}var xt=_(et,2),mt=y(xt),wt=_(xt,2),ft=y(wt),K=_(at,2),Ct=y(K),Mt=_(y(Ct),2);{var zt=Ut=>{var Ir=Rft(),Wr=_(y(Ir));U(()=>{or(Ir,1,`pill ${p(M)??""}`,"svelte-1upeacm"),A(Wr,` ${p(M)==="warn"?"높음":"정상"}`)}),F(Ut,Ir)};It(Mt,Ut=>{p(M)&&Ut(zt)})}var Yt=_(Ct,2),we=y(Yt),Kt=_(we),qe=y(Kt),fr=_(Yt,2),re=y(fr),ge=_(K,2),ye=y(ge),se=_(y(ye),2);{var St=Ut=>{var Ir=Pft(),Wr=_(y(Ir));U(()=>{or(Ir,1,`pill ${p(L)??""}`,"svelte-1upeacm"),A(Wr,` ${p(L)==="warn"?"높음":"정상"}`)}),F(Ut,Ir)};It(se,Ut=>{p(L)&&Ut(St)})}var Et=_(ye,2),ue=y(Et),Se=_(Et,2),me=y(Se),Xe=_(me);{var br=Ut=>{var Ir=Hc();U(Wr=>A(Ir,`(${Wr??""}%)`),[()=>p(m).toFixed(0)]),F(Ut,Ir)};It(Xe,Ut=>{p(m)!=null&&Ut(br)})}var kt=_(Xe),_e=_(N,2),ke=y(_e),Tr=y(ke),Oe=_(y(Tr),2),Hr=y(Oe),Zr=_(ke,2);Gn(Zr,{get option(){return p(P)},height:"280px"});var da=_(Zr,2),ua=y(da),Zt=_(y(ua),2),j=y(Zt),ot=_(ua,2),Tt=_(y(ot),2),ce=y(Tt),ae=_(ot,2),Qt=_(y(ae),2),ie=y(Qt),$e=_(ae,2),ze=_(y($e),2),De=y(ze),Bt=_(_e,2),Jt=y(Bt),fe=_(y(Jt),2),Ge=y(fe),Ve=y(Ge),je=_(y(Ve)),tr=y(je),yr=_(Ve),Me=_(y(yr)),mr=y(Me),kr=_(yr),zr=_(y(kr)),ea=y(zr),vt=_(kr),bt=_(y(vt)),Gt=y(bt),he=_(vt),Ye=_(y(he)),He=y(Ye),te=_(he),ne=_(y(te)),Te=y(ne),rr=_(te),hr=_(y(rr)),vr=y(hr),st=_(fe,2);{var At=Ut=>{var Ir=Eft();F(Ut,Ir)};It(st,Ut=>{p(l)?.power_draw_available||Ut(At)})}var ee=_(Jt,2),nr=_(y(ee),2),wr=y(nr),Xr=y(wr),jr=_(y(Xr)),Aa=y(jr),ba=_(Xr),_a=_(y(ba)),Pa=y(_a),Ga=_(ba,2),_i=_(y(Ga)),vi=y(_i),pr=_(Ga),Na=_(y(pr)),qi=y(Na);U((Ut,Ir,Wr,va,ja,Li,ci,nn,_n,to,xn,Mo,eo,Lo,Io,bn,Ro,hs,wn,ro,ao)=>{V=or(O,1,"metric",null,V,{glow:p(c)!=null&&p(c)>=50}),A($,Ut),A(Q,`${p(l)?.name??"GPU"??""} · 평균 ${Ir??""}`),A(mt,Wr),A(ft,`최고 ${va??""} (최근 ${p(i).length??""} 샘플)`),A(we,ja),A(qe,`/ ${Li??""}`),A(re,`평균 ${ci??""} · 현재 ${nn??""}`),A(ue,_n),A(me,`온도 ${to??""} `),A(kt,` · RAM ${xn??""}`),A(Hr,`${p(l)?.name??"GPU"??""} · 자원 트렌드`),A(j,Mo),ha(Tt,`font-size:18px;font-weight:600;color:${p(T).tempMax!=null&&p(T).tempMax>=80?"var(--danger)":p(T).tempMax!=null&&p(T).tempMax>=70?"var(--warn)":"var(--fg-strong)"}`),A(ce,eo),A(ie,Lo),A(De,Io),A(tr,p(l)?.name??"—"),A(mr,`${p(e)?.gpus?.length??1??""}개`),A(ea,bn),A(Gt,Ro),A(He,hs),A(Te,wn),A(vr,ro),A(Aa,`${p(n)??""} 초`),A(Pa,`${p(i).length??""} / 720 points`),A(vi,ao),A(qi,p(o))},[()=>p(c)!=null?p(c).toFixed(1):"—",()=>p(T).utilAvg!=null?p(T).utilAvg.toFixed(1)+"%":"—",()=>p(d)!=null?p(d).toFixed(1):"—",()=>p(T).tempMax!=null?p(T).tempMax.toFixed(0)+"°C":"—",()=>p(l)?.memory_used_mib!=null?(p(l).memory_used_mib/1024).toFixed(1):"—",()=>p(l)?.memory_total_mib!=null?(p(l).memory_total_mib/1024).toFixed(0)+" GiB":"— GiB",()=>p(T).vramAvg!=null?p(T).vramAvg.toFixed(1)+"%":"—",()=>p(f)!=null?p(f).toFixed(1)+"%":"—",()=>p(h)!=null?p(h).toFixed(1):"—",()=>p(g)!=null?p(g).toFixed(1)+"°C":"미측정",()=>p(x)!=null?p(x).toFixed(1)+"%":"—",()=>p(T).utilAvg!=null?p(T).utilAvg.toFixed(1)+"%":"—",()=>p(T).tempMax!=null?p(T).tempMax.toFixed(0)+"°C":"—",()=>p(T).vramAvg!=null?p(T).vramAvg.toFixed(1)+"%":"—",()=>p(l)?.power_limit_watts!=null?p(l).power_limit_watts.toFixed(0)+" W":"—",()=>p(l)?.memory_total_mib!=null?Ft.bytes(p(l).memory_total_mib):"—",()=>p(l)?.memory_used_mib!=null?Ft.bytes(p(l).memory_used_mib):"—",()=>p(l)?.power_limit_watts!=null?p(l).power_limit_watts.toFixed(0)+" W":"—",()=>p(l)?.power_draw_available&&p(l)?.power_draw_watts!=null?p(l).power_draw_watts.toFixed(0)+" W":"실측 불가",()=>p(e)?.generated_at?Ft.kst(p(e).generated_at):"—",()=>Ft.durationCompact(p(i).length*p(n))]),F(r,E),Yr()}var zft=G(''),Bft=G('허용됨'),Vft=G('차단됨'),Fft=G('미지원'),Gft=G('미설정'),Hft=G(''),Wft=G(''),Uft=G('브라우저 설정에서 알림 권한을 다시 활성화하세요'),qft=G('현재 브라우저가 Notification API 를 지원하지 않습니다'),$ft=G(`
                공식 기능 클라이언트 저장 (localStorage)

                설정

                테마·새로고침 주기·알림 등 클라이언트 환경 설정. 모든 설정은 브라우저에 저장되며 서버 상태에 영향이 없습니다.

                APPEARANCE
                외관
                테마
                우상단 헤더의 sun/moon 토글과 동일합니다.
                사이드바 기본 상태
                데스크탑(≥900px)에서 사이드바를 축소 상태로 시작
                REFRESH
                새로고침 주기
                /api/training/status, /api/training/history, /api/training/gpu 폴링 간격. artifacts 폴링은 30초 고정.
                NOTIFICATIONS
                브라우저 알림

                학습 단계 전환 (tokenizer→predictor), checkpoint 생성, 학습 종료 시 브라우저 알림을 받습니다. + 탭이 백그라운드일 때만 발송됩니다.

                RESET
                설정 초기화

                저장된 모든 클라이언트 설정(테마·새로고침 주기·사이드바 상태)을 기본값으로 되돌립니다. + 학습 데이터와 서버 상태에는 영향이 없습니다.

                ROADMAP
                차후 추가 예정 설정
                계획
                • 언어 / 지역 (한국어 ↔ English)
                • 데이터 표시 정밀도 (loss 소수점 자릿수, KST 12/24h)
                • 예측 기본값 (lookback / pred_len / temperature / top_p 기본값 저장)
                • 고급 / 진단 (디버그 패널, 캐시 강제 갱신, dist 파일 hash 표시)
                `,1);function Yft(r,t){$r(t,!0);let e=rt(5);tv.subscribe(et=>W(e,et,!0));let a=rt("light");mo.subscribe(et=>W(a,et,!0));let i=rt(!1);xc.subscribe(et=>W(i,et,!0));let n=rt(Fr(typeof Notification<"u"?Notification.permission:"unsupported"));async function o(){if(typeof Notification>"u")return;const et=await Notification.requestPermission();W(n,et,!0)}function s(){typeof Notification>"u"||Notification.permission!=="granted"||new Notification("Kronos 대시보드",{body:"알림 테스트입니다. 학습 단계 전환 시 이런 알림을 받습니다.",icon:void 0})}const l=[{v:2,label:"2초",desc:"최소"},{v:5,label:"5초",desc:"기본"},{v:10,label:"10초",desc:"여유"},{v:30,label:"30초",desc:"저빈도"},{v:60,label:"60초",desc:"최대"}];function u(et){tv.set(et)}function v(){if(confirm("모든 클라이언트 설정을 초기화합니다. 계속하시겠습니까?"))try{localStorage.removeItem("kronos-theme"),mo.set("light"),tv.set(5),xc.set(!1)}catch{}}var c=$ft(),d=_(lr(c),2),f=_(y(d),2),h=_(y(f),4),g=y(h),m=_(g,2),x=_(f,2),b=_(y(x),2),w=y(b),S=_(d,2),C=y(S),T=_(y(C),2),k=_(y(T)),D=_(C,4);nt(D,21,()=>l,ut,(et,_t)=>{var Ot=zft(),xt=y(Ot),mt=y(xt),wt=_(xt,2),ft=y(wt);U(()=>{xe(Ot,"data-active",p(e)===p(_t).v?"true":"false"),A(mt,p(_t).label),A(ft,p(_t).desc)}),cr("click",Ot,()=>u(p(_t).v)),F(et,Ot)});var M=_(S,2),L=y(M),I=_(y(L),2);{var P=et=>{var _t=Bft();F(et,_t)},E=et=>{var _t=Vft();F(et,_t)},N=et=>{var _t=Fft();F(et,_t)},O=et=>{var _t=Gft();F(et,_t)};It(I,et=>{p(n)==="granted"?et(P):p(n)==="denied"?et(E,1):p(n)==="unsupported"?et(N,2):et(O,-1)})}var V=_(L,4),B=y(V);{var z=et=>{var _t=Hft();cr("click",_t,o),F(et,_t)},H=et=>{var _t=Wft();cr("click",_t,s),F(et,_t)},Y=et=>{var _t=Uft();F(et,_t)},$=et=>{var _t=qft();F(et,_t)};It(B,et=>{p(n)==="default"?et(z):p(n)==="granted"?et(H,1):p(n)==="denied"?et(Y,2):et($,-1)})}var Z=_(M,2),Q=_(y(Z),4),at=y(Q);U(()=>{xe(g,"data-active",p(a)==="light"?"true":"false"),xe(m,"data-active",p(a)==="dark"?"true":"false"),K7(w,p(i)),A(k,`현재 ${p(e)??""}초`)}),cr("click",g,()=>mo.set("light")),cr("click",m,()=>mo.set("dark")),cr("change",w,et=>xc.set(et.currentTarget.checked)),cr("click",at,v),F(r,c),Yr()}an(["click","change"]);function yA(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}var Ev=yA();function RB(r){Ev=r}var Eu={exec:()=>null};function na(r,t=""){let e=typeof r=="string"?r:r.source,a={replace:(i,n)=>{let o=typeof n=="string"?n:n.source;return o=o.replace(Ci.caret,"$1"),e=e.replace(i,o),a},getRegex:()=>new RegExp(e,t)};return a}var Zft=((r="")=>{try{return!!new RegExp("(?<=1)(?/,blockquoteSetextReplace:/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,blockquoteSetextReplace2:/^ {0,3}>[ \t]?/gm,listReplaceNesting:/^ {1,4}(?=( {4})*[^ ])/g,listIsTask:/^\[[ xX]\] +\S/,listReplaceTask:/^\[[ xX]\] +/,listTaskCheckbox:/\[[ xX]\]/,anyLine:/\n.*\n/,hrefBrackets:/^<(.*)>$/,tableDelimiter:/[:|]/,tableAlignChars:/^\||\| *$/g,tableRowBlankLine:/\n[ \t]*$/,tableAlignRight:/^ *-+: *$/,tableAlignCenter:/^ *:-+: *$/,tableAlignLeft:/^ *:-+ *$/,startATag:/^/i,startPreScriptTag:/^<(pre|code|kbd|script)(\s|>)/i,endPreScriptTag:/^<\/(pre|code|kbd|script)(\s|>)/i,startAngleBracket:/^$/,pedanticHrefTitle:/^([^'"]*[^\s])\s+(['"])(.*)\2/,unicodeAlphaNumeric:/[\p{L}\p{N}]/u,escapeTest:/[&<>"']/,escapeReplace:/[&<>"']/g,escapeTestNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,escapeReplaceNoEncode:/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g,caret:/(^|[^\[])\^/g,percentDecode:/%25/g,findPipe:/\|/g,splitPipe:/ \|/,slashPipe:/\\\|/g,carriageReturn:/\r\n|\r/g,spaceLine:/^ +$/gm,notSpaceStart:/^\S*/,endingNewline:/\n$/,listItemRegex:r=>new RegExp(`^( {0,3}${r})((?:[ ][^\\n]*)?(?:\\n|$))`),nextBulletRegex:r=>new RegExp(`^ {0,${Math.min(3,r-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ ][^\\n]*)?(?:\\n|$))`),hrRegex:r=>new RegExp(`^ {0,${Math.min(3,r-1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`),fencesBeginRegex:r=>new RegExp(`^ {0,${Math.min(3,r-1)}}(?:\`\`\`|~~~)`),headingBeginRegex:r=>new RegExp(`^ {0,${Math.min(3,r-1)}}#`),htmlBeginRegex:r=>new RegExp(`^ {0,${Math.min(3,r-1)}}<(?:[a-z].*>|!--)`,"i"),blockquoteBeginRegex:r=>new RegExp(`^ {0,${Math.min(3,r-1)}}>`)},Xft=/^(?:[ \t]*(?:\n|$))+/,jft=/^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/,Kft=/^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/,rp=/^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/,Qft=/^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/,mA=/ {0,3}(?:[*+-]|\d{1,9}[.)])/,PB=/^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/,EB=na(PB).replace(/bull/g,mA).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/\|table/g,"").getRegex(),Jft=na(PB).replace(/bull/g,mA).replace(/blockCode/g,/(?: {4}| {0,3}\t)/).replace(/fences/g,/ {0,3}(?:`{3,}|~{3,})/).replace(/blockquote/g,/ {0,3}>/).replace(/heading/g,/ {0,3}#{1,6}/).replace(/html/g,/ {0,3}<[^\n>]+>\n/).replace(/table/g,/ {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/).getRegex(),_A=/^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/,tht=/^[^\n]+/,xA=/(?!\s*\])(?:\\[\s\S]|[^\[\]\\])+/,eht=na(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/).replace("label",xA).replace("title",/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/).getRegex(),rht=na(/^(bull)([ \t][^\n]+?)?(?:\n|$)/).replace(/bull/g,mA).getRegex(),G_="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",bA=/|$))/,aht=na("^ {0,3}(?:<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?(?:\\?>\\n*|$)|\\n*|$)|\\n*|$)|)[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$)|(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ ]*)+\\n|$))","i").replace("comment",bA).replace("tag",G_).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),NB=na(_A).replace("hr",rp).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("|table","").replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",G_).getRegex(),iht=na(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/).replace("paragraph",NB).getRegex(),wA={blockquote:iht,code:jft,def:eht,fences:Kft,heading:Qft,hr:rp,html:aht,lheading:EB,list:rht,newline:Xft,paragraph:NB,table:Eu,text:tht},DP=na("^ *([^\\n ].*)\\n {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)").replace("hr",rp).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("blockquote"," {0,3}>").replace("code","(?: {4}| {0,3} )[^\\n]").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",G_).getRegex(),nht={...wA,lheading:Jft,table:DP,paragraph:na(_A).replace("hr",rp).replace("heading"," {0,3}#{1,6}(?:\\s|$)").replace("|lheading","").replace("table",DP).replace("blockquote"," {0,3}>").replace("fences"," {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n").replace("list"," {0,3}(?:[*+-]|1[.)])[ \\t]").replace("html",")|<(?:script|pre|style|textarea|!--)").replace("tag",G_).getRegex()},oht={...wA,html:na(`^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+? *(?:\\n{2,}|\\s*$)|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))`).replace("comment",bA).replace(/tag/g,"(?!(?:a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)\\b)\\w+(?!:|[^\\w\\s@]*@)\\b").getRegex(),def:/^ *\[([^\]]+)\]: *]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/,heading:/^(#{1,6})(.*)(?:\n+|$)/,fences:Eu,lheading:/^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/,paragraph:na(_A).replace("hr",rp).replace("heading",` *#{1,6} *[^ +]`).replace("lheading",EB).replace("|table","").replace("blockquote"," {0,3}>").replace("|fences","").replace("|list","").replace("|html","").replace("|tag","").getRegex()},sht=/^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/,lht=/^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/,OB=/^( {2,}|\\)\n(?!\s*$)/,uht=/^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\`+)[^`]+\k(?!`))*?\]\((?:\\[\s\S]|[^\\\(\)]|\((?:\\[\s\S]|[^\\\(\)])*\))*\)/).replace("precode-",Zft?"(?`+)[^`]+\k(?!`)/).replace("html",/<(?! )[^<>]*?>/).getRegex(),BB=/^(?:\*+(?:((?!\*)punct)|([^\s*]))?)|^_+(?:((?!_)punct)|([^\s_]))?/,hht=na(BB,"u").replace(/punct/g,md).getRegex(),pht=na(BB,"u").replace(/punct/g,zB).getRegex(),VB="^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)|[^*]+(?=[^*])|(?!\\*)punct(\\*+)(?=[\\s]|$)|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)|[\\s](\\*+)(?!\\*)(?=punct)|(?!\\*)punct(\\*+)(?!\\*)(?=punct)|notPunctSpace(\\*+)(?=notPunctSpace)",ght=na(VB,"gu").replace(/notPunctSpace/g,SA).replace(/punctSpace/g,H_).replace(/punct/g,md).getRegex(),yht=na(VB,"gu").replace(/notPunctSpace/g,dht).replace(/punctSpace/g,cht).replace(/punct/g,zB).getRegex(),mht=na("^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)|[^_]+(?=[^_])|(?!_)punct(_+)(?=[\\s]|$)|notPunctSpace(_+)(?!_)(?=punctSpace|$)|(?!_)punctSpace(_+)(?=notPunctSpace)|[\\s](_+)(?!_)(?=punct)|(?!_)punct(_+)(?!_)(?=punct)","gu").replace(/notPunctSpace/g,SA).replace(/punctSpace/g,H_).replace(/punct/g,md).getRegex(),_ht=na(/^~~?(?:((?!~)punct)|[^\s~])/,"u").replace(/punct/g,md).getRegex(),xht="^[^~]+(?=[^~])|(?!~)punct(~~?)(?=[\\s]|$)|notPunctSpace(~~?)(?!~)(?=punctSpace|$)|(?!~)punctSpace(~~?)(?=notPunctSpace)|[\\s](~~?)(?!~)(?=punct)|(?!~)punct(~~?)(?!~)(?=punct)|notPunctSpace(~~?)(?=notPunctSpace)",bht=na(xht,"gu").replace(/notPunctSpace/g,SA).replace(/punctSpace/g,H_).replace(/punct/g,md).getRegex(),wht=na(/\\(punct)/,"gu").replace(/punct/g,md).getRegex(),Sht=na(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/).replace("scheme",/[a-zA-Z][a-zA-Z0-9+.-]{1,31}/).replace("email",/[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/).getRegex(),Tht=na(bA).replace("(?:-->|$)","-->").getRegex(),Aht=na("^comment|^|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>|^<\\?[\\s\\S]*?\\?>|^|^").replace("comment",Tht).replace("attribute",/\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/).getRegex(),Bm=/(?:\[(?:\\[\s\S]|[^\[\]\\])*\]|\\[\s\S]|`+(?!`)[^`]*?`+(?!`)|``+(?=\])|[^\[\]\\`])*?/,Cht=na(/^!?\[(label)\]\(\s*(href)(?:(?:[ \t]+(?:\n[ \t]*)?|\n[ \t]*)(title))?\s*\)/).replace("label",Bm).replace("href",/<(?:\\.|[^\n<>\\])+>|[^ \t\n\x00-\x1f]*/).replace("title",/"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/).getRegex(),FB=na(/^!?\[(label)\]\[(ref)\]/).replace("label",Bm).replace("ref",xA).getRegex(),GB=na(/^!?\[(ref)\](?:\[\])?/).replace("ref",xA).getRegex(),kht=na("reflink|nolink(?!\\()","g").replace("reflink",FB).replace("nolink",GB).getRegex(),MP=/[hH][tT][tT][pP][sS]?|[fF][tT][pP]/,TA={_backpedal:Eu,anyPunctuation:wht,autolink:Sht,blockSkip:fht,br:OB,code:lht,del:Eu,delLDelim:Eu,delRDelim:Eu,emStrongLDelim:hht,emStrongRDelimAst:ght,emStrongRDelimUnd:mht,escape:sht,link:Cht,nolink:GB,punctuation:vht,reflink:FB,reflinkSearch:kht,tag:Aht,text:uht,url:Eu},Dht={...TA,link:na(/^!?\[(label)\]\((.*?)\)/).replace("label",Bm).getRegex(),reflink:na(/^!?\[(label)\]\s*\[([^\]]*)\]/).replace("label",Bm).getRegex()},H2={...TA,emStrongRDelimAst:yht,emStrongLDelim:pht,delLDelim:_ht,delRDelim:bht,url:na(/^((?:protocol):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/).replace("protocol",MP).replace("email",/[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/).getRegex(),_backpedal:/(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/,del:/^(~~?)(?=[^\s~])((?:\\[\s\S]|[^\\])*?(?:\\[\s\S]|[^\s~\\]))\1(?=[^~]|$)/,text:na(/^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\":">",'"':""","'":"'"},LP=r=>Lht[r];function Go(r,t){if(t){if(Ci.escapeTest.test(r))return r.replace(Ci.escapeReplace,LP)}else if(Ci.escapeTestNoEncode.test(r))return r.replace(Ci.escapeReplaceNoEncode,LP);return r}function IP(r){try{r=encodeURI(r).replace(Ci.percentDecode,"%")}catch{return null}return r}function RP(r,t){let e=r.replace(Ci.findPipe,(n,o,s)=>{let l=!1,u=o;for(;--u>=0&&s[u]==="\\";)l=!l;return l?"|":" |"}),a=e.split(Ci.splitPipe),i=0;if(a[0].trim()||a.shift(),a.length>0&&!a.at(-1)?.trim()&&a.pop(),t)if(a.length>t)a.splice(t);else for(;a.length=0&&Ci.blankLine.test(t[e]);)e--;return t.length-e<=2?r:t.slice(0,e+1).join(` +`)}function Iht(r,t){if(r.indexOf(t[1])===-1)return-1;let e=0;for(let a=0;a0?-2:-1}function Rht(r,t=0){let e=t,a="";for(let i of r)if(i===" "){let n=4-e%4;a+=" ".repeat(n),e+=n}else a+=i,e++;return a}function EP(r,t,e,a,i){let n=t.href,o=t.title||null,s=r[1].replace(i.other.outputLinkReplace,"$1");a.state.inLink=!0;let l={type:r[0].charAt(0)==="!"?"image":"link",raw:e,href:n,title:o,text:s,tokens:a.inlineTokens(s)};return a.state.inLink=!1,l}function Pht(r,t,e){let a=r.match(e.other.indentCodeCompensation);if(a===null)return t;let i=a[1];return t.split(` +`).map(n=>{let o=n.match(e.other.beginningSpace);if(o===null)return n;let[s]=o;return s.length>=i.length?n.slice(i.length):n}).join(` +`)}var Vm=class{constructor(r){Ur(this,"options");Ur(this,"rules");Ur(this,"lexer");this.options=r||Ev}space(r){let t=this.rules.block.newline.exec(r);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(r){let t=this.rules.block.code.exec(r);if(t){let e=this.options.pedantic?t[0]:PP(t[0]),a=e.replace(this.rules.other.codeRemoveIndent,"");return{type:"code",raw:e,codeBlockStyle:"indented",text:a}}}fences(r){let t=this.rules.block.fences.exec(r);if(t){let e=t[0],a=Pht(e,t[3]||"",this.rules);return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:a}}}heading(r){let t=this.rules.block.heading.exec(r);if(t){let e=t[2].trim();if(this.rules.other.endingHash.test(e)){let a=ul(e,"#");(this.options.pedantic||!a||this.rules.other.endingSpaceChar.test(a))&&(e=a.trim())}return{type:"heading",raw:ul(t[0],` +`),depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(r){let t=this.rules.block.hr.exec(r);if(t)return{type:"hr",raw:ul(t[0],` +`)}}blockquote(r){let t=this.rules.block.blockquote.exec(r);if(t){let e=ul(t[0],` +`).split(` +`),a="",i="",n=[];for(;e.length>0;){let o=!1,s=[],l;for(l=0;l1,i={type:"list",raw:"",ordered:a,start:a?+e.slice(0,-1):"",loose:!1,items:[]};e=a?`\\d{1,9}\\${e.slice(-1)}`:`\\${e}`,this.options.pedantic&&(e=a?e:"[*+-]");let n=this.rules.other.listItemRegex(e),o=!1;for(;r;){let l=!1,u="",v="";if(!(t=n.exec(r))||this.rules.block.hr.test(r))break;u=t[0],r=r.substring(u.length);let c=Rht(t[2].split(` +`,1)[0],t[1].length),d=r.split(` +`,1)[0],f=!c.trim(),h=0;if(this.options.pedantic?(h=2,v=c.trimStart()):f?h=t[1].length+1:(h=c.search(this.rules.other.nonSpaceChar),h=h>4?1:h,v=c.slice(h),h+=t[1].length),f&&this.rules.other.blankLine.test(d)&&(u+=d+` +`,r=r.substring(d.length+1),l=!0),!l){let g=this.rules.other.nextBulletRegex(h),m=this.rules.other.hrRegex(h),x=this.rules.other.fencesBeginRegex(h),b=this.rules.other.headingBeginRegex(h),w=this.rules.other.htmlBeginRegex(h),S=this.rules.other.blockquoteBeginRegex(h);for(;r;){let C=r.split(` +`,1)[0],T;if(d=C,this.options.pedantic?(d=d.replace(this.rules.other.listReplaceNesting," "),T=d):T=d.replace(this.rules.other.tabCharGlobal," "),x.test(d)||b.test(d)||w.test(d)||S.test(d)||g.test(d)||m.test(d))break;if(T.search(this.rules.other.nonSpaceChar)>=h||!d.trim())v+=` +`+T.slice(h);else{if(f||c.replace(this.rules.other.tabCharGlobal," ").search(this.rules.other.nonSpaceChar)>=4||x.test(c)||b.test(c)||m.test(c))break;v+=` +`+d}f=!d.trim(),u+=C+` +`,r=r.substring(C.length+1),c=T.slice(h)}}i.loose||(o?i.loose=!0:this.rules.other.doubleBlankLine.test(u)&&(o=!0)),i.items.push({type:"list_item",raw:u,task:!!this.options.gfm&&this.rules.other.listIsTask.test(v),loose:!1,text:v,tokens:[]}),i.raw+=u}let s=i.items.at(-1);if(s)s.raw=s.raw.trimEnd(),s.text=s.text.trimEnd();else return;i.raw=i.raw.trimEnd();for(let l of i.items){this.lexer.state.top=!1,l.tokens=this.lexer.blockTokens(l.text,[]);let u=l.tokens[0];if(l.task&&(u?.type==="text"||u?.type==="paragraph")){l.text=l.text.replace(this.rules.other.listReplaceTask,""),u.raw=u.raw.replace(this.rules.other.listReplaceTask,""),u.text=u.text.replace(this.rules.other.listReplaceTask,"");for(let c=this.lexer.inlineQueue.length-1;c>=0;c--)if(this.rules.other.listIsTask.test(this.lexer.inlineQueue[c].src)){this.lexer.inlineQueue[c].src=this.lexer.inlineQueue[c].src.replace(this.rules.other.listReplaceTask,"");break}let v=this.rules.other.listTaskCheckbox.exec(l.raw);if(v){let c={type:"checkbox",raw:v[0]+" ",checked:v[0]!=="[ ]"};l.checked=c.checked,i.loose?l.tokens[0]&&["paragraph","text"].includes(l.tokens[0].type)&&"tokens"in l.tokens[0]&&l.tokens[0].tokens?(l.tokens[0].raw=c.raw+l.tokens[0].raw,l.tokens[0].text=c.raw+l.tokens[0].text,l.tokens[0].tokens.unshift(c)):l.tokens.unshift({type:"paragraph",raw:c.raw,text:c.raw,tokens:[c]}):l.tokens.unshift(c)}}else l.task&&(l.task=!1);if(!i.loose){let v=l.tokens.filter(d=>d.type==="space"),c=v.length>0&&v.some(d=>this.rules.other.anyLine.test(d.raw));i.loose=c}}if(i.loose)for(let l of i.items){l.loose=!0;for(let u of l.tokens)u.type==="text"&&(u.type="paragraph")}return i}}html(r){let t=this.rules.block.html.exec(r);if(t){let e=PP(t[0]);return{type:"html",block:!0,raw:e,pre:t[1]==="pre"||t[1]==="script"||t[1]==="style",text:e}}}def(r){let t=this.rules.block.def.exec(r);if(t){let e=t[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal," "),a=t[2]?t[2].replace(this.rules.other.hrefBrackets,"$1").replace(this.rules.inline.anyPunctuation,"$1"):"",i=t[3]?t[3].substring(1,t[3].length-1).replace(this.rules.inline.anyPunctuation,"$1"):t[3];return{type:"def",tag:e,raw:ul(t[0],` +`),href:a,title:i}}}table(r){let t=this.rules.block.table.exec(r);if(!t||!this.rules.other.tableDelimiter.test(t[2]))return;let e=RP(t[1]),a=t[2].replace(this.rules.other.tableAlignChars,"").split("|"),i=t[3]?.trim()?t[3].replace(this.rules.other.tableRowBlankLine,"").split(` +`):[],n={type:"table",raw:ul(t[0],` +`),header:[],align:[],rows:[]};if(e.length===a.length){for(let o of a)this.rules.other.tableAlignRight.test(o)?n.align.push("right"):this.rules.other.tableAlignCenter.test(o)?n.align.push("center"):this.rules.other.tableAlignLeft.test(o)?n.align.push("left"):n.align.push(null);for(let o=0;o({text:s,tokens:this.lexer.inline(s),header:!1,align:n.align[l]})));return n}}lheading(r){let t=this.rules.block.lheading.exec(r);if(t){let e=t[1].trim();return{type:"heading",raw:ul(t[0],` +`),depth:t[2].charAt(0)==="="?1:2,text:e,tokens:this.lexer.inline(e)}}}paragraph(r){let t=this.rules.block.paragraph.exec(r);if(t){let e=t[1].charAt(t[1].length-1)===` +`?t[1].slice(0,-1):t[1];return{type:"paragraph",raw:t[0],text:e,tokens:this.lexer.inline(e)}}}text(r){let t=this.rules.block.text.exec(r);if(t)return{type:"text",raw:t[0],text:t[0],tokens:this.lexer.inline(t[0])}}escape(r){let t=this.rules.inline.escape.exec(r);if(t)return{type:"escape",raw:t[0],text:t[1]}}tag(r){let t=this.rules.inline.tag.exec(r);if(t)return!this.lexer.state.inLink&&this.rules.other.startATag.test(t[0])?this.lexer.state.inLink=!0:this.lexer.state.inLink&&this.rules.other.endATag.test(t[0])&&(this.lexer.state.inLink=!1),!this.lexer.state.inRawBlock&&this.rules.other.startPreScriptTag.test(t[0])?this.lexer.state.inRawBlock=!0:this.lexer.state.inRawBlock&&this.rules.other.endPreScriptTag.test(t[0])&&(this.lexer.state.inRawBlock=!1),{type:"html",raw:t[0],inLink:this.lexer.state.inLink,inRawBlock:this.lexer.state.inRawBlock,block:!1,text:t[0]}}link(r){let t=this.rules.inline.link.exec(r);if(t){let e=t[2].trim();if(!this.options.pedantic&&this.rules.other.startAngleBracket.test(e)){if(!this.rules.other.endAngleBracket.test(e))return;let n=ul(e.slice(0,-1),"\\");if((e.length-n.length)%2===0)return}else{let n=Iht(t[2],"()");if(n===-2)return;if(n>-1){let o=(t[0].indexOf("!")===0?5:4)+t[1].length+n;t[2]=t[2].substring(0,n),t[0]=t[0].substring(0,o).trim(),t[3]=""}}let a=t[2],i="";if(this.options.pedantic){let n=this.rules.other.pedanticHrefTitle.exec(a);n&&(a=n[1],i=n[3])}else i=t[3]?t[3].slice(1,-1):"";return a=a.trim(),this.rules.other.startAngleBracket.test(a)&&(this.options.pedantic&&!this.rules.other.endAngleBracket.test(e)?a=a.slice(1):a=a.slice(1,-1)),EP(t,{href:a&&a.replace(this.rules.inline.anyPunctuation,"$1"),title:i&&i.replace(this.rules.inline.anyPunctuation,"$1")},t[0],this.lexer,this.rules)}}reflink(r,t){let e;if((e=this.rules.inline.reflink.exec(r))||(e=this.rules.inline.nolink.exec(r))){let a=(e[2]||e[1]).replace(this.rules.other.multipleSpaceGlobal," "),i=t[a.toLowerCase()];if(!i){let n=e[0].charAt(0);return{type:"text",raw:n,text:n}}return EP(e,i,e[0],this.lexer,this.rules)}}emStrong(r,t,e=""){let a=this.rules.inline.emStrongLDelim.exec(r);if(!(!a||!a[1]&&!a[2]&&!a[3]&&!a[4]||a[4]&&e.match(this.rules.other.unicodeAlphaNumeric))&&(!(a[1]||a[3])||!e||this.rules.inline.punctuation.exec(e))){let i=[...a[0]].length-1,n,o,s=i,l=0,u=a[0][0]==="*"?this.rules.inline.emStrongRDelimAst:this.rules.inline.emStrongRDelimUnd;for(u.lastIndex=0,t=t.slice(-1*r.length+i);(a=u.exec(t))!==null;){if(n=a[1]||a[2]||a[3]||a[4]||a[5]||a[6],!n)continue;if(o=[...n].length,a[3]||a[4]){s+=o;continue}else if((a[5]||a[6])&&i%3&&!((i+o)%3)){l+=o;continue}if(s-=o,s>0)continue;o=Math.min(o,o+s+l);let v=[...a[0]][0].length,c=r.slice(0,i+a.index+v+o);if(Math.min(i,o)%2){let f=c.slice(1,-1);return{type:"em",raw:c,text:f,tokens:this.lexer.inlineTokens(f)}}let d=c.slice(2,-2);return{type:"strong",raw:c,text:d,tokens:this.lexer.inlineTokens(d)}}}}codespan(r){let t=this.rules.inline.code.exec(r);if(t){let e=t[2].replace(this.rules.other.newLineCharGlobal," "),a=this.rules.other.nonSpaceChar.test(e),i=this.rules.other.startingSpaceChar.test(e)&&this.rules.other.endingSpaceChar.test(e);return a&&i&&(e=e.substring(1,e.length-1)),{type:"codespan",raw:t[0],text:e}}}br(r){let t=this.rules.inline.br.exec(r);if(t)return{type:"br",raw:t[0]}}del(r,t,e=""){let a=this.rules.inline.delLDelim.exec(r);if(a&&(!a[1]||!e||this.rules.inline.punctuation.exec(e))){let i=[...a[0]].length-1,n,o,s=i,l=this.rules.inline.delRDelim;for(l.lastIndex=0,t=t.slice(-1*r.length+i);(a=l.exec(t))!==null;){if(n=a[1]||a[2]||a[3]||a[4]||a[5]||a[6],!n||(o=[...n].length,o!==i))continue;if(a[3]||a[4]){s+=o;continue}if(s-=o,s>0)continue;o=Math.min(o,o+s);let u=[...a[0]][0].length,v=r.slice(0,i+a.index+u+o),c=v.slice(i,-i);return{type:"del",raw:v,text:c,tokens:this.lexer.inlineTokens(c)}}}}autolink(r){let t=this.rules.inline.autolink.exec(r);if(t){let e,a;return t[2]==="@"?(e=t[1],a="mailto:"+e):(e=t[1],a=e),{type:"link",raw:t[0],text:e,href:a,tokens:[{type:"text",raw:e,text:e}]}}}url(r){let t;if(t=this.rules.inline.url.exec(r)){let e,a;if(t[2]==="@")e=t[0],a="mailto:"+e;else{let i;do i=t[0],t[0]=this.rules.inline._backpedal.exec(t[0])?.[0]??"";while(i!==t[0]);e=t[0],t[1]==="www."?a="http://"+t[0]:a=t[0]}return{type:"link",raw:t[0],text:e,href:a,tokens:[{type:"text",raw:e,text:e}]}}}inlineText(r){let t=this.rules.inline.text.exec(r);if(t){let e=this.lexer.state.inRawBlock;return{type:"text",raw:t[0],text:t[0],escaped:e}}}},po=class W2{constructor(t){Ur(this,"tokens");Ur(this,"options");Ur(this,"state");Ur(this,"inlineQueue");Ur(this,"tokenizer");this.tokens=[],this.tokens.links=Object.create(null),this.options=t||Ev,this.options.tokenizer=this.options.tokenizer||new Vm,this.tokenizer=this.options.tokenizer,this.tokenizer.options=this.options,this.tokenizer.lexer=this,this.inlineQueue=[],this.state={inLink:!1,inRawBlock:!1,top:!0};let e={other:Ci,block:gy.normal,inline:gf.normal};this.options.pedantic?(e.block=gy.pedantic,e.inline=gf.pedantic):this.options.gfm&&(e.block=gy.gfm,this.options.breaks?e.inline=gf.breaks:e.inline=gf.gfm),this.tokenizer.rules=e}static get rules(){return{block:gy,inline:gf}}static lex(t,e){return new W2(e).lex(t)}static lexInline(t,e){return new W2(e).inlineTokens(t)}lex(t){t=t.replace(Ci.carriageReturn,` +`),this.blockTokens(t,this.tokens);for(let e=0;e(n=s.call({lexer:this},t,e))?(t=t.substring(n.raw.length),e.push(n),!0):!1))continue;if(n=this.tokenizer.space(t)){t=t.substring(n.raw.length);let s=e.at(-1);n.raw.length===1&&s!==void 0?s.raw+=` +`:e.push(n);continue}if(n=this.tokenizer.code(t)){t=t.substring(n.raw.length);let s=e.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+n.raw,s.text+=` +`+n.text,this.inlineQueue.at(-1).src=s.text):e.push(n);continue}if(n=this.tokenizer.fences(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.heading(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.hr(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.blockquote(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.list(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.html(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.def(t)){t=t.substring(n.raw.length);let s=e.at(-1);s?.type==="paragraph"||s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+n.raw,s.text+=` +`+n.raw,this.inlineQueue.at(-1).src=s.text):this.tokens.links[n.tag]||(this.tokens.links[n.tag]={href:n.href,title:n.title},e.push(n));continue}if(n=this.tokenizer.table(t)){t=t.substring(n.raw.length),e.push(n);continue}if(n=this.tokenizer.lheading(t)){t=t.substring(n.raw.length),e.push(n);continue}let o=t;if(this.options.extensions?.startBlock){let s=1/0,l=t.slice(1),u;this.options.extensions.startBlock.forEach(v=>{u=v.call({lexer:this},l),typeof u=="number"&&u>=0&&(s=Math.min(s,u))}),s<1/0&&s>=0&&(o=t.substring(0,s+1))}if(this.state.top&&(n=this.tokenizer.paragraph(o))){let s=e.at(-1);a&&s?.type==="paragraph"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+n.raw,s.text+=` +`+n.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):e.push(n),a=o.length!==t.length,t=t.substring(n.raw.length);continue}if(n=this.tokenizer.text(t)){t=t.substring(n.raw.length);let s=e.at(-1);s?.type==="text"?(s.raw+=(s.raw.endsWith(` +`)?"":` +`)+n.raw,s.text+=` +`+n.text,this.inlineQueue.pop(),this.inlineQueue.at(-1).src=s.text):e.push(n);continue}if(t){this.infiniteLoopError(t.charCodeAt(0));break}}return this.state.top=!0,e}inline(t,e=[]){return this.inlineQueue.push({src:t,tokens:e}),e}inlineTokens(t,e=[]){this.tokenizer.lexer=this;let a=t,i=null;if(this.tokens.links){let u=Object.keys(this.tokens.links);if(u.length>0)for(;(i=this.tokenizer.rules.inline.reflinkSearch.exec(a))!==null;)u.includes(i[0].slice(i[0].lastIndexOf("[")+1,-1))&&(a=a.slice(0,i.index)+"["+"a".repeat(i[0].length-2)+"]"+a.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex))}for(;(i=this.tokenizer.rules.inline.anyPunctuation.exec(a))!==null;)a=a.slice(0,i.index)+"++"+a.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex);let n;for(;(i=this.tokenizer.rules.inline.blockSkip.exec(a))!==null;)n=i[2]?i[2].length:0,a=a.slice(0,i.index+n)+"["+"a".repeat(i[0].length-n-2)+"]"+a.slice(this.tokenizer.rules.inline.blockSkip.lastIndex);a=this.options.hooks?.emStrongMask?.call({lexer:this},a)??a;let o=!1,s="",l=1/0;for(;t;){if(t.length(u=c.call({lexer:this},t,e))?(t=t.substring(u.raw.length),e.push(u),!0):!1))continue;if(u=this.tokenizer.escape(t)){t=t.substring(u.raw.length),e.push(u);continue}if(u=this.tokenizer.tag(t)){t=t.substring(u.raw.length),e.push(u);continue}if(u=this.tokenizer.link(t)){t=t.substring(u.raw.length),e.push(u);continue}if(u=this.tokenizer.reflink(t,this.tokens.links)){t=t.substring(u.raw.length);let c=e.at(-1);u.type==="text"&&c?.type==="text"?(c.raw+=u.raw,c.text+=u.text):e.push(u);continue}if(u=this.tokenizer.emStrong(t,a,s)){t=t.substring(u.raw.length),e.push(u);continue}if(u=this.tokenizer.codespan(t)){t=t.substring(u.raw.length),e.push(u);continue}if(u=this.tokenizer.br(t)){t=t.substring(u.raw.length),e.push(u);continue}if(u=this.tokenizer.del(t,a,s)){t=t.substring(u.raw.length),e.push(u);continue}if(u=this.tokenizer.autolink(t)){t=t.substring(u.raw.length),e.push(u);continue}if(!this.state.inLink&&(u=this.tokenizer.url(t))){t=t.substring(u.raw.length),e.push(u);continue}let v=t;if(this.options.extensions?.startInline){let c=1/0,d=t.slice(1),f;this.options.extensions.startInline.forEach(h=>{f=h.call({lexer:this},d),typeof f=="number"&&f>=0&&(c=Math.min(c,f))}),c<1/0&&c>=0&&(v=t.substring(0,c+1))}if(u=this.tokenizer.inlineText(v)){t=t.substring(u.raw.length),u.raw.slice(-1)!=="_"&&(s=u.raw.slice(-1)),o=!0;let c=e.at(-1);c?.type==="text"?(c.raw+=u.raw,c.text+=u.text):e.push(u);continue}if(t){this.infiniteLoopError(t.charCodeAt(0));break}}return e}infiniteLoopError(t){let e="Infinite loop on byte: "+t;if(this.options.silent)console.error(e);else throw new Error(e)}},Fm=class{constructor(r){Ur(this,"options");Ur(this,"parser");this.options=r||Ev}space(r){return""}code({text:r,lang:t,escaped:e}){let a=(t||"").match(Ci.notSpaceStart)?.[0],i=r.replace(Ci.endingNewline,"")+` +`;return a?'
                '+(e?i:Go(i,!0))+`
                +`:"
                "+(e?i:Go(i,!0))+`
                +`}blockquote({tokens:r}){return`
                +${this.parser.parse(r)}
                +`}html({text:r}){return r}def(r){return""}heading({tokens:r,depth:t}){return`${this.parser.parseInline(r)} +`}hr(r){return`
                +`}list(r){let t=r.ordered,e=r.start,a="";for(let o=0;o +`+a+" +`}listitem(r){return`
              • ${this.parser.parse(r.tokens)}
              • +`}checkbox({checked:r}){return" '}paragraph({tokens:r}){return`

                ${this.parser.parseInline(r)}

                +`}table(r){let t="",e="";for(let i=0;i${a}`),` + +`+t+` +`+a+`
                +`}tablerow({text:r}){return` +${r} +`}tablecell(r){let t=this.parser.parseInline(r.tokens),e=r.header?"th":"td";return(r.align?`<${e} align="${r.align}">`:`<${e}>`)+t+` +`}strong({tokens:r}){return`${this.parser.parseInline(r)}`}em({tokens:r}){return`${this.parser.parseInline(r)}`}codespan({text:r}){return`${Go(r,!0)}`}br(r){return"
                "}del({tokens:r}){return`${this.parser.parseInline(r)}`}link({href:r,title:t,tokens:e}){let a=this.parser.parseInline(e),i=IP(r);if(i===null)return a;r=i;let n='
                ",n}image({href:r,title:t,text:e,tokens:a}){a&&(e=this.parser.parseInline(a,this.parser.textRenderer));let i=IP(r);if(i===null)return Go(e);r=i;let n=`${Go(e)}{let o=i[n].flat(1/0);e=e.concat(this.walkTokens(o,t))}):i.tokens&&(e=e.concat(this.walkTokens(i.tokens,t)))}}return e}use(...r){let t=this.defaults.extensions||{renderers:{},childTokens:{}};return r.forEach(e=>{let a={...e};if(a.async=this.defaults.async||a.async||!1,e.extensions&&(e.extensions.forEach(i=>{if(!i.name)throw new Error("extension name required");if("renderer"in i){let n=t.renderers[i.name];n?t.renderers[i.name]=function(...o){let s=i.renderer.apply(this,o);return s===!1&&(s=n.apply(this,o)),s}:t.renderers[i.name]=i.renderer}if("tokenizer"in i){if(!i.level||i.level!=="block"&&i.level!=="inline")throw new Error("extension level must be 'block' or 'inline'");let n=t[i.level];n?n.unshift(i.tokenizer):t[i.level]=[i.tokenizer],i.start&&(i.level==="block"?t.startBlock?t.startBlock.push(i.start):t.startBlock=[i.start]:i.level==="inline"&&(t.startInline?t.startInline.push(i.start):t.startInline=[i.start]))}"childTokens"in i&&i.childTokens&&(t.childTokens[i.name]=i.childTokens)}),a.extensions=t),e.renderer){let i=this.defaults.renderer||new Fm(this.defaults);for(let n in e.renderer){if(!(n in i))throw new Error(`renderer '${n}' does not exist`);if(["options","parser"].includes(n))continue;let o=n,s=e.renderer[o],l=i[o];i[o]=(...u)=>{let v=s.apply(i,u);return v===!1&&(v=l.apply(i,u)),v||""}}a.renderer=i}if(e.tokenizer){let i=this.defaults.tokenizer||new Vm(this.defaults);for(let n in e.tokenizer){if(!(n in i))throw new Error(`tokenizer '${n}' does not exist`);if(["options","rules","lexer"].includes(n))continue;let o=n,s=e.tokenizer[o],l=i[o];i[o]=(...u)=>{let v=s.apply(i,u);return v===!1&&(v=l.apply(i,u)),v}}a.tokenizer=i}if(e.hooks){let i=this.defaults.hooks||new Lf;for(let n in e.hooks){if(!(n in i))throw new Error(`hook '${n}' does not exist`);if(["options","block"].includes(n))continue;let o=n,s=e.hooks[o],l=i[o];Lf.passThroughHooks.has(n)?i[o]=u=>{if(this.defaults.async&&Lf.passThroughHooksRespectAsync.has(n))return(async()=>{let c=await s.call(i,u);return l.call(i,c)})();let v=s.call(i,u);return l.call(i,v)}:i[o]=(...u)=>{if(this.defaults.async)return(async()=>{let c=await s.apply(i,u);return c===!1&&(c=await l.apply(i,u)),c})();let v=s.apply(i,u);return v===!1&&(v=l.apply(i,u)),v}}a.hooks=i}if(e.walkTokens){let i=this.defaults.walkTokens,n=e.walkTokens;a.walkTokens=function(o){let s=[];return s.push(n.call(this,o)),i&&(s=s.concat(i.call(this,o))),s}}this.defaults={...this.defaults,...a}}),this}setOptions(r){return this.defaults={...this.defaults,...r},this}lexer(r,t){return po.lex(r,t??this.defaults)}parser(r,t){return go.parse(r,t??this.defaults)}parseMarkdown(r){return(t,e)=>{let a={...e},i={...this.defaults,...a},n=this.onError(!!i.silent,!!i.async);if(this.defaults.async===!0&&a.async===!1)return n(new Error("marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise."));if(typeof t>"u"||t===null)return n(new Error("marked(): input parameter is undefined or null"));if(typeof t!="string")return n(new Error("marked(): input parameter is of type "+Object.prototype.toString.call(t)+", string expected"));if(i.hooks&&(i.hooks.options=i,i.hooks.block=r),i.async)return(async()=>{let o=i.hooks?await i.hooks.preprocess(t):t,s=await(i.hooks?await i.hooks.provideLexer(r):r?po.lex:po.lexInline)(o,i),l=i.hooks?await i.hooks.processAllTokens(s):s;i.walkTokens&&await Promise.all(this.walkTokens(l,i.walkTokens));let u=await(i.hooks?await i.hooks.provideParser(r):r?go.parse:go.parseInline)(l,i);return i.hooks?await i.hooks.postprocess(u):u})().catch(n);try{i.hooks&&(t=i.hooks.preprocess(t));let o=(i.hooks?i.hooks.provideLexer(r):r?po.lex:po.lexInline)(t,i);i.hooks&&(o=i.hooks.processAllTokens(o)),i.walkTokens&&this.walkTokens(o,i.walkTokens);let s=(i.hooks?i.hooks.provideParser(r):r?go.parse:go.parseInline)(o,i);return i.hooks&&(s=i.hooks.postprocess(s)),s}catch(o){return n(o)}}}onError(r,t){return e=>{if(e.message+=` +Please report this to https://github.com/markedjs/marked.`,r){let a="

                An error occurred:

                "+Go(e.message+"",!0)+"
                ";return t?Promise.resolve(a):a}if(t)return Promise.reject(e);throw e}}},wv=new Eht;function pa(r,t){return wv.parse(r,t)}pa.options=pa.setOptions=function(r){return wv.setOptions(r),pa.defaults=wv.defaults,RB(pa.defaults),pa};pa.getDefaults=yA;pa.defaults=Ev;pa.use=function(...r){return wv.use(...r),pa.defaults=wv.defaults,RB(pa.defaults),pa};pa.walkTokens=function(r,t){return wv.walkTokens(r,t)};pa.parseInline=wv.parseInline;pa.Parser=go;pa.parser=go.parse;pa.Renderer=Fm;pa.TextRenderer=AA;pa.Lexer=po;pa.lexer=po.lex;pa.Tokenizer=Vm;pa.Hooks=Lf;pa.parse=pa;pa.options;pa.setOptions;pa.use;pa.walkTokens;pa.parseInline;go.parse;po.lex;var Nht=G('
                목록 불러오는 중...
                '),Oht=G('
                '),zht=G(''),Bht=G('
                '),Vht=G(' ',1),Fht=G('
                문서 불러오는 중...
                '),Ght=G('
                '),Hht=G('
                좌측에서 문서를 선택하세요
                '),Wht=G(' ',1),Uht=G('
                ',1),qht=G(`
                공식 문서 /api/docs/* · read-only

                문서 · Wiki

                Kronos 프로젝트의 모든 노하우와 시행착오를 모은 살아있는 wiki. 마크다운 원본은 docs/wiki/ 에 보관되며, + 파일을 직접 수정하면 새로고침으로 즉시 반영됩니다.

                `,1);function $ht(r,t){$r(t,!0);let e=rt(Fr([])),a=rt(!1),i=rt(null),n=rt("00-index"),o=rt(""),s=rt(null),l=rt(!1);pa.setOptions({gfm:!0,breaks:!1});async function u(){W(a,!0),W(i,null);try{const B=await fetch("/api/docs/list");if(!B.ok){W(i,`HTTP ${B.status}`);return}const z=await B.json();W(e,Array.isArray(z.docs)?z.docs:[],!0),z.available||W(i,z.message??"문서가 없습니다",!0)}catch(B){W(i,B?.message??"문서 목록 조회 실패",!0)}finally{W(a,!1)}}async function v(B){W(n,B,!0),W(l,!0),W(s,null),W(o,"");try{const z=await fetch(`/api/docs/read?slug=${encodeURIComponent(B)}`);if(!z.ok){const Y=await z.json().catch(()=>({}));W(s,Y?.error??`HTTP ${z.status}`,!0);return}const H=await z.json();W(o,H.content??"",!0)}catch(z){W(s,z?.message??"문서 조회 실패",!0)}finally{W(l,!1)}}mn(async()=>{if(await u(),p(e).length>0){const B=p(e).find(z=>z.slug==="00-index")??p(e)[0];await v(B.slug)}});const c=[{label:"🌅 기초",range:[0,2]},{label:"📊 STOM 데이터",range:[3,5]},{label:"🛠 운영",range:[6,8]},{label:"🔌 레퍼런스",range:[9,99]}];let d=q(()=>c.map(B=>({label:B.label,docs:p(e).filter(z=>z.order>=B.range[0]&&z.order<=B.range[1])})).filter(B=>B.docs.length>0)),f=q(()=>{if(!p(o))return"";let B=pa.parse(p(o));return B=B.replace(/
                /g,(z,H)=>H.startsWith("http")||H.startsWith("/")||H.startsWith("#")?z:``),B});function h(B){const H=B.target.closest?.("a.docs-internal-link");if(H){B.preventDefault();const Y=H.dataset.docSlug;Y&&v(Y)}}let g=q(()=>p(e).find(B=>B.slug===p(n)));var m=qht(),x=lr(m),b=y(x),w=_(y(b),4),S=_(y(w)),C=_(x,2),T=y(C),k=y(T);{var D=B=>{var z=Nht();F(B,z)},M=B=>{var z=Oht(),H=y(z),Y=y(H),$=_(H,2);U(()=>A(Y,`⚠ ${p(i)??""}`)),cr("click",$,u),F(B,z)},L=B=>{var z=Vht(),H=lr(z);nt(H,17,()=>p(d),ut,(Q,at)=>{var et=Bht(),_t=y(et),Ot=y(_t),xt=_(_t,2);nt(xt,17,()=>p(at).docs,ut,(mt,wt)=>{var ft=zht(),K=y(ft),Ct=y(K),Mt=_(K,2),zt=y(Mt);U(Yt=>{xe(ft,"data-active",p(n)===p(wt).slug?"true":"false"),xe(ft,"title",p(wt).name),A(Ct,Yt),A(zt,p(wt).title)},[()=>String(p(wt).order).padStart(2,"0")]),cr("click",ft,()=>v(p(wt).slug)),F(mt,ft)}),U(()=>A(Ot,p(at).label)),F(Q,et)});var Y=_(H,2),$=y(Y),Z=y($);Wn(Z,()=>Ko.refresh,!0),cr("click",$,u),F(B,z)};It(k,B=>{p(a)?B(D):p(i)?B(M,1):B(L,-1)})}var I=_(T,2),P=y(I);{var E=B=>{var z=Fht();F(B,z)},N=B=>{var z=Ght(),H=y(z);U(()=>A(H,`⚠ ${p(s)??""}`)),F(B,z)},O=B=>{var z=Hht();F(B,z)},V=B=>{var z=Uht(),H=lr(z),Y=y(H);{var $=Q=>{var at=Wht(),et=lr(at),_t=y(et),Ot=_(et,2),xt=y(Ot);U((mt,wt)=>{A(_t,p(g).slug),A(xt,`${mt??""} · + ${wt??""} 수정`)},[()=>Ft.bytes(p(g).size_bytes/1024),()=>Ft.relative(p(g).modified_at*1e3)]),F(Q,at)};It(Y,Q=>{p(g)&&Q($)})}var Z=_(H,2);Wn(Z,()=>p(f),!0),cr("click",Z,h),F(B,z)};It(P,B=>{p(l)?B(E):p(s)?B(N,1):p(o)?B(V,-1):B(O,2)})}U(()=>A(S,`${p(e).length??""} 개 문서`)),F(r,m),Yr()}an(["click"]);const ap={status:()=>Qe("/api/training/status"),history:(r=200)=>Qe(`/api/training/history?limit=${r}`),artifacts:()=>Qe("/api/training/artifacts"),gpu:()=>Qe("/api/training/gpu"),system:()=>Qe("/api/training/system"),...lo};let q2=[],NP=null;function Yht(){for(const r of q2)clearInterval(r);q2=[]}async function OP(){const r=await ap.status();r&&(Tv.set(r),Km.set(new Date().toLocaleTimeString("ko-KR",{timeZone:"Asia/Seoul"})))}async function zP(){const r=await ap.history(200);if(r&&(nS.set(r),Array.isArray(r.points))){const t=r.points.map(a=>({step:a.step,loss:a.loss})),e=[r.run_name,r.stage,r.source_log_path].filter(Boolean).join("|")||null;e!==NP?(NP=e,oG(t)):sG(t)}}async function BP(){const r=await ap.artifacts();r&&HE.set(r)}async function VP(){const r=await ap.gpu();if(!r)return;oS.set(r);const t=r.gpus?.[0];t&&iG({util:t.utilization_gpu_percent??null,temp:t.temperature_c??null,vram:t.memory_used_percent??null,ts:Date.now()})}async function FP(){const r=await ap.system();r&&(jm.set(r),nG({cpuUtil:r.cpu?.utilization_percent??null,cpuTemp:r.cpu?.temperature_c??null,cpuTempPct:r.cpu?.temperature_percent??null,ram:r.memory?.used_percent??null,ts:Date.now()}))}function HB(){Yht();const r=e7(tv);OP(),zP(),BP(),VP(),FP(),q2.push(window.setInterval(OP,r*1e3),window.setInterval(zP,r*1e3),window.setInterval(BP,3e4),window.setInterval(VP,r*1e3),window.setInterval(FP,r*1e3))}let GP=null;function Zht(){GP?.(),GP=tv.subscribe(()=>{HB()})}var Xht=G(" ",1),jht=G('
                ');function Kht(r,t){$r(t,!0);function e(){if(typeof window>"u")return null;const T=new URLSearchParams(window.location.search).get("tab");if(T==="rl-lab"||T==="rl-trading")return"rl";if(T)return T;const k=window.location.pathname.replace(/\/+$/,"");return k==="/daily-rl-guide"||k==="/daily-ohlcv/rl-guide"?"daily-rl-guide":k==="/daily-ohlcv"||k==="/daily"?"daily-ohlcv":k==="/rl"||k==="/rl-lab"||k==="/v2/rl-trading"||k==="/v2/rl-lab"?"rl":k==="/training"||k==="/dashboard"?"live-training":null}mn(()=>{const T=e();T&&Wc.set(T),Zht(),HB()});let a=rt("live-training");Wc.subscribe(T=>W(a,T,!0));let i=rt(!1);xc.subscribe(T=>W(i,T,!0));var n=jht(),o=y(n);_G(o,{});var s=_(o,2),l=y(s);wG(l,{});var u=_(l,2),v=y(u);{var c=T=>{var k=Xht(),D=lr(k);TG(D,{});var M=_(D,2);olt(M,{}),F(T,k)},d=T=>{klt(T,{})},f=T=>{Qlt(T,{})},h=T=>{svt(T,{})},g=T=>{rdt(T,{})},m=T=>{jdt(T,{})},x=T=>{wft(T,{})},b=T=>{Mft(T,{})},w=T=>{Oft(T,{})},S=T=>{Yft(T,{})},C=T=>{$ht(T,{})};It(v,T=>{p(a)==="live-training"?T(c):p(a)==="forecast"?T(d,1):p(a)==="stom"?T(f,2):p(a)==="rl"?T(h,3):p(a)==="daily-ohlcv"?T(g,4):p(a)==="daily-rl-guide"?T(m,5):p(a)==="artifacts"?T(x,6):p(a)==="history"?T(b,7):p(a)==="system-health"?T(w,8):p(a)==="settings"?T(S,9):p(a)==="docs"&&T(C,10)})}U(()=>xe(n,"data-sidebar",p(i)?"collapsed":"expanded")),F(r,n),Yr()}const CA=document.getElementById("app");if(!CA)throw new Error("#app element missing — index.html 손상");CA.innerHTML="";B7(Kht,{target:CA}); +//# sourceMappingURL=index-DPLWELOm.js.map diff --git a/webui/static/v2/dist/assets/index-DPLWELOm.js.map b/webui/static/v2/dist/assets/index-DPLWELOm.js.map new file mode 100644 index 000000000..36b767510 --- /dev/null +++ b/webui/static/v2/dist/assets/index-DPLWELOm.js.map @@ -0,0 +1 @@ +{"version":3,"file":"index-DPLWELOm.js","sources":["../../../../v2_src/node_modules/esm-env/false.js","../../../../v2_src/node_modules/svelte/src/internal/shared/utils.js","../../../../v2_src/node_modules/svelte/src/internal/client/constants.js","../../../../v2_src/node_modules/svelte/src/internal/shared/errors.js","../../../../v2_src/node_modules/svelte/src/internal/client/errors.js","../../../../v2_src/node_modules/svelte/src/constants.js","../../../../v2_src/node_modules/svelte/src/internal/client/warnings.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/equality.js","../../../../v2_src/node_modules/svelte/src/internal/flags/index.js","../../../../v2_src/node_modules/svelte/src/internal/client/context.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/task.js","../../../../v2_src/node_modules/svelte/src/internal/client/error-handling.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/status.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/utils.js","../../../../v2_src/node_modules/svelte/src/store/utils.js","../../../../v2_src/node_modules/svelte/src/store/shared/index.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/store.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/batch.js","../../../../v2_src/node_modules/svelte/src/reactivity/create-subscriber.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/blocks/boundary.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/async.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/deriveds.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/sources.js","../../../../v2_src/node_modules/svelte/src/internal/client/proxy.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/operations.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/elements/misc.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/elements/bindings/shared.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/effects.js","../../../../v2_src/node_modules/svelte/src/internal/client/runtime.js","../../../../v2_src/node_modules/svelte/src/utils.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/elements/events.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/reconciler.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/template.js","../../../../v2_src/node_modules/svelte/src/internal/client/render.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/blocks/branches.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/blocks/if.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/blocks/each.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/blocks/html.js","../../../../v2_src/node_modules/clsx/dist/clsx.mjs","../../../../v2_src/node_modules/svelte/src/internal/shared/attributes.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/elements/class.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/elements/style.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/elements/bindings/select.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/elements/attributes.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/elements/bindings/input.js","../../../../v2_src/node_modules/svelte/src/internal/client/dom/elements/bindings/this.js","../../../../v2_src/node_modules/svelte/src/internal/client/reactivity/props.js","../../../../v2_src/node_modules/svelte/src/index-client.js","../../../../v2_src/node_modules/svelte/src/version.js","../../../../v2_src/node_modules/svelte/src/internal/disclose-version.js","../../../../v2_src/src/lib/stores.ts","../../../../v2_src/src/lib/icons.ts","../../../../v2_src/src/lib/format.ts","../../../../v2_src/src/layout/Sidebar.svelte","../../../../v2_src/src/layout/Header.svelte","../../../../v2_src/src/layout/HeroStrip.svelte","../../../../v2_src/node_modules/echarts/node_modules/tslib/tslib.es6.js","../../../../v2_src/node_modules/zrender/lib/core/env.js","../../../../v2_src/node_modules/zrender/lib/core/platform.js","../../../../v2_src/node_modules/zrender/lib/core/util.js","../../../../v2_src/node_modules/zrender/node_modules/tslib/tslib.es6.js","../../../../v2_src/node_modules/zrender/lib/core/vector.js","../../../../v2_src/node_modules/zrender/lib/mixin/Draggable.js","../../../../v2_src/node_modules/zrender/lib/core/Eventful.js","../../../../v2_src/node_modules/zrender/lib/core/fourPointsTransform.js","../../../../v2_src/node_modules/zrender/lib/core/dom.js","../../../../v2_src/node_modules/zrender/lib/core/event.js","../../../../v2_src/node_modules/zrender/lib/core/GestureMgr.js","../../../../v2_src/node_modules/zrender/lib/core/matrix.js","../../../../v2_src/node_modules/zrender/lib/core/Point.js","../../../../v2_src/node_modules/zrender/lib/core/BoundingRect.js","../../../../v2_src/node_modules/zrender/lib/Handler.js","../../../../v2_src/node_modules/zrender/lib/core/timsort.js","../../../../v2_src/node_modules/zrender/lib/graphic/constants.js","../../../../v2_src/node_modules/zrender/lib/Storage.js","../../../../v2_src/node_modules/zrender/lib/animation/requestAnimationFrame.js","../../../../v2_src/node_modules/zrender/lib/animation/easing.js","../../../../v2_src/node_modules/zrender/lib/core/curve.js","../../../../v2_src/node_modules/zrender/lib/animation/cubicEasing.js","../../../../v2_src/node_modules/zrender/lib/animation/Clip.js","../../../../v2_src/node_modules/zrender/lib/core/LRU.js","../../../../v2_src/node_modules/zrender/lib/tool/color.js","../../../../v2_src/node_modules/zrender/lib/svg/helper.js","../../../../v2_src/node_modules/zrender/lib/animation/Animator.js","../../../../v2_src/node_modules/zrender/lib/animation/Animation.js","../../../../v2_src/node_modules/zrender/lib/dom/HandlerProxy.js","../../../../v2_src/node_modules/zrender/lib/config.js","../../../../v2_src/node_modules/zrender/lib/core/Transformable.js","../../../../v2_src/node_modules/zrender/lib/contain/text.js","../../../../v2_src/node_modules/zrender/lib/Element.js","../../../../v2_src/node_modules/zrender/lib/graphic/Group.js","../../../../v2_src/node_modules/zrender/lib/zrender.js","../../../../v2_src/node_modules/echarts/lib/util/number.js","../../../../v2_src/node_modules/echarts/lib/util/log.js","../../../../v2_src/node_modules/echarts/lib/util/model.js","../../../../v2_src/node_modules/echarts/lib/util/clazz.js","../../../../v2_src/node_modules/echarts/lib/model/mixin/makeStyleMapper.js","../../../../v2_src/node_modules/echarts/lib/model/mixin/areaStyle.js","../../../../v2_src/node_modules/zrender/lib/graphic/helper/image.js","../../../../v2_src/node_modules/zrender/lib/graphic/helper/parseText.js","../../../../v2_src/node_modules/zrender/lib/graphic/Displayable.js","../../../../v2_src/node_modules/zrender/lib/core/bbox.js","../../../../v2_src/node_modules/zrender/lib/core/PathProxy.js","../../../../v2_src/node_modules/zrender/lib/contain/line.js","../../../../v2_src/node_modules/zrender/lib/contain/cubic.js","../../../../v2_src/node_modules/zrender/lib/contain/quadratic.js","../../../../v2_src/node_modules/zrender/lib/contain/util.js","../../../../v2_src/node_modules/zrender/lib/contain/arc.js","../../../../v2_src/node_modules/zrender/lib/contain/windingLine.js","../../../../v2_src/node_modules/zrender/lib/contain/path.js","../../../../v2_src/node_modules/zrender/lib/graphic/Path.js","../../../../v2_src/node_modules/zrender/lib/graphic/TSpan.js","../../../../v2_src/node_modules/zrender/lib/graphic/Image.js","../../../../v2_src/node_modules/zrender/lib/graphic/helper/roundRect.js","../../../../v2_src/node_modules/zrender/lib/graphic/helper/subPixelOptimize.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/Rect.js","../../../../v2_src/node_modules/zrender/lib/graphic/Text.js","../../../../v2_src/node_modules/echarts/lib/util/innerStore.js","../../../../v2_src/node_modules/echarts/lib/util/states.js","../../../../v2_src/node_modules/zrender/lib/tool/transformPath.js","../../../../v2_src/node_modules/zrender/lib/tool/path.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/Circle.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/Ellipse.js","../../../../v2_src/node_modules/zrender/lib/graphic/helper/roundSector.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/Sector.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/Ring.js","../../../../v2_src/node_modules/zrender/lib/graphic/helper/smoothBezier.js","../../../../v2_src/node_modules/zrender/lib/graphic/helper/poly.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/Polygon.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/Polyline.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/Line.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/BezierCurve.js","../../../../v2_src/node_modules/zrender/lib/graphic/shape/Arc.js","../../../../v2_src/node_modules/zrender/lib/graphic/CompoundPath.js","../../../../v2_src/node_modules/zrender/lib/graphic/Gradient.js","../../../../v2_src/node_modules/zrender/lib/graphic/LinearGradient.js","../../../../v2_src/node_modules/zrender/lib/graphic/RadialGradient.js","../../../../v2_src/node_modules/zrender/lib/core/OrientedBoundingRect.js","../../../../v2_src/node_modules/zrender/lib/graphic/IncrementalDisplayable.js","../../../../v2_src/node_modules/echarts/lib/animation/basicTransition.js","../../../../v2_src/node_modules/echarts/lib/util/graphic.js","../../../../v2_src/node_modules/echarts/lib/label/labelStyle.js","../../../../v2_src/node_modules/echarts/lib/model/mixin/textStyle.js","../../../../v2_src/node_modules/echarts/lib/model/mixin/lineStyle.js","../../../../v2_src/node_modules/echarts/lib/model/mixin/itemStyle.js","../../../../v2_src/node_modules/echarts/lib/model/Model.js","../../../../v2_src/node_modules/echarts/lib/util/component.js","../../../../v2_src/node_modules/echarts/lib/i18n/langEN.js","../../../../v2_src/node_modules/echarts/lib/i18n/langZH.js","../../../../v2_src/node_modules/echarts/lib/core/locale.js","../../../../v2_src/node_modules/echarts/lib/util/time.js","../../../../v2_src/node_modules/echarts/lib/util/format.js","../../../../v2_src/node_modules/echarts/lib/util/layout.js","../../../../v2_src/node_modules/echarts/lib/model/Component.js","../../../../v2_src/node_modules/echarts/lib/model/globalDefault.js","../../../../v2_src/node_modules/echarts/lib/util/types.js","../../../../v2_src/node_modules/echarts/lib/data/helper/sourceHelper.js","../../../../v2_src/node_modules/echarts/lib/model/internalComponentCreator.js","../../../../v2_src/node_modules/echarts/lib/model/mixin/palette.js","../../../../v2_src/node_modules/echarts/lib/model/Global.js","../../../../v2_src/node_modules/echarts/lib/core/ExtensionAPI.js","../../../../v2_src/node_modules/echarts/lib/core/CoordinateSystem.js","../../../../v2_src/node_modules/echarts/lib/model/OptionManager.js","../../../../v2_src/node_modules/echarts/lib/preprocessor/helper/compatStyle.js","../../../../v2_src/node_modules/echarts/lib/preprocessor/backwardCompat.js","../../../../v2_src/node_modules/echarts/lib/processor/dataStack.js","../../../../v2_src/node_modules/echarts/lib/data/Source.js","../../../../v2_src/node_modules/echarts/lib/data/helper/dataProvider.js","../../../../v2_src/node_modules/echarts/lib/model/mixin/dataFormat.js","../../../../v2_src/node_modules/echarts/lib/core/task.js","../../../../v2_src/node_modules/echarts/lib/data/helper/dataValueHelper.js","../../../../v2_src/node_modules/echarts/lib/data/helper/transform.js","../../../../v2_src/node_modules/echarts/lib/data/DataStore.js","../../../../v2_src/node_modules/echarts/lib/data/helper/sourceManager.js","../../../../v2_src/node_modules/echarts/lib/component/tooltip/tooltipMarkup.js","../../../../v2_src/node_modules/echarts/lib/component/tooltip/seriesFormatTooltip.js","../../../../v2_src/node_modules/echarts/lib/model/Series.js","../../../../v2_src/node_modules/echarts/lib/view/Component.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/createRenderPlanner.js","../../../../v2_src/node_modules/echarts/lib/view/Chart.js","../../../../v2_src/node_modules/echarts/lib/util/throttle.js","../../../../v2_src/node_modules/echarts/lib/visual/style.js","../../../../v2_src/node_modules/echarts/lib/loading/default.js","../../../../v2_src/node_modules/echarts/lib/core/Scheduler.js","../../../../v2_src/node_modules/echarts/lib/theme/light.js","../../../../v2_src/node_modules/echarts/lib/theme/dark.js","../../../../v2_src/node_modules/echarts/lib/util/ECEventProcessor.js","../../../../v2_src/node_modules/echarts/lib/visual/symbol.js","../../../../v2_src/node_modules/echarts/lib/visual/helper.js","../../../../v2_src/node_modules/echarts/lib/legacy/dataSelectAction.js","../../../../v2_src/node_modules/echarts/lib/util/event.js","../../../../v2_src/node_modules/zrender/lib/core/WeakMap.js","../../../../v2_src/node_modules/echarts/lib/util/symbol.js","../../../../v2_src/node_modules/zrender/lib/canvas/helper.js","../../../../v2_src/node_modules/zrender/lib/canvas/dashStyle.js","../../../../v2_src/node_modules/zrender/lib/canvas/graphic.js","../../../../v2_src/node_modules/echarts/lib/util/decal.js","../../../../v2_src/node_modules/echarts/lib/visual/decal.js","../../../../v2_src/node_modules/echarts/lib/core/lifecycle.js","../../../../v2_src/node_modules/echarts/lib/core/impl.js","../../../../v2_src/node_modules/echarts/lib/core/echarts.js","../../../../v2_src/node_modules/echarts/lib/extension.js","../../../../v2_src/node_modules/echarts/lib/data/DataDiffer.js","../../../../v2_src/node_modules/echarts/lib/data/helper/dimensionHelper.js","../../../../v2_src/node_modules/echarts/lib/data/SeriesDimensionDefine.js","../../../../v2_src/node_modules/echarts/lib/data/helper/SeriesDataSchema.js","../../../../v2_src/node_modules/echarts/lib/data/SeriesData.js","../../../../v2_src/node_modules/echarts/lib/data/helper/createDimensions.js","../../../../v2_src/node_modules/echarts/lib/model/referHelper.js","../../../../v2_src/node_modules/echarts/lib/data/helper/dataStackHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/createSeriesData.js","../../../../v2_src/node_modules/echarts/lib/scale/Scale.js","../../../../v2_src/node_modules/echarts/lib/data/OrdinalMeta.js","../../../../v2_src/node_modules/echarts/lib/scale/helper.js","../../../../v2_src/node_modules/echarts/lib/scale/Ordinal.js","../../../../v2_src/node_modules/echarts/lib/scale/Interval.js","../../../../v2_src/node_modules/echarts/lib/util/vendor.js","../../../../v2_src/node_modules/echarts/lib/layout/barGrid.js","../../../../v2_src/node_modules/echarts/lib/scale/Time.js","../../../../v2_src/node_modules/echarts/lib/scale/Log.js","../../../../v2_src/node_modules/echarts/lib/coord/scaleRawExtentInfo.js","../../../../v2_src/node_modules/echarts/lib/coord/axisHelper.js","../../../../v2_src/node_modules/echarts/lib/coord/axisModelCommonMixin.js","../../../../v2_src/node_modules/zrender/lib/contain/polygon.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/Region.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/parseGeoJson.js","../../../../v2_src/node_modules/echarts/lib/coord/axisTickLabelBuilder.js","../../../../v2_src/node_modules/echarts/lib/coord/Axis.js","../../../../v2_src/node_modules/echarts/lib/label/labelGuideHelper.js","../../../../v2_src/node_modules/echarts/lib/label/labelLayoutHelper.js","../../../../v2_src/node_modules/echarts/lib/label/LabelManager.js","../../../../v2_src/node_modules/echarts/lib/label/installLabelLayout.js","../../../../v2_src/node_modules/zrender/lib/svg/SVGPathRebuilder.js","../../../../v2_src/node_modules/zrender/lib/svg/mapStyleToAttrs.js","../../../../v2_src/node_modules/zrender/lib/svg/core.js","../../../../v2_src/node_modules/zrender/lib/svg/cssClassId.js","../../../../v2_src/node_modules/zrender/lib/svg/cssAnimation.js","../../../../v2_src/node_modules/zrender/lib/svg/cssEmphasis.js","../../../../v2_src/node_modules/zrender/lib/svg/graphic.js","../../../../v2_src/node_modules/zrender/lib/svg/domapi.js","../../../../v2_src/node_modules/zrender/lib/svg/patch.js","../../../../v2_src/node_modules/zrender/lib/svg/Painter.js","../../../../v2_src/node_modules/echarts/lib/renderer/installSVGRenderer.js","../../../../v2_src/node_modules/zrender/lib/canvas/Layer.js","../../../../v2_src/node_modules/zrender/lib/canvas/Painter.js","../../../../v2_src/node_modules/echarts/lib/renderer/installCanvasRenderer.js","../../../../v2_src/node_modules/echarts/lib/chart/line/LineSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/labelHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/Symbol.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/SymbolDraw.js","../../../../v2_src/node_modules/echarts/lib/chart/line/helper.js","../../../../v2_src/node_modules/echarts/lib/chart/line/lineAnimationDiff.js","../../../../v2_src/node_modules/echarts/lib/chart/line/poly.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/createClipPathFromCoordSys.js","../../../../v2_src/node_modules/echarts/lib/coord/CoordinateSystem.js","../../../../v2_src/node_modules/echarts/lib/chart/line/LineView.js","../../../../v2_src/node_modules/echarts/lib/layout/points.js","../../../../v2_src/node_modules/echarts/lib/processor/dataSample.js","../../../../v2_src/node_modules/echarts/lib/chart/line/install.js","../../../../v2_src/node_modules/echarts/lib/chart/bar/BaseBarSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/bar/BarSeries.js","../../../../v2_src/node_modules/echarts/lib/util/shape/sausage.js","../../../../v2_src/node_modules/echarts/lib/label/sectorLabel.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/sectorHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/bar/BarView.js","../../../../v2_src/node_modules/echarts/lib/chart/bar/install.js","../../../../v2_src/node_modules/echarts/lib/chart/pie/pieLayout.js","../../../../v2_src/node_modules/echarts/lib/processor/dataFilter.js","../../../../v2_src/node_modules/echarts/lib/chart/pie/labelLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/pie/PieView.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/createSeriesDataSimply.js","../../../../v2_src/node_modules/echarts/lib/visual/LegendVisualProvider.js","../../../../v2_src/node_modules/echarts/lib/chart/pie/PieSeries.js","../../../../v2_src/node_modules/echarts/lib/processor/negativeDataFilter.js","../../../../v2_src/node_modules/echarts/lib/chart/pie/install.js","../../../../v2_src/node_modules/echarts/lib/chart/scatter/ScatterSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/LargeSymbolDraw.js","../../../../v2_src/node_modules/echarts/lib/chart/scatter/ScatterView.js","../../../../v2_src/node_modules/echarts/lib/coord/cartesian/GridModel.js","../../../../v2_src/node_modules/echarts/lib/coord/cartesian/AxisModel.js","../../../../v2_src/node_modules/echarts/lib/coord/axisDefault.js","../../../../v2_src/node_modules/echarts/lib/coord/axisCommonTypes.js","../../../../v2_src/node_modules/echarts/lib/coord/axisModelCreator.js","../../../../v2_src/node_modules/echarts/lib/coord/cartesian/Cartesian.js","../../../../v2_src/node_modules/echarts/lib/coord/cartesian/Cartesian2D.js","../../../../v2_src/node_modules/echarts/lib/coord/cartesian/Axis2D.js","../../../../v2_src/node_modules/echarts/lib/coord/cartesian/cartesianAxisHelper.js","../../../../v2_src/node_modules/echarts/lib/coord/axisAlignTicks.js","../../../../v2_src/node_modules/echarts/lib/coord/cartesian/Grid.js","../../../../v2_src/node_modules/echarts/lib/component/axis/AxisBuilder.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/modelHelper.js","../../../../v2_src/node_modules/echarts/lib/component/axis/AxisView.js","../../../../v2_src/node_modules/echarts/lib/component/axis/axisSplitHelper.js","../../../../v2_src/node_modules/echarts/lib/component/axis/CartesianAxisView.js","../../../../v2_src/node_modules/echarts/lib/component/grid/installSimple.js","../../../../v2_src/node_modules/echarts/lib/chart/scatter/install.js","../../../../v2_src/node_modules/echarts/lib/chart/radar/radarLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/radar/backwardCompat.js","../../../../v2_src/node_modules/echarts/lib/chart/radar/RadarView.js","../../../../v2_src/node_modules/echarts/lib/chart/radar/RadarSeries.js","../../../../v2_src/node_modules/echarts/lib/coord/radar/RadarModel.js","../../../../v2_src/node_modules/echarts/lib/component/radar/RadarView.js","../../../../v2_src/node_modules/echarts/lib/coord/radar/IndicatorAxis.js","../../../../v2_src/node_modules/echarts/lib/coord/radar/Radar.js","../../../../v2_src/node_modules/echarts/lib/component/radar/install.js","../../../../v2_src/node_modules/echarts/lib/chart/radar/install.js","../../../../v2_src/node_modules/echarts/lib/component/helper/interactionMutex.js","../../../../v2_src/node_modules/echarts/lib/component/helper/RoamController.js","../../../../v2_src/node_modules/echarts/lib/component/helper/roamHelper.js","../../../../v2_src/node_modules/echarts/lib/component/helper/cursorHelper.js","../../../../v2_src/node_modules/zrender/lib/tool/parseXML.js","../../../../v2_src/node_modules/zrender/lib/tool/parseSVG.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/GeoSVGResource.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/fix/nanhai.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/fix/textCoord.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/fix/diaoyuIsland.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/GeoJSONResource.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/geoSourceManager.js","../../../../v2_src/node_modules/echarts/lib/component/helper/MapDraw.js","../../../../v2_src/node_modules/echarts/lib/chart/map/MapView.js","../../../../v2_src/node_modules/echarts/lib/chart/map/MapSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/map/mapDataStatistic.js","../../../../v2_src/node_modules/echarts/lib/chart/map/mapSymbolLayout.js","../../../../v2_src/node_modules/echarts/lib/coord/View.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/Geo.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/geoCreator.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/GeoModel.js","../../../../v2_src/node_modules/echarts/lib/action/roamHelper.js","../../../../v2_src/node_modules/echarts/lib/component/geo/GeoView.js","../../../../v2_src/node_modules/echarts/lib/component/geo/install.js","../../../../v2_src/node_modules/echarts/lib/chart/map/install.js","../../../../v2_src/node_modules/echarts/lib/chart/tree/layoutHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/tree/TreeView.js","../../../../v2_src/node_modules/echarts/lib/data/helper/linkSeriesData.js","../../../../v2_src/node_modules/echarts/lib/data/Tree.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/treeHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/tree/TreeSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/tree/traversalHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/tree/treeLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/tree/treeVisual.js","../../../../v2_src/node_modules/echarts/lib/chart/tree/treeAction.js","../../../../v2_src/node_modules/echarts/lib/chart/tree/install.js","../../../../v2_src/node_modules/echarts/lib/chart/treemap/treemapAction.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/enableAriaDecalForTree.js","../../../../v2_src/node_modules/echarts/lib/chart/treemap/TreemapSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/treemap/Breadcrumb.js","../../../../v2_src/node_modules/echarts/lib/util/animation.js","../../../../v2_src/node_modules/echarts/lib/chart/treemap/TreemapView.js","../../../../v2_src/node_modules/echarts/lib/visual/VisualMapping.js","../../../../v2_src/node_modules/echarts/lib/chart/treemap/treemapVisual.js","../../../../v2_src/node_modules/echarts/lib/chart/treemap/treemapLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/treemap/install.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/categoryFilter.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/categoryVisual.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/edgeVisual.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/multipleGraphEdgeHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/simpleLayoutHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/simpleLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/graphHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/circularLayoutHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/circularLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/forceHelper.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/forceLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/createView.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/LinePath.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/Line.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/LineDraw.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/adjustEdge.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/GraphView.js","../../../../v2_src/node_modules/echarts/lib/data/Graph.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/createGraphFromNodeEdge.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/GraphSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/graph/install.js","../../../../v2_src/node_modules/echarts/lib/chart/gauge/PointerPath.js","../../../../v2_src/node_modules/echarts/lib/chart/gauge/GaugeView.js","../../../../v2_src/node_modules/echarts/lib/chart/gauge/GaugeSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/gauge/install.js","../../../../v2_src/node_modules/echarts/lib/chart/funnel/FunnelView.js","../../../../v2_src/node_modules/echarts/lib/chart/funnel/FunnelSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/funnel/funnelLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/funnel/install.js","../../../../v2_src/node_modules/echarts/lib/chart/parallel/ParallelView.js","../../../../v2_src/node_modules/echarts/lib/chart/parallel/ParallelSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/parallel/parallelVisual.js","../../../../v2_src/node_modules/echarts/lib/coord/parallel/parallelPreprocessor.js","../../../../v2_src/node_modules/echarts/lib/component/parallel/ParallelView.js","../../../../v2_src/node_modules/echarts/lib/coord/parallel/ParallelModel.js","../../../../v2_src/node_modules/echarts/lib/coord/parallel/ParallelAxis.js","../../../../v2_src/node_modules/echarts/lib/component/helper/sliderMove.js","../../../../v2_src/node_modules/echarts/lib/coord/parallel/Parallel.js","../../../../v2_src/node_modules/echarts/lib/coord/parallel/parallelCreator.js","../../../../v2_src/node_modules/echarts/lib/coord/parallel/AxisModel.js","../../../../v2_src/node_modules/echarts/lib/component/helper/BrushController.js","../../../../v2_src/node_modules/echarts/lib/component/helper/brushHelper.js","../../../../v2_src/node_modules/echarts/lib/component/axis/ParallelAxisView.js","../../../../v2_src/node_modules/echarts/lib/component/axis/parallelAxisAction.js","../../../../v2_src/node_modules/echarts/lib/component/parallel/install.js","../../../../v2_src/node_modules/echarts/lib/chart/parallel/install.js","../../../../v2_src/node_modules/echarts/lib/chart/sankey/SankeyView.js","../../../../v2_src/node_modules/echarts/lib/chart/sankey/SankeySeries.js","../../../../v2_src/node_modules/echarts/lib/chart/sankey/sankeyLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/sankey/sankeyVisual.js","../../../../v2_src/node_modules/echarts/lib/chart/sankey/install.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/whiskerBoxCommon.js","../../../../v2_src/node_modules/echarts/lib/chart/boxplot/BoxplotSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/boxplot/BoxplotView.js","../../../../v2_src/node_modules/echarts/lib/chart/boxplot/boxplotLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/boxplot/prepareBoxplotData.js","../../../../v2_src/node_modules/echarts/lib/chart/boxplot/boxplotTransform.js","../../../../v2_src/node_modules/echarts/lib/chart/boxplot/install.js","../../../../v2_src/node_modules/echarts/lib/chart/candlestick/candlestickVisual.js","../../../../v2_src/node_modules/echarts/lib/chart/candlestick/CandlestickView.js","../../../../v2_src/node_modules/echarts/lib/chart/candlestick/CandlestickSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/candlestick/preprocessor.js","../../../../v2_src/node_modules/echarts/lib/chart/candlestick/candlestickLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/candlestick/install.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/EffectSymbol.js","../../../../v2_src/node_modules/echarts/lib/chart/effectScatter/EffectScatterView.js","../../../../v2_src/node_modules/echarts/lib/chart/effectScatter/EffectScatterSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/effectScatter/install.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/EffectLine.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/Polyline.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/EffectPolyline.js","../../../../v2_src/node_modules/echarts/lib/chart/helper/LargeLineDraw.js","../../../../v2_src/node_modules/echarts/lib/chart/lines/linesLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/lines/LinesView.js","../../../../v2_src/node_modules/echarts/lib/chart/lines/LinesSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/lines/linesVisual.js","../../../../v2_src/node_modules/echarts/lib/chart/lines/install.js","../../../../v2_src/node_modules/echarts/lib/chart/heatmap/HeatmapLayer.js","../../../../v2_src/node_modules/echarts/lib/chart/heatmap/HeatmapView.js","../../../../v2_src/node_modules/echarts/lib/chart/heatmap/HeatmapSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/heatmap/install.js","../../../../v2_src/node_modules/echarts/lib/chart/bar/PictorialBarView.js","../../../../v2_src/node_modules/echarts/lib/chart/bar/PictorialBarSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/bar/installPictorialBar.js","../../../../v2_src/node_modules/echarts/lib/chart/themeRiver/ThemeRiverView.js","../../../../v2_src/node_modules/echarts/lib/chart/themeRiver/ThemeRiverSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/themeRiver/themeRiverLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/themeRiver/install.js","../../../../v2_src/node_modules/echarts/lib/chart/sunburst/SunburstPiece.js","../../../../v2_src/node_modules/echarts/lib/chart/sunburst/sunburstAction.js","../../../../v2_src/node_modules/echarts/lib/chart/sunburst/SunburstView.js","../../../../v2_src/node_modules/echarts/lib/chart/sunburst/SunburstSeries.js","../../../../v2_src/node_modules/echarts/lib/chart/sunburst/sunburstLayout.js","../../../../v2_src/node_modules/echarts/lib/chart/sunburst/sunburstVisual.js","../../../../v2_src/node_modules/echarts/lib/chart/sunburst/install.js","../../../../v2_src/node_modules/echarts/lib/chart/custom/CustomSeries.js","../../../../v2_src/node_modules/echarts/lib/coord/cartesian/prepareCustom.js","../../../../v2_src/node_modules/echarts/lib/coord/geo/prepareCustom.js","../../../../v2_src/node_modules/echarts/lib/coord/single/prepareCustom.js","../../../../v2_src/node_modules/echarts/lib/coord/polar/prepareCustom.js","../../../../v2_src/node_modules/echarts/lib/coord/calendar/prepareCustom.js","../../../../v2_src/node_modules/echarts/lib/util/styleCompat.js","../../../../v2_src/node_modules/echarts/lib/animation/customGraphicTransition.js","../../../../v2_src/node_modules/echarts/lib/animation/customGraphicKeyframeAnimation.js","../../../../v2_src/node_modules/echarts/lib/chart/custom/CustomView.js","../../../../v2_src/node_modules/echarts/lib/chart/custom/install.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/BaseAxisPointer.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/viewHelper.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/CartesianAxisPointer.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/AxisPointerModel.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/globalListener.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/AxisPointerView.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/findPointFromSeries.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/axisTrigger.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/install.js","../../../../v2_src/node_modules/echarts/lib/component/grid/install.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/PolarAxisPointer.js","../../../../v2_src/node_modules/echarts/lib/coord/polar/PolarModel.js","../../../../v2_src/node_modules/echarts/lib/coord/polar/AxisModel.js","../../../../v2_src/node_modules/echarts/lib/coord/polar/RadiusAxis.js","../../../../v2_src/node_modules/echarts/lib/coord/polar/AngleAxis.js","../../../../v2_src/node_modules/echarts/lib/coord/polar/Polar.js","../../../../v2_src/node_modules/echarts/lib/coord/polar/polarCreator.js","../../../../v2_src/node_modules/echarts/lib/component/axis/AngleAxisView.js","../../../../v2_src/node_modules/echarts/lib/component/axis/RadiusAxisView.js","../../../../v2_src/node_modules/echarts/lib/layout/barPolar.js","../../../../v2_src/node_modules/echarts/lib/component/polar/install.js","../../../../v2_src/node_modules/echarts/lib/coord/single/singleAxisHelper.js","../../../../v2_src/node_modules/echarts/lib/component/axis/SingleAxisView.js","../../../../v2_src/node_modules/echarts/lib/coord/single/AxisModel.js","../../../../v2_src/node_modules/echarts/lib/coord/single/SingleAxis.js","../../../../v2_src/node_modules/echarts/lib/coord/single/Single.js","../../../../v2_src/node_modules/echarts/lib/coord/single/singleCreator.js","../../../../v2_src/node_modules/echarts/lib/component/axisPointer/SingleAxisPointer.js","../../../../v2_src/node_modules/echarts/lib/component/singleAxis/install.js","../../../../v2_src/node_modules/echarts/lib/coord/calendar/CalendarModel.js","../../../../v2_src/node_modules/echarts/lib/component/calendar/CalendarView.js","../../../../v2_src/node_modules/echarts/lib/coord/calendar/Calendar.js","../../../../v2_src/node_modules/echarts/lib/component/calendar/install.js","../../../../v2_src/node_modules/echarts/lib/component/graphic/GraphicModel.js","../../../../v2_src/node_modules/echarts/lib/component/graphic/GraphicView.js","../../../../v2_src/node_modules/echarts/lib/component/graphic/install.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/helper.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/DataZoomModel.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/SelectZoomModel.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/DataZoomView.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/SelectZoomView.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/AxisProxy.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/dataZoomProcessor.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/dataZoomAction.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/installCommon.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/installDataZoomSelect.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/featureManager.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/ToolboxModel.js","../../../../v2_src/node_modules/echarts/lib/component/helper/listComponent.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/ToolboxView.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/feature/SaveAsImage.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/feature/MagicType.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/feature/DataView.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/history.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/feature/Restore.js","../../../../v2_src/node_modules/echarts/lib/component/helper/BrushTargetManager.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/feature/DataZoom.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/install.js","../../../../v2_src/node_modules/echarts/lib/component/tooltip/TooltipModel.js","../../../../v2_src/node_modules/echarts/lib/component/tooltip/helper.js","../../../../v2_src/node_modules/echarts/lib/component/tooltip/TooltipHTMLContent.js","../../../../v2_src/node_modules/echarts/lib/component/tooltip/TooltipRichContent.js","../../../../v2_src/node_modules/echarts/lib/component/tooltip/TooltipView.js","../../../../v2_src/node_modules/echarts/lib/component/tooltip/install.js","../../../../v2_src/node_modules/echarts/lib/component/brush/preprocessor.js","../../../../v2_src/node_modules/echarts/lib/visual/visualSolution.js","../../../../v2_src/node_modules/echarts/lib/component/brush/selector.js","../../../../v2_src/node_modules/echarts/lib/component/brush/visualEncoding.js","../../../../v2_src/node_modules/echarts/lib/component/brush/BrushView.js","../../../../v2_src/node_modules/echarts/lib/component/brush/BrushModel.js","../../../../v2_src/node_modules/echarts/lib/component/toolbox/feature/Brush.js","../../../../v2_src/node_modules/echarts/lib/component/brush/install.js","../../../../v2_src/node_modules/echarts/lib/component/title/install.js","../../../../v2_src/node_modules/echarts/lib/component/timeline/TimelineModel.js","../../../../v2_src/node_modules/echarts/lib/component/timeline/SliderTimelineModel.js","../../../../v2_src/node_modules/echarts/lib/component/timeline/TimelineView.js","../../../../v2_src/node_modules/echarts/lib/component/timeline/TimelineAxis.js","../../../../v2_src/node_modules/echarts/lib/component/timeline/SliderTimelineView.js","../../../../v2_src/node_modules/echarts/lib/component/timeline/timelineAction.js","../../../../v2_src/node_modules/echarts/lib/component/timeline/preprocessor.js","../../../../v2_src/node_modules/echarts/lib/component/timeline/install.js","../../../../v2_src/node_modules/echarts/lib/component/marker/checkMarkerInSeries.js","../../../../v2_src/node_modules/echarts/lib/component/marker/MarkerModel.js","../../../../v2_src/node_modules/echarts/lib/component/marker/MarkPointModel.js","../../../../v2_src/node_modules/echarts/lib/component/marker/markerHelper.js","../../../../v2_src/node_modules/echarts/lib/component/marker/MarkerView.js","../../../../v2_src/node_modules/echarts/lib/component/marker/MarkPointView.js","../../../../v2_src/node_modules/echarts/lib/component/marker/installMarkPoint.js","../../../../v2_src/node_modules/echarts/lib/component/marker/MarkLineModel.js","../../../../v2_src/node_modules/echarts/lib/component/marker/MarkLineView.js","../../../../v2_src/node_modules/echarts/lib/component/marker/installMarkLine.js","../../../../v2_src/node_modules/echarts/lib/component/marker/MarkAreaModel.js","../../../../v2_src/node_modules/echarts/lib/component/marker/MarkAreaView.js","../../../../v2_src/node_modules/echarts/lib/component/marker/installMarkArea.js","../../../../v2_src/node_modules/echarts/lib/component/legend/LegendModel.js","../../../../v2_src/node_modules/echarts/lib/component/legend/LegendView.js","../../../../v2_src/node_modules/echarts/lib/component/legend/legendFilter.js","../../../../v2_src/node_modules/echarts/lib/component/legend/legendAction.js","../../../../v2_src/node_modules/echarts/lib/component/legend/installLegendPlain.js","../../../../v2_src/node_modules/echarts/lib/component/legend/ScrollableLegendModel.js","../../../../v2_src/node_modules/echarts/lib/component/legend/ScrollableLegendView.js","../../../../v2_src/node_modules/echarts/lib/component/legend/scrollableLegendAction.js","../../../../v2_src/node_modules/echarts/lib/component/legend/installLegendScroll.js","../../../../v2_src/node_modules/echarts/lib/component/legend/install.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/InsideZoomModel.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/roams.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/InsideZoomView.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/installDataZoomInside.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/SliderZoomModel.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/SliderZoomView.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/installDataZoomSlider.js","../../../../v2_src/node_modules/echarts/lib/component/dataZoom/install.js","../../../../v2_src/node_modules/echarts/lib/visual/visualDefault.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/VisualMapModel.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/ContinuousModel.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/VisualMapView.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/helper.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/ContinuousView.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/visualMapAction.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/visualEncoding.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/preprocessor.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/installCommon.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/installVisualMapContinuous.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/PiecewiseModel.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/PiecewiseView.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/installVisualMapPiecewise.js","../../../../v2_src/node_modules/echarts/lib/component/visualMap/install.js","../../../../v2_src/node_modules/echarts/lib/visual/aria.js","../../../../v2_src/node_modules/echarts/lib/component/aria/preprocessor.js","../../../../v2_src/node_modules/echarts/lib/component/aria/install.js","../../../../v2_src/node_modules/echarts/lib/util/conditionalExpression.js","../../../../v2_src/node_modules/echarts/lib/component/transform/filterTransform.js","../../../../v2_src/node_modules/echarts/lib/component/transform/sortTransform.js","../../../../v2_src/node_modules/echarts/lib/component/transform/install.js","../../../../v2_src/node_modules/echarts/lib/component/dataset/install.js","../../../../v2_src/node_modules/zrender/lib/tool/convertPath.js","../../../../v2_src/node_modules/zrender/lib/tool/dividePath.js","../../../../v2_src/node_modules/zrender/lib/tool/morphPath.js","../../../../v2_src/node_modules/echarts/lib/animation/morphTransitionHelper.js","../../../../v2_src/node_modules/echarts/lib/animation/universalTransition.js","../../../../v2_src/node_modules/echarts/index.js","../../../../v2_src/src/charts/EChartsRenderer.svelte","../../../../v2_src/src/widgets/W3_LossCurve.svelte","../../../../v2_src/src/widgets/W4_EtaTimeline.svelte","../../../../v2_src/src/widgets/W5_GpuSparkline.svelte","../../../../v2_src/src/widgets/W6_LossVolatility.svelte","../../../../v2_src/src/widgets/W9_LogTail.svelte","../../../../v2_src/src/tabs/LiveTrainingTab.svelte","../../../../v2_src/src/tabs/ForecastWorkbenchTab.svelte","../../../../v2_src/src/tabs/StomDiagnosticsTab.svelte","../../../../v2_src/src/lib/http.ts","../../../../v2_src/src/lib/rlApi.ts","../../../../v2_src/src/lib/rlRows.ts","../../../../v2_src/src/tabs/rlTrading/RLHero.svelte","../../../../v2_src/src/tabs/rlTrading/OrderbookReadinessCard.svelte","../../../../v2_src/src/tabs/rlTrading/RiskSummaryCard.svelte","../../../../v2_src/src/tabs/rlTrading/RunSelector.svelte","../../../../v2_src/src/tabs/rlTrading/RunDetailCard.svelte","../../../../v2_src/src/tabs/rlTrading/OpeningWorkflowCard.svelte","../../../../v2_src/src/tabs/rlTrading/ParticipantProxyCard.svelte","../../../../v2_src/src/lib/safeHtml.ts","../../../../v2_src/src/tabs/rlTrading/chartOptions.ts","../../../../v2_src/src/tabs/rlTrading/EvidenceCharts.svelte","../../../../v2_src/src/tabs/rlTrading/RunTables.svelte","../../../../v2_src/src/tabs/RLTradingTab.svelte","../../../../v2_src/src/lib/dailyOhlcvApi.ts","../../../../v2_src/src/tabs/dailyOhlcv/DailyProgressTimeline.svelte","../../../../v2_src/src/tabs/dailyOhlcv/DailyDbQualityCard.svelte","../../../../v2_src/src/tabs/dailyOhlcv/DailyUniverseCard.svelte","../../../../v2_src/src/tabs/dailyOhlcv/DailyDatasetBuilderCard.svelte","../../../../v2_src/src/tabs/dailyOhlcv/DailyModelResultsCard.svelte","../../../../v2_src/src/tabs/dailyOhlcv/DailyVisualLabCard.svelte","../../../../v2_src/src/tabs/dailyOhlcv/DailyScenarioLabCard.svelte","../../../../v2_src/src/tabs/dailyOhlcv/DailyScenarioRunLedgerCard.svelte","../../../../v2_src/src/tabs/DailyOhlcvTab.svelte","../../../../v2_src/src/tabs/DailyRlGuideTab.svelte","../../../../v2_src/src/tabs/ArtifactsModelsTab.svelte","../../../../v2_src/src/tabs/HistoryRunsTab.svelte","../../../../v2_src/src/tabs/SystemHealthTab.svelte","../../../../v2_src/src/tabs/SettingsTab.svelte","../../../../v2_src/node_modules/marked/lib/marked.esm.js","../../../../v2_src/src/tabs/DocsTab.svelte","../../../../v2_src/src/lib/api.ts","../../../../v2_src/src/lib/polling.ts","../../../../v2_src/src/App.svelte","../../../../v2_src/src/main.ts"],"sourcesContent":["export default false;\n","// Store the references to globals in case someone tries to monkey patch these, causing the below\n// to de-opt (this occurs often when using popular extensions).\nexport var is_array = Array.isArray;\nexport var index_of = Array.prototype.indexOf;\nexport var includes = Array.prototype.includes;\nexport var array_from = Array.from;\nexport var object_keys = Object.keys;\nexport var define_property = Object.defineProperty;\nexport var get_descriptor = Object.getOwnPropertyDescriptor;\nexport var get_descriptors = Object.getOwnPropertyDescriptors;\nexport var object_prototype = Object.prototype;\nexport var array_prototype = Array.prototype;\nexport var get_prototype_of = Object.getPrototypeOf;\nexport var is_extensible = Object.isExtensible;\nexport var has_own_property = Object.prototype.hasOwnProperty;\n\n/**\n * @param {any} thing\n * @returns {thing is Function}\n */\nexport function is_function(thing) {\n\treturn typeof thing === 'function';\n}\n\nexport const noop = () => {};\n\n// Adapted from https://github.com/then/is-promise/blob/master/index.js\n// Distributed under MIT License https://github.com/then/is-promise/blob/master/LICENSE\n\n/**\n * @template [T=any]\n * @param {any} value\n * @returns {value is PromiseLike}\n */\nexport function is_promise(value) {\n\treturn typeof value?.then === 'function';\n}\n\n/** @param {Function} fn */\nexport function run(fn) {\n\treturn fn();\n}\n\n/** @param {Array<() => void>} arr */\nexport function run_all(arr) {\n\tfor (var i = 0; i < arr.length; i++) {\n\t\tarr[i]();\n\t}\n}\n\n/**\n * TODO replace with Promise.withResolvers once supported widely enough\n * @template [T=void]\n */\nexport function deferred() {\n\t/** @type {(value: T) => void} */\n\tvar resolve;\n\n\t/** @type {(reason: any) => void} */\n\tvar reject;\n\n\t/** @type {Promise} */\n\tvar promise = new Promise((res, rej) => {\n\t\tresolve = res;\n\t\treject = rej;\n\t});\n\n\t// @ts-expect-error\n\treturn { promise, resolve, reject };\n}\n\n/**\n * @template V\n * @param {V} value\n * @param {V | (() => V)} fallback\n * @param {boolean} [lazy]\n * @returns {V}\n */\nexport function fallback(value, fallback, lazy = false) {\n\treturn value === undefined\n\t\t? lazy\n\t\t\t? /** @type {() => V} */ (fallback)()\n\t\t\t: /** @type {V} */ (fallback)\n\t\t: value;\n}\n\n/**\n * When encountering a situation like `let [a, b, c] = $derived(blah())`,\n * we need to stash an intermediate value that `a`, `b`, and `c` derive\n * from, in case it's an iterable\n * @template T\n * @param {ArrayLike | Iterable} value\n * @param {number} [n]\n * @returns {Array}\n */\nexport function to_array(value, n) {\n\t// return arrays unchanged\n\tif (Array.isArray(value)) {\n\t\treturn value;\n\t}\n\n\t// if value is not iterable, or `n` is unspecified (indicates a rest\n\t// element, which means we're not concerned about unbounded iterables)\n\t// convert to an array with `Array.from`\n\tif (n === undefined || !(Symbol.iterator in value)) {\n\t\treturn Array.from(value);\n\t}\n\n\t// otherwise, populate an array with `n` values\n\n\t/** @type {T[]} */\n\tconst array = [];\n\n\tfor (const element of value) {\n\t\tarray.push(element);\n\t\tif (array.length === n) break;\n\t}\n\n\treturn array;\n}\n\n/**\n * @param {Record} obj\n * @param {Array} keys\n * @returns {Record}\n */\nexport function exclude_from_object(obj, keys) {\n\t/** @type {Record} */\n\tvar result = {};\n\n\tfor (var key in obj) {\n\t\tif (!keys.includes(key)) {\n\t\t\tresult[key] = obj[key];\n\t\t}\n\t}\n\n\tfor (var symbol of Object.getOwnPropertySymbols(obj)) {\n\t\tif (Object.propertyIsEnumerable.call(obj, symbol) && !keys.includes(symbol)) {\n\t\t\tresult[symbol] = obj[symbol];\n\t\t}\n\t}\n\n\treturn result;\n}\n","// General flags\nexport const DERIVED = 1 << 1;\nexport const EFFECT = 1 << 2;\nexport const RENDER_EFFECT = 1 << 3;\n/**\n * An effect that does not destroy its child effects when it reruns.\n * Runs as part of render effects, i.e. not eagerly as part of tree traversal or effect flushing.\n */\nexport const MANAGED_EFFECT = 1 << 24;\n/**\n * An effect that does not destroy its child effects when it reruns (like MANAGED_EFFECT).\n * Runs eagerly as part of tree traversal or effect flushing.\n */\nexport const BLOCK_EFFECT = 1 << 4;\nexport const BRANCH_EFFECT = 1 << 5;\nexport const ROOT_EFFECT = 1 << 6;\nexport const BOUNDARY_EFFECT = 1 << 7;\n/**\n * Indicates that a reaction is connected to an effect root — either it is an effect,\n * or it is a derived that is depended on by at least one effect. If a derived has\n * no dependents, we can disconnect it from the graph, allowing it to either be\n * GC'd or reconnected later if an effect comes to depend on it again\n */\nexport const CONNECTED = 1 << 9;\nexport const CLEAN = 1 << 10;\nexport const DIRTY = 1 << 11;\nexport const MAYBE_DIRTY = 1 << 12;\nexport const INERT = 1 << 13;\nexport const DESTROYED = 1 << 14;\n/** Set once a reaction has run for the first time */\nexport const REACTION_RAN = 1 << 15;\n/** Effect is in the process of getting destroyed. Can be observed in child teardown functions */\nexport const DESTROYING = 1 << 25;\n\n// Flags exclusive to effects\n/**\n * 'Transparent' effects do not create a transition boundary.\n * This is on a block effect 99% of the time but may also be on a branch effect if its parent block effect was pruned\n */\nexport const EFFECT_TRANSPARENT = 1 << 16;\nexport const EAGER_EFFECT = 1 << 17;\nexport const HEAD_EFFECT = 1 << 18;\nexport const EFFECT_PRESERVED = 1 << 19;\nexport const USER_EFFECT = 1 << 20;\nexport const EFFECT_OFFSCREEN = 1 << 25;\n\n// Flags exclusive to deriveds\n/**\n * Tells that we marked this derived and its reactions as visited during the \"mark as (maybe) dirty\"-phase.\n * Will be lifted during execution of the derived and during checking its dirty state (both are necessary\n * because a derived might be checked but not executed).\n */\nexport const WAS_MARKED = 1 << 16;\n\n// Flags used for async\nexport const REACTION_IS_UPDATING = 1 << 21;\nexport const ASYNC = 1 << 22;\n\nexport const ERROR_VALUE = 1 << 23;\n\nexport const STATE_SYMBOL = Symbol('$state');\nexport const LEGACY_PROPS = Symbol('legacy props');\nexport const LOADING_ATTR_SYMBOL = Symbol('');\nexport const PROXY_PATH_SYMBOL = Symbol('proxy path');\n\n/** allow users to ignore aborted signal errors if `reason.name === 'StaleReactionError` */\nexport const STALE_REACTION = new (class StaleReactionError extends Error {\n\tname = 'StaleReactionError';\n\tmessage = 'The reaction that called `getAbortSignal()` was re-run or destroyed';\n})();\n\nexport const IS_XHTML =\n\t// We gotta write it like this because after downleveling the pure comment may end up in the wrong location\n\t!!globalThis.document?.contentType &&\n\t/* @__PURE__ */ globalThis.document.contentType.includes('xml');\nexport const ELEMENT_NODE = 1;\nexport const TEXT_NODE = 3;\nexport const COMMENT_NODE = 8;\nexport const DOCUMENT_FRAGMENT_NODE = 11;\n","/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\nimport { DEV } from 'esm-env';\n\n/**\n * Cannot use `%name%(...)` unless the `experimental.async` compiler option is `true`\n * @param {string} name\n * @returns {never}\n */\nexport function experimental_async_required(name) {\n\tif (DEV) {\n\t\tconst error = new Error(`experimental_async_required\\nCannot use \\`${name}(...)\\` unless the \\`experimental.async\\` compiler option is \\`true\\`\\nhttps://svelte.dev/e/experimental_async_required`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/experimental_async_required`);\n\t}\n}\n\n/**\n * Cannot use `{@render children(...)}` if the parent component uses `let:` directives. Consider using a named snippet instead\n * @returns {never}\n */\nexport function invalid_default_snippet() {\n\tif (DEV) {\n\t\tconst error = new Error(`invalid_default_snippet\\nCannot use \\`{@render children(...)}\\` if the parent component uses \\`let:\\` directives. Consider using a named snippet instead\\nhttps://svelte.dev/e/invalid_default_snippet`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/invalid_default_snippet`);\n\t}\n}\n\n/**\n * A snippet function was passed invalid arguments. Snippets should only be instantiated via `{@render ...}`\n * @returns {never}\n */\nexport function invalid_snippet_arguments() {\n\tif (DEV) {\n\t\tconst error = new Error(`invalid_snippet_arguments\\nA snippet function was passed invalid arguments. Snippets should only be instantiated via \\`{@render ...}\\`\\nhttps://svelte.dev/e/invalid_snippet_arguments`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/invalid_snippet_arguments`);\n\t}\n}\n\n/**\n * `%name%(...)` can only be used during component initialisation\n * @param {string} name\n * @returns {never}\n */\nexport function lifecycle_outside_component(name) {\n\tif (DEV) {\n\t\tconst error = new Error(`lifecycle_outside_component\\n\\`${name}(...)\\` can only be used during component initialisation\\nhttps://svelte.dev/e/lifecycle_outside_component`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/lifecycle_outside_component`);\n\t}\n}\n\n/**\n * Context was not set in a parent component\n * @returns {never}\n */\nexport function missing_context() {\n\tif (DEV) {\n\t\tconst error = new Error(`missing_context\\nContext was not set in a parent component\\nhttps://svelte.dev/e/missing_context`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/missing_context`);\n\t}\n}\n\n/**\n * Attempted to render a snippet without a `{@render}` block. This would cause the snippet code to be stringified instead of its content being rendered to the DOM. To fix this, change `{snippet}` to `{@render snippet()}`.\n * @returns {never}\n */\nexport function snippet_without_render_tag() {\n\tif (DEV) {\n\t\tconst error = new Error(`snippet_without_render_tag\\nAttempted to render a snippet without a \\`{@render}\\` block. This would cause the snippet code to be stringified instead of its content being rendered to the DOM. To fix this, change \\`{snippet}\\` to \\`{@render snippet()}\\`.\\nhttps://svelte.dev/e/snippet_without_render_tag`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/snippet_without_render_tag`);\n\t}\n}\n\n/**\n * `%name%` is not a store with a `subscribe` method\n * @param {string} name\n * @returns {never}\n */\nexport function store_invalid_shape(name) {\n\tif (DEV) {\n\t\tconst error = new Error(`store_invalid_shape\\n\\`${name}\\` is not a store with a \\`subscribe\\` method\\nhttps://svelte.dev/e/store_invalid_shape`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/store_invalid_shape`);\n\t}\n}\n\n/**\n * The `this` prop on `` must be a string, if defined\n * @returns {never}\n */\nexport function svelte_element_invalid_this_value() {\n\tif (DEV) {\n\t\tconst error = new Error(`svelte_element_invalid_this_value\\nThe \\`this\\` prop on \\`\\` must be a string, if defined\\nhttps://svelte.dev/e/svelte_element_invalid_this_value`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/svelte_element_invalid_this_value`);\n\t}\n}","/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\nimport { DEV } from 'esm-env';\n\nexport * from '../shared/errors.js';\n\n/**\n * Cannot create a `$derived(...)` with an `await` expression outside of an effect tree\n * @returns {never}\n */\nexport function async_derived_orphan() {\n\tif (DEV) {\n\t\tconst error = new Error(`async_derived_orphan\\nCannot create a \\`$derived(...)\\` with an \\`await\\` expression outside of an effect tree\\nhttps://svelte.dev/e/async_derived_orphan`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/async_derived_orphan`);\n\t}\n}\n\n/**\n * Using `bind:value` together with a checkbox input is not allowed. Use `bind:checked` instead\n * @returns {never}\n */\nexport function bind_invalid_checkbox_value() {\n\tif (DEV) {\n\t\tconst error = new Error(`bind_invalid_checkbox_value\\nUsing \\`bind:value\\` together with a checkbox input is not allowed. Use \\`bind:checked\\` instead\\nhttps://svelte.dev/e/bind_invalid_checkbox_value`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/bind_invalid_checkbox_value`);\n\t}\n}\n\n/**\n * Component %component% has an export named `%key%` that a consumer component is trying to access using `bind:%key%`, which is disallowed. Instead, use `bind:this` (e.g. `<%name% bind:this={component} />`) and then access the property on the bound component instance (e.g. `component.%key%`)\n * @param {string} component\n * @param {string} key\n * @param {string} name\n * @returns {never}\n */\nexport function bind_invalid_export(component, key, name) {\n\tif (DEV) {\n\t\tconst error = new Error(`bind_invalid_export\\nComponent ${component} has an export named \\`${key}\\` that a consumer component is trying to access using \\`bind:${key}\\`, which is disallowed. Instead, use \\`bind:this\\` (e.g. \\`<${name} bind:this={component} />\\`) and then access the property on the bound component instance (e.g. \\`component.${key}\\`)\\nhttps://svelte.dev/e/bind_invalid_export`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/bind_invalid_export`);\n\t}\n}\n\n/**\n * A component is attempting to bind to a non-bindable property `%key%` belonging to %component% (i.e. `<%name% bind:%key%={...}>`). To mark a property as bindable: `let { %key% = $bindable() } = $props()`\n * @param {string} key\n * @param {string} component\n * @param {string} name\n * @returns {never}\n */\nexport function bind_not_bindable(key, component, name) {\n\tif (DEV) {\n\t\tconst error = new Error(`bind_not_bindable\\nA component is attempting to bind to a non-bindable property \\`${key}\\` belonging to ${component} (i.e. \\`<${name} bind:${key}={...}>\\`). To mark a property as bindable: \\`let { ${key} = $bindable() } = $props()\\`\\nhttps://svelte.dev/e/bind_not_bindable`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/bind_not_bindable`);\n\t}\n}\n\n/**\n * Calling `%method%` on a component instance (of %component%) is no longer valid in Svelte 5\n * @param {string} method\n * @param {string} component\n * @returns {never}\n */\nexport function component_api_changed(method, component) {\n\tif (DEV) {\n\t\tconst error = new Error(`component_api_changed\\nCalling \\`${method}\\` on a component instance (of ${component}) is no longer valid in Svelte 5\\nhttps://svelte.dev/e/component_api_changed`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/component_api_changed`);\n\t}\n}\n\n/**\n * Attempted to instantiate %component% with `new %name%`, which is no longer valid in Svelte 5. If this component is not under your control, set the `compatibility.componentApi` compiler option to `4` to keep it working.\n * @param {string} component\n * @param {string} name\n * @returns {never}\n */\nexport function component_api_invalid_new(component, name) {\n\tif (DEV) {\n\t\tconst error = new Error(`component_api_invalid_new\\nAttempted to instantiate ${component} with \\`new ${name}\\`, which is no longer valid in Svelte 5. If this component is not under your control, set the \\`compatibility.componentApi\\` compiler option to \\`4\\` to keep it working.\\nhttps://svelte.dev/e/component_api_invalid_new`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/component_api_invalid_new`);\n\t}\n}\n\n/**\n * A derived value cannot reference itself recursively\n * @returns {never}\n */\nexport function derived_references_self() {\n\tif (DEV) {\n\t\tconst error = new Error(`derived_references_self\\nA derived value cannot reference itself recursively\\nhttps://svelte.dev/e/derived_references_self`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/derived_references_self`);\n\t}\n}\n\n/**\n * Keyed each block has duplicate key `%value%` at indexes %a% and %b%\n * @param {string} a\n * @param {string} b\n * @param {string | undefined | null} [value]\n * @returns {never}\n */\nexport function each_key_duplicate(a, b, value) {\n\tif (DEV) {\n\t\tconst error = new Error(`each_key_duplicate\\n${value\n\t\t\t? `Keyed each block has duplicate key \\`${value}\\` at indexes ${a} and ${b}`\n\t\t\t: `Keyed each block has duplicate key at indexes ${a} and ${b}`}\\nhttps://svelte.dev/e/each_key_duplicate`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/each_key_duplicate`);\n\t}\n}\n\n/**\n * Keyed each block has key that is not idempotent — the key for item at index %index% was `%a%` but is now `%b%`. Keys must be the same each time for a given item\n * @param {string} index\n * @param {string} a\n * @param {string} b\n * @returns {never}\n */\nexport function each_key_volatile(index, a, b) {\n\tif (DEV) {\n\t\tconst error = new Error(`each_key_volatile\\nKeyed each block has key that is not idempotent — the key for item at index ${index} was \\`${a}\\` but is now \\`${b}\\`. Keys must be the same each time for a given item\\nhttps://svelte.dev/e/each_key_volatile`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/each_key_volatile`);\n\t}\n}\n\n/**\n * `%rune%` cannot be used inside an effect cleanup function\n * @param {string} rune\n * @returns {never}\n */\nexport function effect_in_teardown(rune) {\n\tif (DEV) {\n\t\tconst error = new Error(`effect_in_teardown\\n\\`${rune}\\` cannot be used inside an effect cleanup function\\nhttps://svelte.dev/e/effect_in_teardown`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/effect_in_teardown`);\n\t}\n}\n\n/**\n * Effect cannot be created inside a `$derived` value that was not itself created inside an effect\n * @returns {never}\n */\nexport function effect_in_unowned_derived() {\n\tif (DEV) {\n\t\tconst error = new Error(`effect_in_unowned_derived\\nEffect cannot be created inside a \\`$derived\\` value that was not itself created inside an effect\\nhttps://svelte.dev/e/effect_in_unowned_derived`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/effect_in_unowned_derived`);\n\t}\n}\n\n/**\n * `%rune%` can only be used inside an effect (e.g. during component initialisation)\n * @param {string} rune\n * @returns {never}\n */\nexport function effect_orphan(rune) {\n\tif (DEV) {\n\t\tconst error = new Error(`effect_orphan\\n\\`${rune}\\` can only be used inside an effect (e.g. during component initialisation)\\nhttps://svelte.dev/e/effect_orphan`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/effect_orphan`);\n\t}\n}\n\n/**\n * `$effect.pending()` can only be called inside an effect or derived\n * @returns {never}\n */\nexport function effect_pending_outside_reaction() {\n\tif (DEV) {\n\t\tconst error = new Error(`effect_pending_outside_reaction\\n\\`$effect.pending()\\` can only be called inside an effect or derived\\nhttps://svelte.dev/e/effect_pending_outside_reaction`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/effect_pending_outside_reaction`);\n\t}\n}\n\n/**\n * Maximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state\n * @returns {never}\n */\nexport function effect_update_depth_exceeded() {\n\tif (DEV) {\n\t\tconst error = new Error(`effect_update_depth_exceeded\\nMaximum update depth exceeded. This typically indicates that an effect reads and writes the same piece of state\\nhttps://svelte.dev/e/effect_update_depth_exceeded`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/effect_update_depth_exceeded`);\n\t}\n}\n\n/**\n * Cannot use `flushSync` inside an effect\n * @returns {never}\n */\nexport function flush_sync_in_effect() {\n\tif (DEV) {\n\t\tconst error = new Error(`flush_sync_in_effect\\nCannot use \\`flushSync\\` inside an effect\\nhttps://svelte.dev/e/flush_sync_in_effect`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/flush_sync_in_effect`);\n\t}\n}\n\n/**\n * Cannot commit a fork that was already discarded\n * @returns {never}\n */\nexport function fork_discarded() {\n\tif (DEV) {\n\t\tconst error = new Error(`fork_discarded\\nCannot commit a fork that was already discarded\\nhttps://svelte.dev/e/fork_discarded`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/fork_discarded`);\n\t}\n}\n\n/**\n * Cannot create a fork inside an effect or when state changes are pending\n * @returns {never}\n */\nexport function fork_timing() {\n\tif (DEV) {\n\t\tconst error = new Error(`fork_timing\\nCannot create a fork inside an effect or when state changes are pending\\nhttps://svelte.dev/e/fork_timing`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/fork_timing`);\n\t}\n}\n\n/**\n * `getAbortSignal()` can only be called inside an effect or derived\n * @returns {never}\n */\nexport function get_abort_signal_outside_reaction() {\n\tif (DEV) {\n\t\tconst error = new Error(`get_abort_signal_outside_reaction\\n\\`getAbortSignal()\\` can only be called inside an effect or derived\\nhttps://svelte.dev/e/get_abort_signal_outside_reaction`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/get_abort_signal_outside_reaction`);\n\t}\n}\n\n/**\n * Expected to find a hydratable with key `%key%` during hydration, but did not.\n * @param {string} key\n * @returns {never}\n */\nexport function hydratable_missing_but_required(key) {\n\tif (DEV) {\n\t\tconst error = new Error(`hydratable_missing_but_required\\nExpected to find a hydratable with key \\`${key}\\` during hydration, but did not.\\nhttps://svelte.dev/e/hydratable_missing_but_required`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/hydratable_missing_but_required`);\n\t}\n}\n\n/**\n * Failed to hydrate the application\n * @returns {never}\n */\nexport function hydration_failed() {\n\tif (DEV) {\n\t\tconst error = new Error(`hydration_failed\\nFailed to hydrate the application\\nhttps://svelte.dev/e/hydration_failed`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/hydration_failed`);\n\t}\n}\n\n/**\n * Could not `{@render}` snippet due to the expression being `null` or `undefined`. Consider using optional chaining `{@render snippet?.()}`\n * @returns {never}\n */\nexport function invalid_snippet() {\n\tif (DEV) {\n\t\tconst error = new Error(`invalid_snippet\\nCould not \\`{@render}\\` snippet due to the expression being \\`null\\` or \\`undefined\\`. Consider using optional chaining \\`{@render snippet?.()}\\`\\nhttps://svelte.dev/e/invalid_snippet`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/invalid_snippet`);\n\t}\n}\n\n/**\n * `%name%(...)` cannot be used in runes mode\n * @param {string} name\n * @returns {never}\n */\nexport function lifecycle_legacy_only(name) {\n\tif (DEV) {\n\t\tconst error = new Error(`lifecycle_legacy_only\\n\\`${name}(...)\\` cannot be used in runes mode\\nhttps://svelte.dev/e/lifecycle_legacy_only`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/lifecycle_legacy_only`);\n\t}\n}\n\n/**\n * Cannot do `bind:%key%={undefined}` when `%key%` has a fallback value\n * @param {string} key\n * @returns {never}\n */\nexport function props_invalid_value(key) {\n\tif (DEV) {\n\t\tconst error = new Error(`props_invalid_value\\nCannot do \\`bind:${key}={undefined}\\` when \\`${key}\\` has a fallback value\\nhttps://svelte.dev/e/props_invalid_value`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/props_invalid_value`);\n\t}\n}\n\n/**\n * Rest element properties of `$props()` such as `%property%` are readonly\n * @param {string} property\n * @returns {never}\n */\nexport function props_rest_readonly(property) {\n\tif (DEV) {\n\t\tconst error = new Error(`props_rest_readonly\\nRest element properties of \\`$props()\\` such as \\`${property}\\` are readonly\\nhttps://svelte.dev/e/props_rest_readonly`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/props_rest_readonly`);\n\t}\n}\n\n/**\n * The `%rune%` rune is only available inside `.svelte` and `.svelte.js/ts` files\n * @param {string} rune\n * @returns {never}\n */\nexport function rune_outside_svelte(rune) {\n\tif (DEV) {\n\t\tconst error = new Error(`rune_outside_svelte\\nThe \\`${rune}\\` rune is only available inside \\`.svelte\\` and \\`.svelte.js/ts\\` files\\nhttps://svelte.dev/e/rune_outside_svelte`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/rune_outside_svelte`);\n\t}\n}\n\n/**\n * `setContext` must be called when a component first initializes, not in a subsequent effect or after an `await` expression\n * @returns {never}\n */\nexport function set_context_after_init() {\n\tif (DEV) {\n\t\tconst error = new Error(`set_context_after_init\\n\\`setContext\\` must be called when a component first initializes, not in a subsequent effect or after an \\`await\\` expression\\nhttps://svelte.dev/e/set_context_after_init`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/set_context_after_init`);\n\t}\n}\n\n/**\n * Property descriptors defined on `$state` objects must contain `value` and always be `enumerable`, `configurable` and `writable`.\n * @returns {never}\n */\nexport function state_descriptors_fixed() {\n\tif (DEV) {\n\t\tconst error = new Error(`state_descriptors_fixed\\nProperty descriptors defined on \\`$state\\` objects must contain \\`value\\` and always be \\`enumerable\\`, \\`configurable\\` and \\`writable\\`.\\nhttps://svelte.dev/e/state_descriptors_fixed`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/state_descriptors_fixed`);\n\t}\n}\n\n/**\n * Cannot set prototype of `$state` object\n * @returns {never}\n */\nexport function state_prototype_fixed() {\n\tif (DEV) {\n\t\tconst error = new Error(`state_prototype_fixed\\nCannot set prototype of \\`$state\\` object\\nhttps://svelte.dev/e/state_prototype_fixed`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/state_prototype_fixed`);\n\t}\n}\n\n/**\n * Updating state inside `$derived(...)`, `$inspect(...)` or a template expression is forbidden. If the value should not be reactive, declare it without `$state`\n * @returns {never}\n */\nexport function state_unsafe_mutation() {\n\tif (DEV) {\n\t\tconst error = new Error(`state_unsafe_mutation\\nUpdating state inside \\`$derived(...)\\`, \\`$inspect(...)\\` or a template expression is forbidden. If the value should not be reactive, declare it without \\`$state\\`\\nhttps://svelte.dev/e/state_unsafe_mutation`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/state_unsafe_mutation`);\n\t}\n}\n\n/**\n * A `` `reset` function cannot be called while an error is still being handled\n * @returns {never}\n */\nexport function svelte_boundary_reset_onerror() {\n\tif (DEV) {\n\t\tconst error = new Error(`svelte_boundary_reset_onerror\\nA \\`\\` \\`reset\\` function cannot be called while an error is still being handled\\nhttps://svelte.dev/e/svelte_boundary_reset_onerror`);\n\n\t\terror.name = 'Svelte error';\n\n\t\tthrow error;\n\t} else {\n\t\tthrow new Error(`https://svelte.dev/e/svelte_boundary_reset_onerror`);\n\t}\n}","export const EACH_ITEM_REACTIVE = 1;\nexport const EACH_INDEX_REACTIVE = 1 << 1;\n/** See EachBlock interface metadata.is_controlled for an explanation what this is */\nexport const EACH_IS_CONTROLLED = 1 << 2;\nexport const EACH_IS_ANIMATED = 1 << 3;\nexport const EACH_ITEM_IMMUTABLE = 1 << 4;\n\nexport const PROPS_IS_IMMUTABLE = 1;\nexport const PROPS_IS_RUNES = 1 << 1;\nexport const PROPS_IS_UPDATED = 1 << 2;\nexport const PROPS_IS_BINDABLE = 1 << 3;\nexport const PROPS_IS_LAZY_INITIAL = 1 << 4;\n\nexport const TRANSITION_IN = 1;\nexport const TRANSITION_OUT = 1 << 1;\nexport const TRANSITION_GLOBAL = 1 << 2;\n\nexport const TEMPLATE_FRAGMENT = 1;\nexport const TEMPLATE_USE_IMPORT_NODE = 1 << 1;\nexport const TEMPLATE_USE_SVG = 1 << 2;\nexport const TEMPLATE_USE_MATHML = 1 << 3;\n\nexport const HYDRATION_START = '[';\n/** used to indicate that an `{:else}...` block was rendered */\nexport const HYDRATION_START_ELSE = '[!';\n/** used to indicate that a boundary's `failed` snippet was rendered on the server */\nexport const HYDRATION_START_FAILED = '[?';\nexport const HYDRATION_END = ']';\nexport const HYDRATION_ERROR = {};\n\nexport const ELEMENT_IS_NAMESPACED = 1;\nexport const ELEMENT_PRESERVE_ATTRIBUTE_CASE = 1 << 1;\nexport const ELEMENT_IS_INPUT = 1 << 2;\n\nexport const UNINITIALIZED = Symbol();\n\n// Dev-time component properties\nexport const FILENAME = Symbol('filename');\nexport const HMR = Symbol('hmr');\n\nexport const NAMESPACE_HTML = 'http://www.w3.org/1999/xhtml';\nexport const NAMESPACE_SVG = 'http://www.w3.org/2000/svg';\nexport const NAMESPACE_MATHML = 'http://www.w3.org/1998/Math/MathML';\n\n// we use a list of ignorable runtime warnings because not every runtime warning\n// can be ignored and we want to keep the validation for svelte-ignore in place\nexport const IGNORABLE_RUNTIME_WARNINGS = /** @type {const} */ ([\n\t'await_waterfall',\n\t'await_reactivity_loss',\n\t'state_snapshot_uncloneable',\n\t'binding_property_non_reactive',\n\t'hydration_attribute_changed',\n\t'hydration_html_changed',\n\t'ownership_invalid_binding',\n\t'ownership_invalid_mutation'\n]);\n\n/**\n * Whitespace inside one of these elements will not result in\n * a whitespace node being created in any circumstances. (This\n * list is almost certainly very incomplete)\n * TODO this is currently unused\n */\nexport const ELEMENTS_WITHOUT_TEXT = ['audio', 'datalist', 'dl', 'optgroup', 'select', 'video'];\n\nexport const ATTACHMENT_KEY = '@attach';\n","/* This file is generated by scripts/process-messages/index.js. Do not edit! */\n\nimport { DEV } from 'esm-env';\n\nvar bold = 'font-weight: bold';\nvar normal = 'font-weight: normal';\n\n/**\n * Assignment to `%property%` property (%location%) will evaluate to the right-hand side, not the value of `%property%` following the assignment. This may result in unexpected behaviour.\n * @param {string} property\n * @param {string} location\n */\nexport function assignment_value_stale(property, location) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] assignment_value_stale\\n%cAssignment to \\`${property}\\` property (${location}) will evaluate to the right-hand side, not the value of \\`${property}\\` following the assignment. This may result in unexpected behaviour.\\nhttps://svelte.dev/e/assignment_value_stale`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/assignment_value_stale`);\n\t}\n}\n\n/**\n * Detected reactivity loss when reading `%name%`. This happens when state is read in an async function after an earlier `await`\n * @param {string} name\n */\nexport function await_reactivity_loss(name) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] await_reactivity_loss\\n%cDetected reactivity loss when reading \\`${name}\\`. This happens when state is read in an async function after an earlier \\`await\\`\\nhttps://svelte.dev/e/await_reactivity_loss`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/await_reactivity_loss`);\n\t}\n}\n\n/**\n * An async derived, `%name%` (%location%) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app\n * @param {string} name\n * @param {string} location\n */\nexport function await_waterfall(name, location) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] await_waterfall\\n%cAn async derived, \\`${name}\\` (${location}) was not read immediately after it resolved. This often indicates an unnecessary waterfall, which can slow down your app\\nhttps://svelte.dev/e/await_waterfall`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/await_waterfall`);\n\t}\n}\n\n/**\n * `%binding%` (%location%) is binding to a non-reactive property\n * @param {string} binding\n * @param {string | undefined | null} [location]\n */\nexport function binding_property_non_reactive(binding, location) {\n\tif (DEV) {\n\t\tconsole.warn(\n\t\t\t`%c[svelte] binding_property_non_reactive\\n%c${location\n\t\t\t\t? `\\`${binding}\\` (${location}) is binding to a non-reactive property`\n\t\t\t\t: `\\`${binding}\\` is binding to a non-reactive property`}\\nhttps://svelte.dev/e/binding_property_non_reactive`,\n\t\t\tbold,\n\t\t\tnormal\n\t\t);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/binding_property_non_reactive`);\n\t}\n}\n\n/**\n * Your `console.%method%` contained `$state` proxies. Consider using `$inspect(...)` or `$state.snapshot(...)` instead\n * @param {string} method\n */\nexport function console_log_state(method) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] console_log_state\\n%cYour \\`console.${method}\\` contained \\`$state\\` proxies. Consider using \\`$inspect(...)\\` or \\`$state.snapshot(...)\\` instead\\nhttps://svelte.dev/e/console_log_state`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/console_log_state`);\n\t}\n}\n\n/**\n * %handler% should be a function. Did you mean to %suggestion%?\n * @param {string} handler\n * @param {string} suggestion\n */\nexport function event_handler_invalid(handler, suggestion) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] event_handler_invalid\\n%c${handler} should be a function. Did you mean to ${suggestion}?\\nhttps://svelte.dev/e/event_handler_invalid`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/event_handler_invalid`);\n\t}\n}\n\n/**\n * Expected to find a hydratable with key `%key%` during hydration, but did not.\n * @param {string} key\n */\nexport function hydratable_missing_but_expected(key) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] hydratable_missing_but_expected\\n%cExpected to find a hydratable with key \\`${key}\\` during hydration, but did not.\\nhttps://svelte.dev/e/hydratable_missing_but_expected`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/hydratable_missing_but_expected`);\n\t}\n}\n\n/**\n * The `%attribute%` attribute on `%html%` changed its value between server and client renders. The client value, `%value%`, will be ignored in favour of the server value\n * @param {string} attribute\n * @param {string} html\n * @param {string} value\n */\nexport function hydration_attribute_changed(attribute, html, value) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] hydration_attribute_changed\\n%cThe \\`${attribute}\\` attribute on \\`${html}\\` changed its value between server and client renders. The client value, \\`${value}\\`, will be ignored in favour of the server value\\nhttps://svelte.dev/e/hydration_attribute_changed`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/hydration_attribute_changed`);\n\t}\n}\n\n/**\n * The value of an `{@html ...}` block %location% changed between server and client renders. The client value will be ignored in favour of the server value\n * @param {string | undefined | null} [location]\n */\nexport function hydration_html_changed(location) {\n\tif (DEV) {\n\t\tconsole.warn(\n\t\t\t`%c[svelte] hydration_html_changed\\n%c${location\n\t\t\t\t? `The value of an \\`{@html ...}\\` block ${location} changed between server and client renders. The client value will be ignored in favour of the server value`\n\t\t\t\t: 'The value of an `{@html ...}` block changed between server and client renders. The client value will be ignored in favour of the server value'}\\nhttps://svelte.dev/e/hydration_html_changed`,\n\t\t\tbold,\n\t\t\tnormal\n\t\t);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/hydration_html_changed`);\n\t}\n}\n\n/**\n * Hydration failed because the initial UI does not match what was rendered on the server. The error occurred near %location%\n * @param {string | undefined | null} [location]\n */\nexport function hydration_mismatch(location) {\n\tif (DEV) {\n\t\tconsole.warn(\n\t\t\t`%c[svelte] hydration_mismatch\\n%c${location\n\t\t\t\t? `Hydration failed because the initial UI does not match what was rendered on the server. The error occurred near ${location}`\n\t\t\t\t: 'Hydration failed because the initial UI does not match what was rendered on the server'}\\nhttps://svelte.dev/e/hydration_mismatch`,\n\t\t\tbold,\n\t\t\tnormal\n\t\t);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/hydration_mismatch`);\n\t}\n}\n\n/**\n * The `render` function passed to `createRawSnippet` should return HTML for a single element\n */\nexport function invalid_raw_snippet_render() {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] invalid_raw_snippet_render\\n%cThe \\`render\\` function passed to \\`createRawSnippet\\` should return HTML for a single element\\nhttps://svelte.dev/e/invalid_raw_snippet_render`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/invalid_raw_snippet_render`);\n\t}\n}\n\n/**\n * Detected a migrated `$:` reactive block in `%filename%` that both accesses and updates the same reactive value. This may cause recursive updates when converted to an `$effect`.\n * @param {string} filename\n */\nexport function legacy_recursive_reactive_block(filename) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] legacy_recursive_reactive_block\\n%cDetected a migrated \\`$:\\` reactive block in \\`${filename}\\` that both accesses and updates the same reactive value. This may cause recursive updates when converted to an \\`$effect\\`.\\nhttps://svelte.dev/e/legacy_recursive_reactive_block`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/legacy_recursive_reactive_block`);\n\t}\n}\n\n/**\n * Tried to unmount a component that was not mounted\n */\nexport function lifecycle_double_unmount() {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] lifecycle_double_unmount\\n%cTried to unmount a component that was not mounted\\nhttps://svelte.dev/e/lifecycle_double_unmount`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/lifecycle_double_unmount`);\n\t}\n}\n\n/**\n * %parent% passed property `%prop%` to %child% with `bind:`, but its parent component %owner% did not declare `%prop%` as a binding. Consider creating a binding between %owner% and %parent% (e.g. `bind:%prop%={...}` instead of `%prop%={...}`)\n * @param {string} parent\n * @param {string} prop\n * @param {string} child\n * @param {string} owner\n */\nexport function ownership_invalid_binding(parent, prop, child, owner) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] ownership_invalid_binding\\n%c${parent} passed property \\`${prop}\\` to ${child} with \\`bind:\\`, but its parent component ${owner} did not declare \\`${prop}\\` as a binding. Consider creating a binding between ${owner} and ${parent} (e.g. \\`bind:${prop}={...}\\` instead of \\`${prop}={...}\\`)\\nhttps://svelte.dev/e/ownership_invalid_binding`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/ownership_invalid_binding`);\n\t}\n}\n\n/**\n * Mutating unbound props (`%name%`, at %location%) is strongly discouraged. Consider using `bind:%prop%={...}` in %parent% (or using a callback) instead\n * @param {string} name\n * @param {string} location\n * @param {string} prop\n * @param {string} parent\n */\nexport function ownership_invalid_mutation(name, location, prop, parent) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] ownership_invalid_mutation\\n%cMutating unbound props (\\`${name}\\`, at ${location}) is strongly discouraged. Consider using \\`bind:${prop}={...}\\` in ${parent} (or using a callback) instead\\nhttps://svelte.dev/e/ownership_invalid_mutation`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/ownership_invalid_mutation`);\n\t}\n}\n\n/**\n * The `value` property of a `\\` element should be an array, but it received a non-array value. The selection will be kept as is.\\nhttps://svelte.dev/e/select_multiple_invalid_value`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/select_multiple_invalid_value`);\n\t}\n}\n\n/**\n * Reactive `$state(...)` proxies and the values they proxy have different identities. Because of this, comparisons with `%operator%` will produce unexpected results\n * @param {string} operator\n */\nexport function state_proxy_equality_mismatch(operator) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] state_proxy_equality_mismatch\\n%cReactive \\`$state(...)\\` proxies and the values they proxy have different identities. Because of this, comparisons with \\`${operator}\\` will produce unexpected results\\nhttps://svelte.dev/e/state_proxy_equality_mismatch`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/state_proxy_equality_mismatch`);\n\t}\n}\n\n/**\n * Tried to unmount a state proxy, rather than a component\n */\nexport function state_proxy_unmount() {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] state_proxy_unmount\\n%cTried to unmount a state proxy, rather than a component\\nhttps://svelte.dev/e/state_proxy_unmount`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/state_proxy_unmount`);\n\t}\n}\n\n/**\n * A `` `reset` function only resets the boundary the first time it is called\n */\nexport function svelte_boundary_reset_noop() {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] svelte_boundary_reset_noop\\n%cA \\`\\` \\`reset\\` function only resets the boundary the first time it is called\\nhttps://svelte.dev/e/svelte_boundary_reset_noop`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/svelte_boundary_reset_noop`);\n\t}\n}\n\n/**\n * The `slide` transition does not work correctly for elements with `display: %value%`\n * @param {string} value\n */\nexport function transition_slide_display(value) {\n\tif (DEV) {\n\t\tconsole.warn(`%c[svelte] transition_slide_display\\n%cThe \\`slide\\` transition does not work correctly for elements with \\`display: ${value}\\`\\nhttps://svelte.dev/e/transition_slide_display`, bold, normal);\n\t} else {\n\t\tconsole.warn(`https://svelte.dev/e/transition_slide_display`);\n\t}\n}","/** @import { Equals } from '#client' */\n\n/** @type {Equals} */\nexport function equals(value) {\n\treturn value === this.v;\n}\n\n/**\n * @param {unknown} a\n * @param {unknown} b\n * @returns {boolean}\n */\nexport function safe_not_equal(a, b) {\n\treturn a != a\n\t\t? b == b\n\t\t: a !== b || (a !== null && typeof a === 'object') || typeof a === 'function';\n}\n\n/**\n * @param {unknown} a\n * @param {unknown} b\n * @returns {boolean}\n */\nexport function not_equal(a, b) {\n\treturn a !== b;\n}\n\n/** @type {Equals} */\nexport function safe_equals(value) {\n\treturn !safe_not_equal(value, this.v);\n}\n","/** True if experimental.async=true */\nexport let async_mode_flag = false;\n/** True if we're not certain that we only have Svelte 5 code in the compilation */\nexport let legacy_mode_flag = false;\n/** True if $inspect.trace is used */\nexport let tracing_mode_flag = false;\n\nexport function enable_async_mode_flag() {\n\tasync_mode_flag = true;\n}\n\n/** ONLY USE THIS DURING TESTING */\nexport function disable_async_mode_flag() {\n\tasync_mode_flag = false;\n}\n\nexport function enable_legacy_mode_flag() {\n\tlegacy_mode_flag = true;\n}\n\nexport function enable_tracing_mode_flag() {\n\ttracing_mode_flag = true;\n}\n","/** @import { ComponentContext, DevStackEntry, Effect } from '#client' */\nimport { DEV } from 'esm-env';\nimport * as e from './errors.js';\nimport { active_effect, active_reaction } from './runtime.js';\nimport { create_user_effect } from './reactivity/effects.js';\nimport { async_mode_flag, legacy_mode_flag } from '../flags/index.js';\nimport { FILENAME } from '../../constants.js';\nimport { BRANCH_EFFECT } from './constants.js';\n\n/** @type {ComponentContext | null} */\nexport let component_context = null;\n\n/** @param {ComponentContext | null} context */\nexport function set_component_context(context) {\n\tcomponent_context = context;\n}\n\n/** @type {DevStackEntry | null} */\nexport let dev_stack = null;\n\n/** @param {DevStackEntry | null} stack */\nexport function set_dev_stack(stack) {\n\tdev_stack = stack;\n}\n\n/**\n * Execute a callback with a new dev stack entry\n * @param {() => any} callback - Function to execute\n * @param {DevStackEntry['type']} type - Type of block/component\n * @param {any} component - Component function\n * @param {number} line - Line number\n * @param {number} column - Column number\n * @param {Record} [additional] - Any additional properties to add to the dev stack entry\n * @returns {any}\n */\nexport function add_svelte_meta(callback, type, component, line, column, additional) {\n\tconst parent = dev_stack;\n\n\tdev_stack = {\n\t\ttype,\n\t\tfile: component[FILENAME],\n\t\tline,\n\t\tcolumn,\n\t\tparent,\n\t\t...additional\n\t};\n\n\ttry {\n\t\treturn callback();\n\t} finally {\n\t\tdev_stack = parent;\n\t}\n}\n\n/**\n * The current component function. Different from current component context:\n * ```html\n * \n * \n * \n * \n * ```\n * @type {ComponentContext['function']}\n */\nexport let dev_current_component_function = null;\n\n/** @param {ComponentContext['function']} fn */\nexport function set_dev_current_component_function(fn) {\n\tdev_current_component_function = fn;\n}\n\n/**\n * Returns a `[get, set]` pair of functions for working with context in a type-safe way.\n *\n * `get` will throw an error if no parent component called `set`.\n *\n * @template T\n * @returns {[() => T, (context: T) => T]}\n * @since 5.40.0\n */\nexport function createContext() {\n\tconst key = {};\n\n\treturn [\n\t\t() => {\n\t\t\tif (!hasContext(key)) {\n\t\t\t\te.missing_context();\n\t\t\t}\n\n\t\t\treturn getContext(key);\n\t\t},\n\t\t(context) => setContext(key, context)\n\t];\n}\n\n/**\n * Retrieves the context that belongs to the closest parent component with the specified `key`.\n * Must be called during component initialisation.\n *\n * [`createContext`](https://svelte.dev/docs/svelte/svelte#createContext) is a type-safe alternative.\n *\n * @template T\n * @param {any} key\n * @returns {T}\n */\nexport function getContext(key) {\n\tconst context_map = get_or_init_context_map('getContext');\n\tconst result = /** @type {T} */ (context_map.get(key));\n\treturn result;\n}\n\n/**\n * Associates an arbitrary `context` object with the current component and the specified `key`\n * and returns that object. The context is then available to children of the component\n * (including slotted content) with `getContext`.\n *\n * Like lifecycle functions, this must be called during component initialisation.\n *\n * [`createContext`](https://svelte.dev/docs/svelte/svelte#createContext) is a type-safe alternative.\n *\n * @template T\n * @param {any} key\n * @param {T} context\n * @returns {T}\n */\nexport function setContext(key, context) {\n\tconst context_map = get_or_init_context_map('setContext');\n\n\tif (async_mode_flag) {\n\t\tvar flags = /** @type {Effect} */ (active_effect).f;\n\t\tvar valid =\n\t\t\t!active_reaction &&\n\t\t\t(flags & BRANCH_EFFECT) !== 0 &&\n\t\t\t// pop() runs synchronously, so this indicates we're setting context after an await\n\t\t\t!(/** @type {ComponentContext} */ (component_context).i);\n\n\t\tif (!valid) {\n\t\t\te.set_context_after_init();\n\t\t}\n\t}\n\n\tcontext_map.set(key, context);\n\treturn context;\n}\n\n/**\n * Checks whether a given `key` has been set in the context of a parent component.\n * Must be called during component initialisation.\n *\n * @param {any} key\n * @returns {boolean}\n */\nexport function hasContext(key) {\n\tconst context_map = get_or_init_context_map('hasContext');\n\treturn context_map.has(key);\n}\n\n/**\n * Retrieves the whole context map that belongs to the closest parent component.\n * Must be called during component initialisation. Useful, for example, if you\n * programmatically create a component and want to pass the existing context to it.\n *\n * @template {Map} [T=Map]\n * @returns {T}\n */\nexport function getAllContexts() {\n\tconst context_map = get_or_init_context_map('getAllContexts');\n\treturn /** @type {T} */ (context_map);\n}\n\n/**\n * @param {Record} props\n * @param {any} runes\n * @param {Function} [fn]\n * @returns {void}\n */\nexport function push(props, runes = false, fn) {\n\tcomponent_context = {\n\t\tp: component_context,\n\t\ti: false,\n\t\tc: null,\n\t\te: null,\n\t\ts: props,\n\t\tx: null,\n\t\tr: /** @type {Effect} */ (active_effect),\n\t\tl: legacy_mode_flag && !runes ? { s: null, u: null, $: [] } : null\n\t};\n\n\tif (DEV) {\n\t\t// component function\n\t\tcomponent_context.function = fn;\n\t\tdev_current_component_function = fn;\n\t}\n}\n\n/**\n * @template {Record} T\n * @param {T} [component]\n * @returns {T}\n */\nexport function pop(component) {\n\tvar context = /** @type {ComponentContext} */ (component_context);\n\tvar effects = context.e;\n\n\tif (effects !== null) {\n\t\tcontext.e = null;\n\n\t\tfor (var fn of effects) {\n\t\t\tcreate_user_effect(fn);\n\t\t}\n\t}\n\n\tif (component !== undefined) {\n\t\tcontext.x = component;\n\t}\n\n\tcontext.i = true;\n\n\tcomponent_context = context.p;\n\n\tif (DEV) {\n\t\tdev_current_component_function = component_context?.function ?? null;\n\t}\n\n\treturn component ?? /** @type {T} */ ({});\n}\n\n/** @returns {boolean} */\nexport function is_runes() {\n\treturn !legacy_mode_flag || (component_context !== null && component_context.l === null);\n}\n\n/**\n * @param {string} name\n * @returns {Map}\n */\nfunction get_or_init_context_map(name) {\n\tif (component_context === null) {\n\t\te.lifecycle_outside_component(name);\n\t}\n\n\treturn (component_context.c ??= new Map(get_parent_context(component_context) || undefined));\n}\n\n/**\n * @param {ComponentContext} component_context\n * @returns {Map | null}\n */\nfunction get_parent_context(component_context) {\n\tlet parent = component_context.p;\n\twhile (parent !== null) {\n\t\tconst context_map = parent.c;\n\t\tif (context_map !== null) {\n\t\t\treturn context_map;\n\t\t}\n\t\tparent = parent.p;\n\t}\n\treturn null;\n}\n","import { run_all } from '../../shared/utils.js';\nimport { is_flushing_sync } from '../reactivity/batch.js';\n\n/** @type {Array<() => void>} */\nlet micro_tasks = [];\n\nfunction run_micro_tasks() {\n\tvar tasks = micro_tasks;\n\tmicro_tasks = [];\n\trun_all(tasks);\n}\n\n/**\n * @param {() => void} fn\n */\nexport function queue_micro_task(fn) {\n\tif (micro_tasks.length === 0 && !is_flushing_sync) {\n\t\tvar tasks = micro_tasks;\n\t\tqueueMicrotask(() => {\n\t\t\t// If this is false, a flushSync happened in the meantime. Do _not_ run new scheduled microtasks in that case\n\t\t\t// as the ordering of microtasks would be broken at that point - consider this case:\n\t\t\t// - queue_micro_task schedules microtask A to flush task X\n\t\t\t// - synchronously after, flushSync runs, processing task X\n\t\t\t// - synchronously after, some other microtask B is scheduled, but not through queue_micro_task but for example a Promise.resolve() in user code\n\t\t\t// - synchronously after, queue_micro_task schedules microtask C to flush task Y\n\t\t\t// - one tick later, microtask A now resolves, flushing task Y before microtask B, which is incorrect\n\t\t\t// This if check prevents that race condition (that realistically will only happen in tests)\n\t\t\tif (tasks === micro_tasks) run_micro_tasks();\n\t\t});\n\t}\n\n\tmicro_tasks.push(fn);\n}\n\n/**\n * Synchronously run any queued tasks.\n */\nexport function flush_tasks() {\n\twhile (micro_tasks.length > 0) {\n\t\trun_micro_tasks();\n\t}\n}\n","/** @import { Derived, Effect } from '#client' */\n/** @import { Boundary } from './dom/blocks/boundary.js' */\nimport { DEV } from 'esm-env';\nimport { FILENAME } from '../../constants.js';\nimport { is_firefox } from './dom/operations.js';\nimport { ERROR_VALUE, BOUNDARY_EFFECT, REACTION_RAN, EFFECT } from './constants.js';\nimport { define_property, get_descriptor } from '../shared/utils.js';\nimport { active_effect, active_reaction } from './runtime.js';\n\nconst adjustments = new WeakMap();\n\n/**\n * @param {unknown} error\n */\nexport function handle_error(error) {\n\tvar effect = active_effect;\n\n\t// for unowned deriveds, don't throw until we read the value\n\tif (effect === null) {\n\t\t/** @type {Derived} */ (active_reaction).f |= ERROR_VALUE;\n\t\treturn error;\n\t}\n\n\tif (DEV && error instanceof Error && !adjustments.has(error)) {\n\t\tadjustments.set(error, get_adjustments(error, effect));\n\t}\n\n\t// if the error occurred while creating this subtree, we let it\n\t// bubble up until it hits a boundary that can handle it, unless\n\t// it's an $effect in which case it doesn't run immediately\n\tif ((effect.f & REACTION_RAN) === 0 && (effect.f & EFFECT) === 0) {\n\t\tif (DEV && !effect.parent && error instanceof Error) {\n\t\t\tapply_adjustments(error);\n\t\t}\n\n\t\tthrow error;\n\t}\n\n\t// otherwise we bubble up the effect tree ourselves\n\tinvoke_error_boundary(error, effect);\n}\n\n/**\n * @param {unknown} error\n * @param {Effect | null} effect\n */\nexport function invoke_error_boundary(error, effect) {\n\twhile (effect !== null) {\n\t\tif ((effect.f & BOUNDARY_EFFECT) !== 0) {\n\t\t\tif ((effect.f & REACTION_RAN) === 0) {\n\t\t\t\t// we are still creating the boundary effect\n\t\t\t\tthrow error;\n\t\t\t}\n\n\t\t\ttry {\n\t\t\t\t/** @type {Boundary} */ (effect.b).error(error);\n\t\t\t\treturn;\n\t\t\t} catch (e) {\n\t\t\t\terror = e;\n\t\t\t}\n\t\t}\n\n\t\teffect = effect.parent;\n\t}\n\n\tif (DEV && error instanceof Error) {\n\t\tapply_adjustments(error);\n\t}\n\n\tthrow error;\n}\n\n/**\n * Add useful information to the error message/stack in development\n * @param {Error} error\n * @param {Effect} effect\n */\nfunction get_adjustments(error, effect) {\n\tconst message_descriptor = get_descriptor(error, 'message');\n\n\t// if the message was already changed and it's not configurable we can't change it\n\t// or it will throw a different error swallowing the original error\n\tif (message_descriptor && !message_descriptor.configurable) return;\n\n\tvar indent = is_firefox ? ' ' : '\\t';\n\tvar component_stack = `\\n${indent}in ${effect.fn?.name || ''}`;\n\tvar context = effect.ctx;\n\n\twhile (context !== null) {\n\t\tcomponent_stack += `\\n${indent}in ${context.function?.[FILENAME].split('/').pop()}`;\n\t\tcontext = context.p;\n\t}\n\n\treturn {\n\t\tmessage: error.message + `\\n${component_stack}\\n`,\n\t\tstack: error.stack\n\t\t\t?.split('\\n')\n\t\t\t.filter((line) => !line.includes('svelte/src/internal'))\n\t\t\t.join('\\n')\n\t};\n}\n\n/**\n * @param {Error} error\n */\nfunction apply_adjustments(error) {\n\tconst adjusted = adjustments.get(error);\n\n\tif (adjusted) {\n\t\tdefine_property(error, 'message', {\n\t\t\tvalue: adjusted.message\n\t\t});\n\n\t\tdefine_property(error, 'stack', {\n\t\t\tvalue: adjusted.stack\n\t\t});\n\t}\n}\n","/** @import { Derived, Signal } from '#client' */\nimport { CLEAN, CONNECTED, DIRTY, MAYBE_DIRTY } from '#client/constants';\n\nconst STATUS_MASK = ~(DIRTY | MAYBE_DIRTY | CLEAN);\n\n/**\n * @param {Signal} signal\n * @param {number} status\n */\nexport function set_signal_status(signal, status) {\n\tsignal.f = (signal.f & STATUS_MASK) | status;\n}\n\n/**\n * Set a derived's status to CLEAN or MAYBE_DIRTY based on its connection state.\n * @param {Derived} derived\n */\nexport function update_derived_status(derived) {\n\t// Only mark as MAYBE_DIRTY if disconnected and has dependencies.\n\tif ((derived.f & CONNECTED) !== 0 || derived.deps === null) {\n\t\tset_signal_status(derived, CLEAN);\n\t} else {\n\t\tset_signal_status(derived, MAYBE_DIRTY);\n\t}\n}\n","/** @import { Derived, Effect, Value } from '#client' */\nimport { CLEAN, DERIVED, DIRTY, MAYBE_DIRTY, WAS_MARKED } from '#client/constants';\nimport { set_signal_status } from './status.js';\n\n/**\n * @param {Value[] | null} deps\n */\nfunction clear_marked(deps) {\n\tif (deps === null) return;\n\n\tfor (const dep of deps) {\n\t\tif ((dep.f & DERIVED) === 0 || (dep.f & WAS_MARKED) === 0) {\n\t\t\tcontinue;\n\t\t}\n\n\t\tdep.f ^= WAS_MARKED;\n\n\t\tclear_marked(/** @type {Derived} */ (dep).deps);\n\t}\n}\n\n/**\n * @param {Effect} effect\n * @param {Set} dirty_effects\n * @param {Set} maybe_dirty_effects\n */\nexport function defer_effect(effect, dirty_effects, maybe_dirty_effects) {\n\tif ((effect.f & DIRTY) !== 0) {\n\t\tdirty_effects.add(effect);\n\t} else if ((effect.f & MAYBE_DIRTY) !== 0) {\n\t\tmaybe_dirty_effects.add(effect);\n\t}\n\n\t// Since we're not executing these effects now, we need to clear any WAS_MARKED flags\n\t// so that other batches can correctly reach these effects during their own traversal\n\tclear_marked(effect.deps);\n\n\t// mark as clean so they get scheduled if they depend on pending async state\n\tset_signal_status(effect, CLEAN);\n}\n","/** @import { Readable } from './public' */\nimport { untrack } from '../internal/client/runtime.js';\nimport { noop } from '../internal/shared/utils.js';\n\n/**\n * @template T\n * @param {Readable | null | undefined} store\n * @param {(value: T) => void} run\n * @param {(value: T) => void} [invalidate]\n * @returns {() => void}\n */\nexport function subscribe_to_store(store, run, invalidate) {\n\tif (store == null) {\n\t\t// @ts-expect-error\n\t\trun(undefined);\n\n\t\t// @ts-expect-error\n\t\tif (invalidate) invalidate(undefined);\n\n\t\treturn noop;\n\t}\n\n\t// Svelte store takes a private second argument\n\t// StartStopNotifier could mutate state, and we want to silence the corresponding validation error\n\tconst unsub = untrack(() =>\n\t\tstore.subscribe(\n\t\t\trun,\n\t\t\t// @ts-expect-error\n\t\t\tinvalidate\n\t\t)\n\t);\n\n\t// Also support RxJS\n\t// @ts-expect-error TODO fix this in the types?\n\treturn unsub.unsubscribe ? () => unsub.unsubscribe() : unsub;\n}\n","/** @import { Readable, StartStopNotifier, Subscriber, Unsubscriber, Updater, Writable } from '../public.js' */\n/** @import { Stores, StoresValues, SubscribeInvalidateTuple } from '../private.js' */\nimport { noop, run_all } from '../../internal/shared/utils.js';\nimport { safe_not_equal } from '../../internal/client/reactivity/equality.js';\nimport { subscribe_to_store } from '../utils.js';\n\n/**\n * @type {Array | any>}\n */\nconst subscriber_queue = [];\n\n/**\n * Creates a `Readable` store that allows reading by subscription.\n *\n * @template T\n * @param {T} [value] initial value\n * @param {StartStopNotifier} [start]\n * @returns {Readable}\n */\nexport function readable(value, start) {\n\treturn {\n\t\tsubscribe: writable(value, start).subscribe\n\t};\n}\n\n/**\n * Create a `Writable` store that allows both updating and reading by subscription.\n *\n * @template T\n * @param {T} [value] initial value\n * @param {StartStopNotifier} [start]\n * @returns {Writable}\n */\nexport function writable(value, start = noop) {\n\t/** @type {Unsubscriber | null} */\n\tlet stop = null;\n\n\t/** @type {Set>} */\n\tconst subscribers = new Set();\n\n\t/**\n\t * @param {T} new_value\n\t * @returns {void}\n\t */\n\tfunction set(new_value) {\n\t\tif (safe_not_equal(value, new_value)) {\n\t\t\tvalue = new_value;\n\t\t\tif (stop) {\n\t\t\t\t// store is ready\n\t\t\t\tconst run_queue = !subscriber_queue.length;\n\t\t\t\tfor (const subscriber of subscribers) {\n\t\t\t\t\tsubscriber[1]();\n\t\t\t\t\tsubscriber_queue.push(subscriber, value);\n\t\t\t\t}\n\t\t\t\tif (run_queue) {\n\t\t\t\t\tfor (let i = 0; i < subscriber_queue.length; i += 2) {\n\t\t\t\t\t\tsubscriber_queue[i][0](subscriber_queue[i + 1]);\n\t\t\t\t\t}\n\t\t\t\t\tsubscriber_queue.length = 0;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * @param {Updater} fn\n\t * @returns {void}\n\t */\n\tfunction update(fn) {\n\t\tset(fn(/** @type {T} */ (value)));\n\t}\n\n\t/**\n\t * @param {Subscriber} run\n\t * @param {() => void} [invalidate]\n\t * @returns {Unsubscriber}\n\t */\n\tfunction subscribe(run, invalidate = noop) {\n\t\t/** @type {SubscribeInvalidateTuple} */\n\t\tconst subscriber = [run, invalidate];\n\t\tsubscribers.add(subscriber);\n\t\tif (subscribers.size === 1) {\n\t\t\tstop = start(set, update) || noop;\n\t\t}\n\t\trun(/** @type {T} */ (value));\n\t\treturn () => {\n\t\t\tsubscribers.delete(subscriber);\n\t\t\tif (subscribers.size === 0 && stop) {\n\t\t\t\tstop();\n\t\t\t\tstop = null;\n\t\t\t}\n\t\t};\n\t}\n\treturn { set, update, subscribe };\n}\n\n/**\n * Derived value store by synchronizing one or more readable stores and\n * applying an aggregation function over its input values.\n *\n * @template {Stores} S\n * @template T\n * @overload\n * @param {S} stores\n * @param {(values: StoresValues, set: (value: T) => void, update: (fn: Updater) => void) => Unsubscriber | void} fn\n * @param {T} [initial_value]\n * @returns {Readable}\n */\n/**\n * Derived value store by synchronizing one or more readable stores and\n * applying an aggregation function over its input values.\n *\n * @template {Stores} S\n * @template T\n * @overload\n * @param {S} stores\n * @param {(values: StoresValues) => T} fn\n * @param {T} [initial_value]\n * @returns {Readable}\n */\n/**\n * @template {Stores} S\n * @template T\n * @param {S} stores\n * @param {Function} fn\n * @param {T} [initial_value]\n * @returns {Readable}\n */\nexport function derived(stores, fn, initial_value) {\n\tconst single = !Array.isArray(stores);\n\t/** @type {Array>} */\n\tconst stores_array = single ? [stores] : stores;\n\tif (!stores_array.every(Boolean)) {\n\t\tthrow new Error('derived() expects stores as input, got a falsy value');\n\t}\n\tconst auto = fn.length < 2;\n\treturn readable(initial_value, (set, update) => {\n\t\tlet started = false;\n\t\t/** @type {T[]} */\n\t\tconst values = [];\n\t\tlet pending = 0;\n\t\tlet cleanup = noop;\n\t\tconst sync = () => {\n\t\t\tif (pending) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tcleanup();\n\t\t\tconst result = fn(single ? values[0] : values, set, update);\n\t\t\tif (auto) {\n\t\t\t\tset(result);\n\t\t\t} else {\n\t\t\t\tcleanup = typeof result === 'function' ? result : noop;\n\t\t\t}\n\t\t};\n\t\tconst unsubscribers = stores_array.map((store, i) =>\n\t\t\tsubscribe_to_store(\n\t\t\t\tstore,\n\t\t\t\t(value) => {\n\t\t\t\t\tvalues[i] = value;\n\t\t\t\t\tpending &= ~(1 << i);\n\t\t\t\t\tif (started) {\n\t\t\t\t\t\tsync();\n\t\t\t\t\t}\n\t\t\t\t},\n\t\t\t\t() => {\n\t\t\t\t\tpending |= 1 << i;\n\t\t\t\t}\n\t\t\t)\n\t\t);\n\t\tstarted = true;\n\t\tsync();\n\t\treturn function stop() {\n\t\t\trun_all(unsubscribers);\n\t\t\tcleanup();\n\t\t\t// We need to set this to false because callbacks can still happen despite having unsubscribed:\n\t\t\t// Callbacks might already be placed in the queue which doesn't know it should no longer\n\t\t\t// invoke this derived store.\n\t\t\tstarted = false;\n\t\t};\n\t});\n}\n\n/**\n * Takes a store and returns a new one derived from the old one that is readable.\n *\n * @template T\n * @param {Readable} store - store to make readonly\n * @returns {Readable}\n */\nexport function readonly(store) {\n\treturn {\n\t\t// @ts-expect-error TODO i suspect the bind is unnecessary\n\t\tsubscribe: store.subscribe.bind(store)\n\t};\n}\n\n/**\n * Get the current value from a store by subscribing and immediately unsubscribing.\n *\n * @template T\n * @param {Readable} store\n * @returns {T}\n */\nexport function get(store) {\n\tlet value;\n\tsubscribe_to_store(store, (_) => (value = _))();\n\t// @ts-expect-error\n\treturn value;\n}\n","/** @import { StoreReferencesContainer } from '#client' */\n/** @import { Store } from '#shared' */\nimport { subscribe_to_store } from '../../../store/utils.js';\nimport { get as get_store } from '../../../store/shared/index.js';\nimport { define_property, noop } from '../../shared/utils.js';\nimport { get } from '../runtime.js';\nimport { teardown } from './effects.js';\nimport { mutable_source, set } from './sources.js';\nimport { DEV } from 'esm-env';\n\n/**\n * We set this to `true` when updating a store so that we correctly\n * schedule effects if the update takes place inside a `$:` effect\n */\nexport let legacy_is_updating_store = false;\n\n/**\n * Whether or not the prop currently being read is a store binding, as in\n * ``. If it is, we treat the prop as mutable even in\n * runes mode, and skip `binding_property_non_reactive` validation\n */\nlet is_store_binding = false;\n\nlet IS_UNMOUNTED = Symbol();\n\n/**\n * Gets the current value of a store. If the store isn't subscribed to yet, it will create a proxy\n * signal that will be updated when the store is. The store references container is needed to\n * track reassignments to stores and to track the correct component context.\n * @template V\n * @param {Store | null | undefined} store\n * @param {string} store_name\n * @param {StoreReferencesContainer} stores\n * @returns {V}\n */\nexport function store_get(store, store_name, stores) {\n\tconst entry = (stores[store_name] ??= {\n\t\tstore: null,\n\t\tsource: mutable_source(undefined),\n\t\tunsubscribe: noop\n\t});\n\n\tif (DEV) {\n\t\tentry.source.label = store_name;\n\t}\n\n\t// if the component that setup this is already unmounted we don't want to register a subscription\n\tif (entry.store !== store && !(IS_UNMOUNTED in stores)) {\n\t\tentry.unsubscribe();\n\t\tentry.store = store ?? null;\n\n\t\tif (store == null) {\n\t\t\tentry.source.v = undefined; // see synchronous callback comment below\n\t\t\tentry.unsubscribe = noop;\n\t\t} else {\n\t\t\tvar is_synchronous_callback = true;\n\n\t\t\tentry.unsubscribe = subscribe_to_store(store, (v) => {\n\t\t\t\tif (is_synchronous_callback) {\n\t\t\t\t\t// If the first updates to the store value (possibly multiple of them) are synchronously\n\t\t\t\t\t// inside a derived, we will hit the `state_unsafe_mutation` error if we `set` the value\n\t\t\t\t\tentry.source.v = v;\n\t\t\t\t} else {\n\t\t\t\t\tset(entry.source, v);\n\t\t\t\t}\n\t\t\t});\n\n\t\t\tis_synchronous_callback = false;\n\t\t}\n\t}\n\n\t// if the component that setup this stores is already unmounted the source will be out of sync\n\t// so we just use the `get` for the stores, less performant but it avoids to create a memory leak\n\t// and it will keep the value consistent\n\tif (store && IS_UNMOUNTED in stores) {\n\t\treturn get_store(store);\n\t}\n\n\treturn get(entry.source);\n}\n\n/**\n * Unsubscribe from a store if it's not the same as the one in the store references container.\n * We need this in addition to `store_get` because someone could unsubscribe from a store but\n * then never subscribe to the new one (if any), causing the subscription to stay open wrongfully.\n * @param {Store | null | undefined} store\n * @param {string} store_name\n * @param {StoreReferencesContainer} stores\n */\nexport function store_unsub(store, store_name, stores) {\n\t/** @type {StoreReferencesContainer[''] | undefined} */\n\tlet entry = stores[store_name];\n\n\tif (entry && entry.store !== store) {\n\t\t// Don't reset store yet, so that store_get above can resubscribe to new store if necessary\n\t\tentry.unsubscribe();\n\t\tentry.unsubscribe = noop;\n\t}\n\n\treturn store;\n}\n\n/**\n * Sets the new value of a store and returns that value.\n * @template V\n * @param {Store} store\n * @param {V} value\n * @returns {V}\n */\nexport function store_set(store, value) {\n\tupdate_with_flag(store, value);\n\treturn value;\n}\n\n/**\n * @param {StoreReferencesContainer} stores\n * @param {string} store_name\n */\nexport function invalidate_store(stores, store_name) {\n\tvar entry = stores[store_name];\n\tif (entry.store !== null) {\n\t\tstore_set(entry.store, entry.source.v);\n\t}\n}\n\n/**\n * Unsubscribes from all auto-subscribed stores on destroy\n * @returns {[StoreReferencesContainer, ()=>void]}\n */\nexport function setup_stores() {\n\t/** @type {StoreReferencesContainer} */\n\tconst stores = {};\n\n\tfunction cleanup() {\n\t\tteardown(() => {\n\t\t\tfor (var store_name in stores) {\n\t\t\t\tconst ref = stores[store_name];\n\t\t\t\tref.unsubscribe();\n\t\t\t}\n\t\t\tdefine_property(stores, IS_UNMOUNTED, {\n\t\t\t\tenumerable: false,\n\t\t\t\tvalue: true\n\t\t\t});\n\t\t});\n\t}\n\n\treturn [stores, cleanup];\n}\n\n/**\n * @param {Store} store\n * @param {V} value\n * @template V\n */\nfunction update_with_flag(store, value) {\n\tlegacy_is_updating_store = true;\n\n\ttry {\n\t\tstore.set(value);\n\t} finally {\n\t\tlegacy_is_updating_store = false;\n\t}\n}\n\n/**\n * Updates a store with a new value.\n * @param {Store} store the store to update\n * @param {any} expression the expression that mutates the store\n * @param {V} new_value the new store value\n * @template V\n */\nexport function store_mutate(store, expression, new_value) {\n\tupdate_with_flag(store, new_value);\n\treturn expression;\n}\n\n/**\n * @param {Store} store\n * @param {number} store_value\n * @param {1 | -1} [d]\n * @returns {number}\n */\nexport function update_store(store, store_value, d = 1) {\n\tupdate_with_flag(store, store_value + d);\n\treturn store_value;\n}\n\n/**\n * @param {Store} store\n * @param {number} store_value\n * @param {1 | -1} [d]\n * @returns {number}\n */\nexport function update_pre_store(store, store_value, d = 1) {\n\tconst value = store_value + d;\n\tupdate_with_flag(store, value);\n\treturn value;\n}\n\n/**\n * Called inside prop getters to communicate that the prop is a store binding\n */\nexport function mark_store_binding() {\n\tis_store_binding = true;\n}\n\n/**\n * Returns a tuple that indicates whether `fn()` reads a prop that is a store binding.\n * Used to prevent `binding_property_non_reactive` validation false positives and\n * ensure that these props are treated as mutable even in runes mode\n * @template T\n * @param {() => T} fn\n * @returns {[T, boolean]}\n */\nexport function capture_store_binding(fn) {\n\tvar previous_is_store_binding = is_store_binding;\n\n\ttry {\n\t\tis_store_binding = false;\n\t\treturn [fn(), is_store_binding];\n\t} finally {\n\t\tis_store_binding = previous_is_store_binding;\n\t}\n}\n","/** @import { Fork } from 'svelte' */\n/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */\nimport {\n\tBLOCK_EFFECT,\n\tBRANCH_EFFECT,\n\tCLEAN,\n\tDESTROYED,\n\tDIRTY,\n\tEFFECT,\n\tASYNC,\n\tINERT,\n\tRENDER_EFFECT,\n\tROOT_EFFECT,\n\tMAYBE_DIRTY,\n\tDERIVED,\n\tEAGER_EFFECT,\n\tERROR_VALUE,\n\tMANAGED_EFFECT,\n\tREACTION_RAN\n} from '#client/constants';\nimport { async_mode_flag } from '../../flags/index.js';\nimport { deferred, define_property, includes } from '../../shared/utils.js';\nimport {\n\tactive_effect,\n\tactive_reaction,\n\tget,\n\tincrement_write_version,\n\tis_dirty,\n\tupdate_effect\n} from '../runtime.js';\nimport * as e from '../errors.js';\nimport { flush_tasks, queue_micro_task } from '../dom/task.js';\nimport { DEV } from 'esm-env';\nimport { invoke_error_boundary } from '../error-handling.js';\nimport { flush_eager_effects, old_values, set_eager_effects, source, update } from './sources.js';\nimport { eager_effect, unlink_effect } from './effects.js';\nimport { defer_effect } from './utils.js';\nimport { UNINITIALIZED } from '../../../constants.js';\nimport { set_signal_status } from './status.js';\nimport { legacy_is_updating_store } from './store.js';\n\n/** @type {Set} */\nconst batches = new Set();\n\n/** @type {Batch | null} */\nexport let current_batch = null;\n\n/**\n * This is needed to avoid overwriting inputs\n * @type {Batch | null}\n */\nexport let previous_batch = null;\n\n/**\n * When time travelling (i.e. working in one batch, while other batches\n * still have ongoing work), we ignore the real values of affected\n * signals in favour of their values within the batch\n * @type {Map | null}\n */\nexport let batch_values = null;\n\n/** @type {Effect | null} */\nlet last_scheduled_effect = null;\n\nexport let is_flushing_sync = false;\nlet is_processing = false;\n\n/**\n * During traversal, this is an array. Newly created effects are (if not immediately\n * executed) pushed to this array, rather than going through the scheduling\n * rigamarole that would cause another turn of the flush loop.\n * @type {Effect[] | null}\n */\nexport let collected_effects = null;\n\n/**\n * An array of effects that are marked during traversal as a result of a `set`\n * (not `internal_set`) call. These will be added to the next batch and\n * trigger another `batch.process()`\n * @type {Effect[] | null}\n * @deprecated when we get rid of legacy mode and stores, we can get rid of this\n */\nexport let legacy_updates = null;\n\nvar flush_count = 0;\nvar source_stacks = DEV ? new Set() : null;\n\nlet uid = 1;\n\nexport class Batch {\n\t// for debugging. TODO remove once async is stable\n\tid = uid++;\n\n\t/**\n\t * The current values of any sources that are updated in this batch\n\t * They keys of this map are identical to `this.#previous`\n\t * @type {Map}\n\t */\n\tcurrent = new Map();\n\n\t/**\n\t * The values of any sources that are updated in this batch _before_ those updates took place.\n\t * They keys of this map are identical to `this.#current`\n\t * @type {Map}\n\t */\n\tprevious = new Map();\n\n\t/**\n\t * When the batch is committed (and the DOM is updated), we need to remove old branches\n\t * and append new ones by calling the functions added inside (if/each/key/etc) blocks\n\t * @type {Set<(batch: Batch) => void>}\n\t */\n\t#commit_callbacks = new Set();\n\n\t/**\n\t * If a fork is discarded, we need to destroy any effects that are no longer needed\n\t * @type {Set<(batch: Batch) => void>}\n\t */\n\t#discard_callbacks = new Set();\n\n\t/**\n\t * The number of async effects that are currently in flight\n\t */\n\t#pending = 0;\n\n\t/**\n\t * The number of async effects that are currently in flight, _not_ inside a pending boundary\n\t */\n\t#blocking_pending = 0;\n\n\t/**\n\t * A deferred that resolves when the batch is committed, used with `settled()`\n\t * TODO replace with Promise.withResolvers once supported widely enough\n\t * @type {{ promise: Promise, resolve: (value?: any) => void, reject: (reason: unknown) => void } | null}\n\t */\n\t#deferred = null;\n\n\t/**\n\t * The root effects that need to be flushed\n\t * @type {Effect[]}\n\t */\n\t#roots = [];\n\n\t/**\n\t * Deferred effects (which run after async work has completed) that are DIRTY\n\t * @type {Set}\n\t */\n\t#dirty_effects = new Set();\n\n\t/**\n\t * Deferred effects that are MAYBE_DIRTY\n\t * @type {Set}\n\t */\n\t#maybe_dirty_effects = new Set();\n\n\t/**\n\t * A map of branches that still exist, but will be destroyed when this batch\n\t * is committed — we skip over these during `process`.\n\t * The value contains child effects that were dirty/maybe_dirty before being reset,\n\t * so they can be rescheduled if the branch survives.\n\t * @type {Map}\n\t */\n\t#skipped_branches = new Map();\n\n\tis_fork = false;\n\n\t#decrement_queued = false;\n\n\t#is_deferred() {\n\t\treturn this.is_fork || this.#blocking_pending > 0;\n\t}\n\n\t/**\n\t * Add an effect to the #skipped_branches map and reset its children\n\t * @param {Effect} effect\n\t */\n\tskip_effect(effect) {\n\t\tif (!this.#skipped_branches.has(effect)) {\n\t\t\tthis.#skipped_branches.set(effect, { d: [], m: [] });\n\t\t}\n\t}\n\n\t/**\n\t * Remove an effect from the #skipped_branches map and reschedule\n\t * any tracked dirty/maybe_dirty child effects\n\t * @param {Effect} effect\n\t */\n\tunskip_effect(effect) {\n\t\tvar tracked = this.#skipped_branches.get(effect);\n\t\tif (tracked) {\n\t\t\tthis.#skipped_branches.delete(effect);\n\n\t\t\tfor (var e of tracked.d) {\n\t\t\t\tset_signal_status(e, DIRTY);\n\t\t\t\tthis.schedule(e);\n\t\t\t}\n\n\t\t\tfor (e of tracked.m) {\n\t\t\t\tset_signal_status(e, MAYBE_DIRTY);\n\t\t\t\tthis.schedule(e);\n\t\t\t}\n\t\t}\n\t}\n\n\t#process() {\n\t\tif (flush_count++ > 1000) {\n\t\t\tinfinite_loop_guard();\n\t\t}\n\n\t\tconst roots = this.#roots;\n\t\tthis.#roots = [];\n\n\t\tthis.apply();\n\n\t\t/** @type {Effect[]} */\n\t\tvar effects = (collected_effects = []);\n\n\t\t/** @type {Effect[]} */\n\t\tvar render_effects = [];\n\n\t\t/**\n\t\t * @type {Effect[]}\n\t\t * @deprecated when we get rid of legacy mode and stores, we can get rid of this\n\t\t */\n\t\tvar updates = (legacy_updates = []);\n\n\t\tfor (const root of roots) {\n\t\t\ttry {\n\t\t\t\tthis.#traverse(root, effects, render_effects);\n\t\t\t} catch (e) {\n\t\t\t\treset_all(root);\n\t\t\t\tthrow e;\n\t\t\t}\n\t\t}\n\n\t\t// any writes should take effect in a subsequent batch\n\t\tcurrent_batch = null;\n\n\t\tif (updates.length > 0) {\n\t\t\tvar batch = Batch.ensure();\n\t\t\tfor (const e of updates) {\n\t\t\t\tbatch.schedule(e);\n\t\t\t}\n\t\t}\n\n\t\tcollected_effects = null;\n\t\tlegacy_updates = null;\n\n\t\tif (this.#is_deferred()) {\n\t\t\tthis.#defer_effects(render_effects);\n\t\t\tthis.#defer_effects(effects);\n\n\t\t\tfor (const [e, t] of this.#skipped_branches) {\n\t\t\t\treset_branch(e, t);\n\t\t\t}\n\t\t} else {\n\t\t\tif (this.#pending === 0) {\n\t\t\t\tbatches.delete(this);\n\t\t\t}\n\n\t\t\t// clear effects. Those that are still needed will be rescheduled through unskipping the skipped branches.\n\t\t\tthis.#dirty_effects.clear();\n\t\t\tthis.#maybe_dirty_effects.clear();\n\n\t\t\t// append/remove branches\n\t\t\tfor (const fn of this.#commit_callbacks) fn(this);\n\t\t\tthis.#commit_callbacks.clear();\n\n\t\t\tprevious_batch = this;\n\t\t\tflush_queued_effects(render_effects);\n\t\t\tflush_queued_effects(effects);\n\t\t\tprevious_batch = null;\n\n\t\t\tthis.#deferred?.resolve();\n\t\t}\n\n\t\tvar next_batch = /** @type {Batch | null} */ (/** @type {unknown} */ (current_batch));\n\n\t\t// Edge case: During traversal new branches might create effects that run immediately and set state,\n\t\t// causing an effect and therefore a root to be scheduled again. We need to traverse the current batch\n\t\t// once more in that case - most of the time this will just clean up dirty branches.\n\t\tif (this.#roots.length > 0) {\n\t\t\tconst batch = (next_batch ??= this);\n\t\t\tbatch.#roots.push(...this.#roots.filter((r) => !batch.#roots.includes(r)));\n\t\t}\n\n\t\tif (next_batch !== null) {\n\t\t\tbatches.add(next_batch);\n\n\t\t\tif (DEV) {\n\t\t\t\tfor (const source of this.current.keys()) {\n\t\t\t\t\t/** @type {Set} */ (source_stacks).add(source);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tnext_batch.#process();\n\t\t}\n\n\t\tif (!batches.has(this)) {\n\t\t\tthis.#commit();\n\t\t}\n\t}\n\n\t/**\n\t * Traverse the effect tree, executing effects or stashing\n\t * them for later execution as appropriate\n\t * @param {Effect} root\n\t * @param {Effect[]} effects\n\t * @param {Effect[]} render_effects\n\t */\n\t#traverse(root, effects, render_effects) {\n\t\troot.f ^= CLEAN;\n\n\t\tvar effect = root.first;\n\n\t\twhile (effect !== null) {\n\t\t\tvar flags = effect.f;\n\t\t\tvar is_branch = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) !== 0;\n\t\t\tvar is_skippable_branch = is_branch && (flags & CLEAN) !== 0;\n\n\t\t\tvar skip = is_skippable_branch || (flags & INERT) !== 0 || this.#skipped_branches.has(effect);\n\n\t\t\tif (!skip && effect.fn !== null) {\n\t\t\t\tif (is_branch) {\n\t\t\t\t\teffect.f ^= CLEAN;\n\t\t\t\t} else if ((flags & EFFECT) !== 0) {\n\t\t\t\t\teffects.push(effect);\n\t\t\t\t} else if (async_mode_flag && (flags & (RENDER_EFFECT | MANAGED_EFFECT)) !== 0) {\n\t\t\t\t\trender_effects.push(effect);\n\t\t\t\t} else if (is_dirty(effect)) {\n\t\t\t\t\tif ((flags & BLOCK_EFFECT) !== 0) this.#maybe_dirty_effects.add(effect);\n\t\t\t\t\tupdate_effect(effect);\n\t\t\t\t}\n\n\t\t\t\tvar child = effect.first;\n\n\t\t\t\tif (child !== null) {\n\t\t\t\t\teffect = child;\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\twhile (effect !== null) {\n\t\t\t\tvar next = effect.next;\n\n\t\t\t\tif (next !== null) {\n\t\t\t\t\teffect = next;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\teffect = effect.parent;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * @param {Effect[]} effects\n\t */\n\t#defer_effects(effects) {\n\t\tfor (var i = 0; i < effects.length; i += 1) {\n\t\t\tdefer_effect(effects[i], this.#dirty_effects, this.#maybe_dirty_effects);\n\t\t}\n\t}\n\n\t/**\n\t * Associate a change to a given source with the current\n\t * batch, noting its previous and current values\n\t * @param {Source} source\n\t * @param {any} value\n\t */\n\tcapture(source, value) {\n\t\tif (value !== UNINITIALIZED && !this.previous.has(source)) {\n\t\t\tthis.previous.set(source, value);\n\t\t}\n\n\t\t// Don't save errors in `batch_values`, or they won't be thrown in `runtime.js#get`\n\t\tif ((source.f & ERROR_VALUE) === 0) {\n\t\t\tthis.current.set(source, source.v);\n\t\t\tbatch_values?.set(source, source.v);\n\t\t}\n\t}\n\n\tactivate() {\n\t\tcurrent_batch = this;\n\t}\n\n\tdeactivate() {\n\t\tcurrent_batch = null;\n\t\tbatch_values = null;\n\t}\n\n\tflush() {\n\t\tvar source_stacks = DEV ? new Set() : null;\n\n\t\ttry {\n\t\t\tis_processing = true;\n\t\t\tcurrent_batch = this;\n\n\t\t\t// we only reschedule previously-deferred effects if we expect\n\t\t\t// to be able to run them after processing the batch\n\t\t\tif (!this.#is_deferred()) {\n\t\t\t\tfor (const e of this.#dirty_effects) {\n\t\t\t\t\tthis.#maybe_dirty_effects.delete(e);\n\t\t\t\t\tset_signal_status(e, DIRTY);\n\t\t\t\t\tthis.schedule(e);\n\t\t\t\t}\n\n\t\t\t\tfor (const e of this.#maybe_dirty_effects) {\n\t\t\t\t\tset_signal_status(e, MAYBE_DIRTY);\n\t\t\t\t\tthis.schedule(e);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tthis.#process();\n\t\t} finally {\n\t\t\tflush_count = 0;\n\t\t\tlast_scheduled_effect = null;\n\t\t\tcollected_effects = null;\n\t\t\tlegacy_updates = null;\n\t\t\tis_processing = false;\n\n\t\t\tcurrent_batch = null;\n\t\t\tbatch_values = null;\n\n\t\t\told_values.clear();\n\n\t\t\tif (DEV) {\n\t\t\t\tfor (const source of /** @type {Set} */ (source_stacks)) {\n\t\t\t\t\tsource.updated = null;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tdiscard() {\n\t\tfor (const fn of this.#discard_callbacks) fn(this);\n\t\tthis.#discard_callbacks.clear();\n\t}\n\n\t#commit() {\n\t\t// If there are other pending batches, they now need to be 'rebased' —\n\t\t// in other words, we re-run block/async effects with the newly\n\t\t// committed state, unless the batch in question has a more\n\t\t// recent value for a given source\n\t\tfor (const batch of batches) {\n\t\t\tvar is_earlier = batch.id < this.id;\n\n\t\t\t/** @type {Source[]} */\n\t\t\tvar sources = [];\n\n\t\t\tfor (const [source, value] of this.current) {\n\t\t\t\tif (batch.current.has(source)) {\n\t\t\t\t\tif (is_earlier && value !== batch.current.get(source)) {\n\t\t\t\t\t\t// bring the value up to date\n\t\t\t\t\t\tbatch.current.set(source, value);\n\t\t\t\t\t} else {\n\t\t\t\t\t\t// same value or later batch has more recent value,\n\t\t\t\t\t\t// no need to re-run these effects\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tsources.push(source);\n\t\t\t}\n\n\t\t\tif (sources.length === 0) {\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// Re-run async/block effects that depend on distinct values changed in both batches\n\t\t\tvar others = [...batch.current.keys()].filter((s) => !this.current.has(s));\n\t\t\tif (others.length > 0) {\n\t\t\t\tbatch.activate();\n\n\t\t\t\t/** @type {Set} */\n\t\t\t\tvar marked = new Set();\n\n\t\t\t\t/** @type {Map} */\n\t\t\t\tvar checked = new Map();\n\n\t\t\t\tfor (var source of sources) {\n\t\t\t\t\tmark_effects(source, others, marked, checked);\n\t\t\t\t}\n\n\t\t\t\tif (batch.#roots.length > 0) {\n\t\t\t\t\tbatch.apply();\n\n\t\t\t\t\tfor (var root of batch.#roots) {\n\t\t\t\t\t\tbatch.#traverse(root, [], []);\n\t\t\t\t\t}\n\n\t\t\t\t\t// TODO do we need to do anything with the dummy effect arrays?\n\t\t\t\t}\n\n\t\t\t\tbatch.deactivate();\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t *\n\t * @param {boolean} blocking\n\t */\n\tincrement(blocking) {\n\t\tthis.#pending += 1;\n\t\tif (blocking) this.#blocking_pending += 1;\n\t}\n\n\t/**\n\t * @param {boolean} blocking\n\t * @param {boolean} skip - whether to skip updates (because this is triggered by a stale reaction)\n\t */\n\tdecrement(blocking, skip) {\n\t\tthis.#pending -= 1;\n\t\tif (blocking) this.#blocking_pending -= 1;\n\n\t\tif (this.#decrement_queued || skip) return;\n\t\tthis.#decrement_queued = true;\n\n\t\tqueue_micro_task(() => {\n\t\t\tthis.#decrement_queued = false;\n\t\t\tthis.flush();\n\t\t});\n\t}\n\n\t/** @param {(batch: Batch) => void} fn */\n\toncommit(fn) {\n\t\tthis.#commit_callbacks.add(fn);\n\t}\n\n\t/** @param {(batch: Batch) => void} fn */\n\tondiscard(fn) {\n\t\tthis.#discard_callbacks.add(fn);\n\t}\n\n\tsettled() {\n\t\treturn (this.#deferred ??= deferred()).promise;\n\t}\n\n\tstatic ensure() {\n\t\tif (current_batch === null) {\n\t\t\tconst batch = (current_batch = new Batch());\n\n\t\t\tif (!is_processing) {\n\t\t\t\tbatches.add(current_batch);\n\n\t\t\t\tif (!is_flushing_sync) {\n\t\t\t\t\tqueue_micro_task(() => {\n\t\t\t\t\t\tif (current_batch !== batch) {\n\t\t\t\t\t\t\t// a flushSync happened in the meantime\n\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tbatch.flush();\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn current_batch;\n\t}\n\n\tapply() {\n\t\tif (!async_mode_flag || (!this.is_fork && batches.size === 1)) {\n\t\t\tbatch_values = null;\n\t\t\treturn;\n\t\t}\n\n\t\t// if there are multiple batches, we are 'time travelling' —\n\t\t// we need to override values with the ones in this batch...\n\t\tbatch_values = new Map(this.current);\n\n\t\t// ...and undo changes belonging to other batches\n\t\tfor (const batch of batches) {\n\t\t\tif (batch === this) continue;\n\n\t\t\tfor (const [source, previous] of batch.previous) {\n\t\t\t\tif (!batch_values.has(source)) {\n\t\t\t\t\tbatch_values.set(source, previous);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t *\n\t * @param {Effect} effect\n\t */\n\tschedule(effect) {\n\t\tlast_scheduled_effect = effect;\n\n\t\t// defer render effects inside a pending boundary\n\t\t// TODO the `REACTION_RAN` check is only necessary because of legacy `$:` effects AFAICT — we can remove later\n\t\tif (\n\t\t\teffect.b?.is_pending &&\n\t\t\t(effect.f & (EFFECT | RENDER_EFFECT | MANAGED_EFFECT)) !== 0 &&\n\t\t\t(effect.f & REACTION_RAN) === 0\n\t\t) {\n\t\t\teffect.b.defer_effect(effect);\n\t\t\treturn;\n\t\t}\n\n\t\tvar e = effect;\n\n\t\twhile (e.parent !== null) {\n\t\t\te = e.parent;\n\t\t\tvar flags = e.f;\n\n\t\t\t// if the effect is being scheduled because a parent (each/await/etc) block\n\t\t\t// updated an internal source, or because a branch is being unskipped,\n\t\t\t// bail out or we'll cause a second flush\n\t\t\tif (collected_effects !== null && e === active_effect) {\n\t\t\t\tif (async_mode_flag) return;\n\n\t\t\t\t// in sync mode, render effects run during traversal. in an extreme edge case\n\t\t\t\t// — namely that we're setting a value inside a derived read during traversal —\n\t\t\t\t// they can be made dirty after they have already been visited, in which\n\t\t\t\t// case we shouldn't bail out. we also shouldn't bail out if we're\n\t\t\t\t// updating a store inside a `$:`, since this might invalidate\n\t\t\t\t// effects that were already visited\n\t\t\t\tif (\n\t\t\t\t\t(active_reaction === null || (active_reaction.f & DERIVED) === 0) &&\n\t\t\t\t\t!legacy_is_updating_store\n\t\t\t\t) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ((flags & (ROOT_EFFECT | BRANCH_EFFECT)) !== 0) {\n\t\t\t\tif ((flags & CLEAN) === 0) {\n\t\t\t\t\t// branch is already dirty, bail\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\te.f ^= CLEAN;\n\t\t\t}\n\t\t}\n\n\t\tthis.#roots.push(e);\n\t}\n}\n\n/**\n * Synchronously flush any pending updates.\n * Returns void if no callback is provided, otherwise returns the result of calling the callback.\n * @template [T=void]\n * @param {(() => T) | undefined} [fn]\n * @returns {T}\n */\nexport function flushSync(fn) {\n\tvar was_flushing_sync = is_flushing_sync;\n\tis_flushing_sync = true;\n\n\ttry {\n\t\tvar result;\n\n\t\tif (fn) {\n\t\t\tif (current_batch !== null && !current_batch.is_fork) {\n\t\t\t\tcurrent_batch.flush();\n\t\t\t}\n\n\t\t\tresult = fn();\n\t\t}\n\n\t\twhile (true) {\n\t\t\tflush_tasks();\n\n\t\t\tif (current_batch === null) {\n\t\t\t\treturn /** @type {T} */ (result);\n\t\t\t}\n\n\t\t\tcurrent_batch.flush();\n\t\t}\n\t} finally {\n\t\tis_flushing_sync = was_flushing_sync;\n\t}\n}\n\nfunction infinite_loop_guard() {\n\tif (DEV) {\n\t\tvar updates = new Map();\n\n\t\tfor (const source of /** @type {Batch} */ (current_batch).current.keys()) {\n\t\t\tfor (const [stack, update] of source.updated ?? []) {\n\t\t\t\tvar entry = updates.get(stack);\n\n\t\t\t\tif (!entry) {\n\t\t\t\t\tentry = { error: update.error, count: 0 };\n\t\t\t\t\tupdates.set(stack, entry);\n\t\t\t\t}\n\n\t\t\t\tentry.count += update.count;\n\t\t\t}\n\t\t}\n\n\t\tfor (const update of updates.values()) {\n\t\t\tif (update.error) {\n\t\t\t\t// eslint-disable-next-line no-console\n\t\t\t\tconsole.error(update.error);\n\t\t\t}\n\t\t}\n\t}\n\n\ttry {\n\t\te.effect_update_depth_exceeded();\n\t} catch (error) {\n\t\tif (DEV) {\n\t\t\t// stack contains no useful information, replace it\n\t\t\tdefine_property(error, 'stack', { value: '' });\n\t\t}\n\n\t\t// Best effort: invoke the boundary nearest the most recent\n\t\t// effect and hope that it's relevant to the infinite loop\n\t\tinvoke_error_boundary(error, last_scheduled_effect);\n\t}\n}\n\n/** @type {Set | null} */\nexport let eager_block_effects = null;\n\n/**\n * @param {Array} effects\n * @returns {void}\n */\nfunction flush_queued_effects(effects) {\n\tvar length = effects.length;\n\tif (length === 0) return;\n\n\tvar i = 0;\n\n\twhile (i < length) {\n\t\tvar effect = effects[i++];\n\n\t\tif ((effect.f & (DESTROYED | INERT)) === 0 && is_dirty(effect)) {\n\t\t\teager_block_effects = new Set();\n\n\t\t\tupdate_effect(effect);\n\n\t\t\t// Effects with no dependencies or teardown do not get added to the effect tree.\n\t\t\t// Deferred effects (e.g. `$effect(...)`) _are_ added to the tree because we\n\t\t\t// don't know if we need to keep them until they are executed. Doing the check\n\t\t\t// here (rather than in `update_effect`) allows us to skip the work for\n\t\t\t// immediate effects.\n\t\t\tif (\n\t\t\t\teffect.deps === null &&\n\t\t\t\teffect.first === null &&\n\t\t\t\teffect.nodes === null &&\n\t\t\t\teffect.teardown === null &&\n\t\t\t\teffect.ac === null\n\t\t\t) {\n\t\t\t\t// remove this effect from the graph\n\t\t\t\tunlink_effect(effect);\n\t\t\t}\n\n\t\t\t// If update_effect() has a flushSync() in it, we may have flushed another flush_queued_effects(),\n\t\t\t// which already handled this logic and did set eager_block_effects to null.\n\t\t\tif (eager_block_effects?.size > 0) {\n\t\t\t\told_values.clear();\n\n\t\t\t\tfor (const e of eager_block_effects) {\n\t\t\t\t\t// Skip eager effects that have already been unmounted\n\t\t\t\t\tif ((e.f & (DESTROYED | INERT)) !== 0) continue;\n\n\t\t\t\t\t// Run effects in order from ancestor to descendant, else we could run into nullpointers\n\t\t\t\t\t/** @type {Effect[]} */\n\t\t\t\t\tconst ordered_effects = [e];\n\t\t\t\t\tlet ancestor = e.parent;\n\t\t\t\t\twhile (ancestor !== null) {\n\t\t\t\t\t\tif (eager_block_effects.has(ancestor)) {\n\t\t\t\t\t\t\teager_block_effects.delete(ancestor);\n\t\t\t\t\t\t\tordered_effects.push(ancestor);\n\t\t\t\t\t\t}\n\t\t\t\t\t\tancestor = ancestor.parent;\n\t\t\t\t\t}\n\n\t\t\t\t\tfor (let j = ordered_effects.length - 1; j >= 0; j--) {\n\t\t\t\t\t\tconst e = ordered_effects[j];\n\t\t\t\t\t\t// Skip eager effects that have already been unmounted\n\t\t\t\t\t\tif ((e.f & (DESTROYED | INERT)) !== 0) continue;\n\t\t\t\t\t\tupdate_effect(e);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\teager_block_effects.clear();\n\t\t\t}\n\t\t}\n\t}\n\n\teager_block_effects = null;\n}\n\n/**\n * This is similar to `mark_reactions`, but it only marks async/block effects\n * depending on `value` and at least one of the other `sources`, so that\n * these effects can re-run after another batch has been committed\n * @param {Value} value\n * @param {Source[]} sources\n * @param {Set} marked\n * @param {Map} checked\n */\nfunction mark_effects(value, sources, marked, checked) {\n\tif (marked.has(value)) return;\n\tmarked.add(value);\n\n\tif (value.reactions !== null) {\n\t\tfor (const reaction of value.reactions) {\n\t\t\tconst flags = reaction.f;\n\n\t\t\tif ((flags & DERIVED) !== 0) {\n\t\t\t\tmark_effects(/** @type {Derived} */ (reaction), sources, marked, checked);\n\t\t\t} else if (\n\t\t\t\t(flags & (ASYNC | BLOCK_EFFECT)) !== 0 &&\n\t\t\t\t(flags & DIRTY) === 0 &&\n\t\t\t\tdepends_on(reaction, sources, checked)\n\t\t\t) {\n\t\t\t\tset_signal_status(reaction, DIRTY);\n\t\t\t\tschedule_effect(/** @type {Effect} */ (reaction));\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * When committing a fork, we need to trigger eager effects so that\n * any `$state.eager(...)` expressions update immediately. This\n * function allows us to discover them\n * @param {Value} value\n * @param {Set} effects\n */\nfunction mark_eager_effects(value, effects) {\n\tif (value.reactions === null) return;\n\n\tfor (const reaction of value.reactions) {\n\t\tconst flags = reaction.f;\n\n\t\tif ((flags & DERIVED) !== 0) {\n\t\t\tmark_eager_effects(/** @type {Derived} */ (reaction), effects);\n\t\t} else if ((flags & EAGER_EFFECT) !== 0) {\n\t\t\tset_signal_status(reaction, DIRTY);\n\t\t\teffects.add(/** @type {Effect} */ (reaction));\n\t\t}\n\t}\n}\n\n/**\n * @param {Reaction} reaction\n * @param {Source[]} sources\n * @param {Map} checked\n */\nfunction depends_on(reaction, sources, checked) {\n\tconst depends = checked.get(reaction);\n\tif (depends !== undefined) return depends;\n\n\tif (reaction.deps !== null) {\n\t\tfor (const dep of reaction.deps) {\n\t\t\tif (includes.call(sources, dep)) {\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tif ((dep.f & DERIVED) !== 0 && depends_on(/** @type {Derived} */ (dep), sources, checked)) {\n\t\t\t\tchecked.set(/** @type {Derived} */ (dep), true);\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\t}\n\n\tchecked.set(reaction, false);\n\n\treturn false;\n}\n\n/**\n * @param {Effect} effect\n * @returns {void}\n */\nexport function schedule_effect(effect) {\n\t/** @type {Batch} */ (current_batch).schedule(effect);\n}\n\n/** @type {Source[]} */\nlet eager_versions = [];\n\nfunction eager_flush() {\n\ttry {\n\t\tflushSync(() => {\n\t\t\tfor (const version of eager_versions) {\n\t\t\t\tupdate(version);\n\t\t\t}\n\t\t});\n\t} finally {\n\t\teager_versions = [];\n\t}\n}\n\n/**\n * Implementation of `$state.eager(fn())`\n * @template T\n * @param {() => T} fn\n * @returns {T}\n */\nexport function eager(fn) {\n\tvar version = source(0);\n\tvar initial = true;\n\tvar value = /** @type {T} */ (undefined);\n\n\tget(version);\n\n\teager_effect(() => {\n\t\tif (initial) {\n\t\t\t// the first time this runs, we create an eager effect\n\t\t\t// that will run eagerly whenever the expression changes\n\t\t\tvar previous_batch_values = batch_values;\n\n\t\t\ttry {\n\t\t\t\tbatch_values = null;\n\t\t\t\tvalue = fn();\n\t\t\t} finally {\n\t\t\t\tbatch_values = previous_batch_values;\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\t// the second time this effect runs, it's to schedule a\n\t\t// `version` update. since this will recreate the effect,\n\t\t// we don't need to evaluate the expression here\n\t\tif (eager_versions.length === 0) {\n\t\t\tqueue_micro_task(eager_flush);\n\t\t}\n\n\t\teager_versions.push(version);\n\t});\n\n\tinitial = false;\n\n\treturn value;\n}\n\n/**\n * Mark all the effects inside a skipped branch CLEAN, so that\n * they can be correctly rescheduled later. Tracks dirty and maybe_dirty\n * effects so they can be rescheduled if the branch survives.\n * @param {Effect} effect\n * @param {{ d: Effect[], m: Effect[] }} tracked\n */\nfunction reset_branch(effect, tracked) {\n\t// clean branch = nothing dirty inside, no need to traverse further\n\tif ((effect.f & BRANCH_EFFECT) !== 0 && (effect.f & CLEAN) !== 0) {\n\t\treturn;\n\t}\n\n\tif ((effect.f & DIRTY) !== 0) {\n\t\ttracked.d.push(effect);\n\t} else if ((effect.f & MAYBE_DIRTY) !== 0) {\n\t\ttracked.m.push(effect);\n\t}\n\n\tset_signal_status(effect, CLEAN);\n\n\tvar e = effect.first;\n\twhile (e !== null) {\n\t\treset_branch(e, tracked);\n\t\te = e.next;\n\t}\n}\n\n/**\n * Mark an entire effect tree clean following an error\n * @param {Effect} effect\n */\nfunction reset_all(effect) {\n\tset_signal_status(effect, CLEAN);\n\n\tvar e = effect.first;\n\twhile (e !== null) {\n\t\treset_all(e);\n\t\te = e.next;\n\t}\n}\n\n/**\n * Creates a 'fork', in which state changes are evaluated but not applied to the DOM.\n * This is useful for speculatively loading data (for example) when you suspect that\n * the user is about to take some action.\n *\n * Frameworks like SvelteKit can use this to preload data when the user touches or\n * hovers over a link, making any subsequent navigation feel instantaneous.\n *\n * The `fn` parameter is a synchronous function that modifies some state. The\n * state changes will be reverted after the fork is initialised, then reapplied\n * if and when the fork is eventually committed.\n *\n * When it becomes clear that a fork will _not_ be committed (e.g. because the\n * user navigated elsewhere), it must be discarded to avoid leaking memory.\n *\n * @param {() => void} fn\n * @returns {Fork}\n * @since 5.42\n */\nexport function fork(fn) {\n\tif (!async_mode_flag) {\n\t\te.experimental_async_required('fork');\n\t}\n\n\tif (current_batch !== null) {\n\t\te.fork_timing();\n\t}\n\n\tvar batch = Batch.ensure();\n\tbatch.is_fork = true;\n\tbatch_values = new Map();\n\n\tvar committed = false;\n\tvar settled = batch.settled();\n\n\tflushSync(fn);\n\n\t// revert state changes\n\tfor (var [source, value] of batch.previous) {\n\t\tsource.v = value;\n\t}\n\n\t// make writable deriveds dirty, so they recalculate correctly\n\tfor (source of batch.current.keys()) {\n\t\tif ((source.f & DERIVED) !== 0) {\n\t\t\tset_signal_status(source, DIRTY);\n\t\t}\n\t}\n\n\treturn {\n\t\tcommit: async () => {\n\t\t\tif (committed) {\n\t\t\t\tawait settled;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (!batches.has(batch)) {\n\t\t\t\te.fork_discarded();\n\t\t\t}\n\n\t\t\tcommitted = true;\n\n\t\t\tbatch.is_fork = false;\n\n\t\t\t// apply changes and update write versions so deriveds see the change\n\t\t\tfor (var [source, value] of batch.current) {\n\t\t\t\tsource.v = value;\n\t\t\t\tsource.wv = increment_write_version();\n\t\t\t}\n\n\t\t\t// trigger any `$state.eager(...)` expressions with the new state.\n\t\t\t// eager effects don't get scheduled like other effects, so we\n\t\t\t// can't just encounter them during traversal, we need to\n\t\t\t// proactively flush them\n\t\t\t// TODO maybe there's a better implementation?\n\t\t\tflushSync(() => {\n\t\t\t\t/** @type {Set} */\n\t\t\t\tvar eager_effects = new Set();\n\n\t\t\t\tfor (var source of batch.current.keys()) {\n\t\t\t\t\tmark_eager_effects(source, eager_effects);\n\t\t\t\t}\n\n\t\t\t\tset_eager_effects(eager_effects);\n\t\t\t\tflush_eager_effects();\n\t\t\t});\n\n\t\t\tbatch.flush();\n\t\t\tawait settled;\n\t\t},\n\t\tdiscard: () => {\n\t\t\t// cause any MAYBE_DIRTY deriveds to update\n\t\t\t// if they depend on things thath changed\n\t\t\t// inside the discarded fork\n\t\t\tfor (var source of batch.current.keys()) {\n\t\t\t\tsource.wv = increment_write_version();\n\t\t\t}\n\n\t\t\tif (!committed && batches.has(batch)) {\n\t\t\t\tbatches.delete(batch);\n\t\t\t\tbatch.discard();\n\t\t\t}\n\t\t}\n\t};\n}\n\n/**\n * Forcibly remove all current batches, to prevent cross-talk between tests\n */\nexport function clear() {\n\tbatches.clear();\n}\n","import { get, tick, untrack } from '../internal/client/runtime.js';\nimport { effect_tracking, render_effect } from '../internal/client/reactivity/effects.js';\nimport { source, increment } from '../internal/client/reactivity/sources.js';\nimport { tag } from '../internal/client/dev/tracing.js';\nimport { DEV } from 'esm-env';\nimport { queue_micro_task } from '../internal/client/dom/task.js';\n\n/**\n * Returns a `subscribe` function that integrates external event-based systems with Svelte's reactivity.\n * It's particularly useful for integrating with web APIs like `MediaQuery`, `IntersectionObserver`, or `WebSocket`.\n *\n * If `subscribe` is called inside an effect (including indirectly, for example inside a getter),\n * the `start` callback will be called with an `update` function. Whenever `update` is called, the effect re-runs.\n *\n * If `start` returns a cleanup function, it will be called when the effect is destroyed.\n *\n * If `subscribe` is called in multiple effects, `start` will only be called once as long as the effects\n * are active, and the returned teardown function will only be called when all effects are destroyed.\n *\n * It's best understood with an example. Here's an implementation of [`MediaQuery`](https://svelte.dev/docs/svelte/svelte-reactivity#MediaQuery):\n *\n * ```js\n * import { createSubscriber } from 'svelte/reactivity';\n * import { on } from 'svelte/events';\n *\n * export class MediaQuery {\n * \t#query;\n * \t#subscribe;\n *\n * \tconstructor(query) {\n * \t\tthis.#query = window.matchMedia(`(${query})`);\n *\n * \t\tthis.#subscribe = createSubscriber((update) => {\n * \t\t\t// when the `change` event occurs, re-run any effects that read `this.current`\n * \t\t\tconst off = on(this.#query, 'change', update);\n *\n * \t\t\t// stop listening when all the effects are destroyed\n * \t\t\treturn () => off();\n * \t\t});\n * \t}\n *\n * \tget current() {\n * \t\t// This makes the getter reactive, if read in an effect\n * \t\tthis.#subscribe();\n *\n * \t\t// Return the current state of the query, whether or not we're in an effect\n * \t\treturn this.#query.matches;\n * \t}\n * }\n * ```\n * @param {(update: () => void) => (() => void) | void} start\n * @since 5.7.0\n */\nexport function createSubscriber(start) {\n\tlet subscribers = 0;\n\tlet version = source(0);\n\t/** @type {(() => void) | void} */\n\tlet stop;\n\n\tif (DEV) {\n\t\ttag(version, 'createSubscriber version');\n\t}\n\n\treturn () => {\n\t\tif (effect_tracking()) {\n\t\t\tget(version);\n\n\t\t\trender_effect(() => {\n\t\t\t\tif (subscribers === 0) {\n\t\t\t\t\tstop = untrack(() => start(() => increment(version)));\n\t\t\t\t}\n\n\t\t\t\tsubscribers += 1;\n\n\t\t\t\treturn () => {\n\t\t\t\t\tqueue_micro_task(() => {\n\t\t\t\t\t\t// Only count down after a microtask, else we would reach 0 before our own render effect reruns,\n\t\t\t\t\t\t// but reach 1 again when the tick callback of the prior teardown runs. That would mean we\n\t\t\t\t\t\t// re-subcribe unnecessarily and create a memory leak because the old subscription is never cleaned up.\n\t\t\t\t\t\tsubscribers -= 1;\n\n\t\t\t\t\t\tif (subscribers === 0) {\n\t\t\t\t\t\t\tstop?.();\n\t\t\t\t\t\t\tstop = undefined;\n\t\t\t\t\t\t\t// Increment the version to ensure any dependent deriveds are marked dirty when the subscription is picked up again later.\n\t\t\t\t\t\t\t// If we didn't do this then the comparison of write versions would determine that the derived has a later version than\n\t\t\t\t\t\t\t// the subscriber, and it would not be re-run.\n\t\t\t\t\t\t\tincrement(version);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t};\n\t\t\t});\n\t\t}\n\t};\n}\n","/** @import { Effect, Source, TemplateNode, } from '#client' */\nimport {\n\tBOUNDARY_EFFECT,\n\tDIRTY,\n\tEFFECT_PRESERVED,\n\tEFFECT_TRANSPARENT,\n\tMAYBE_DIRTY\n} from '#client/constants';\nimport { HYDRATION_START_ELSE, HYDRATION_START_FAILED } from '../../../../constants.js';\nimport { component_context, set_component_context } from '../../context.js';\nimport { handle_error, invoke_error_boundary } from '../../error-handling.js';\nimport {\n\tblock,\n\tbranch,\n\tdestroy_effect,\n\tmove_effect,\n\tpause_effect\n} from '../../reactivity/effects.js';\nimport {\n\tactive_effect,\n\tactive_reaction,\n\tget,\n\tset_active_effect,\n\tset_active_reaction\n} from '../../runtime.js';\nimport {\n\thydrate_next,\n\thydrate_node,\n\thydrating,\n\tnext,\n\tskip_nodes,\n\tset_hydrate_node\n} from '../hydration.js';\nimport { queue_micro_task } from '../task.js';\nimport * as e from '../../errors.js';\nimport * as w from '../../warnings.js';\nimport { DEV } from 'esm-env';\nimport { Batch, current_batch, schedule_effect } from '../../reactivity/batch.js';\nimport { internal_set, source } from '../../reactivity/sources.js';\nimport { tag } from '../../dev/tracing.js';\nimport { createSubscriber } from '../../../../reactivity/create-subscriber.js';\nimport { create_text } from '../operations.js';\nimport { defer_effect } from '../../reactivity/utils.js';\nimport { set_signal_status } from '../../reactivity/status.js';\n\n/**\n * @typedef {{\n * \t onerror?: (error: unknown, reset: () => void) => void;\n * failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void;\n * pending?: (anchor: Node) => void;\n * }} BoundaryProps\n */\n\nvar flags = EFFECT_TRANSPARENT | EFFECT_PRESERVED;\n\n/**\n * @param {TemplateNode} node\n * @param {BoundaryProps} props\n * @param {((anchor: Node) => void)} children\n * @param {((error: unknown) => unknown) | undefined} [transform_error]\n * @returns {void}\n */\nexport function boundary(node, props, children, transform_error) {\n\tnew Boundary(node, props, children, transform_error);\n}\n\nexport class Boundary {\n\t/** @type {Boundary | null} */\n\tparent;\n\n\tis_pending = false;\n\n\t/**\n\t * API-level transformError transform function. Transforms errors before they reach the `failed` snippet.\n\t * Inherited from parent boundary, or defaults to identity.\n\t * @type {(error: unknown) => unknown}\n\t */\n\ttransform_error;\n\n\t/** @type {TemplateNode} */\n\t#anchor;\n\n\t/** @type {TemplateNode | null} */\n\t#hydrate_open = hydrating ? hydrate_node : null;\n\n\t/** @type {BoundaryProps} */\n\t#props;\n\n\t/** @type {((anchor: Node) => void)} */\n\t#children;\n\n\t/** @type {Effect} */\n\t#effect;\n\n\t/** @type {Effect | null} */\n\t#main_effect = null;\n\n\t/** @type {Effect | null} */\n\t#pending_effect = null;\n\n\t/** @type {Effect | null} */\n\t#failed_effect = null;\n\n\t/** @type {DocumentFragment | null} */\n\t#offscreen_fragment = null;\n\n\t#local_pending_count = 0;\n\t#pending_count = 0;\n\t#pending_count_update_queued = false;\n\n\t/** @type {Set} */\n\t#dirty_effects = new Set();\n\n\t/** @type {Set} */\n\t#maybe_dirty_effects = new Set();\n\n\t/**\n\t * A source containing the number of pending async deriveds/expressions.\n\t * Only created if `$effect.pending()` is used inside the boundary,\n\t * otherwise updating the source results in needless `Batch.ensure()`\n\t * calls followed by no-op flushes\n\t * @type {Source | null}\n\t */\n\t#effect_pending = null;\n\n\t#effect_pending_subscriber = createSubscriber(() => {\n\t\tthis.#effect_pending = source(this.#local_pending_count);\n\n\t\tif (DEV) {\n\t\t\ttag(this.#effect_pending, '$effect.pending()');\n\t\t}\n\n\t\treturn () => {\n\t\t\tthis.#effect_pending = null;\n\t\t};\n\t});\n\n\t/**\n\t * @param {TemplateNode} node\n\t * @param {BoundaryProps} props\n\t * @param {((anchor: Node) => void)} children\n\t * @param {((error: unknown) => unknown) | undefined} [transform_error]\n\t */\n\tconstructor(node, props, children, transform_error) {\n\t\tthis.#anchor = node;\n\t\tthis.#props = props;\n\n\t\tthis.#children = (anchor) => {\n\t\t\tvar effect = /** @type {Effect} */ (active_effect);\n\n\t\t\teffect.b = this;\n\t\t\teffect.f |= BOUNDARY_EFFECT;\n\n\t\t\tchildren(anchor);\n\t\t};\n\n\t\tthis.parent = /** @type {Effect} */ (active_effect).b;\n\n\t\t// Inherit transform_error from parent boundary, or use the provided one, or default to identity\n\t\tthis.transform_error = transform_error ?? this.parent?.transform_error ?? ((e) => e);\n\n\t\tthis.#effect = block(() => {\n\t\t\tif (hydrating) {\n\t\t\t\tconst comment = /** @type {Comment} */ (this.#hydrate_open);\n\t\t\t\thydrate_next();\n\n\t\t\t\tconst server_rendered_pending = comment.data === HYDRATION_START_ELSE;\n\t\t\t\tconst server_rendered_failed = comment.data.startsWith(HYDRATION_START_FAILED);\n\n\t\t\t\tif (server_rendered_failed) {\n\t\t\t\t\t// Server rendered the failed snippet - hydrate it.\n\t\t\t\t\t// The serialized error is embedded in the comment: \n\t\t\t\t\tconst serialized_error = JSON.parse(comment.data.slice(HYDRATION_START_FAILED.length));\n\t\t\t\t\tthis.#hydrate_failed_content(serialized_error);\n\t\t\t\t} else if (server_rendered_pending) {\n\t\t\t\t\tthis.#hydrate_pending_content();\n\t\t\t\t} else {\n\t\t\t\t\tthis.#hydrate_resolved_content();\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tthis.#render();\n\t\t\t}\n\t\t}, flags);\n\n\t\tif (hydrating) {\n\t\t\tthis.#anchor = hydrate_node;\n\t\t}\n\t}\n\n\t#hydrate_resolved_content() {\n\t\ttry {\n\t\t\tthis.#main_effect = branch(() => this.#children(this.#anchor));\n\t\t} catch (error) {\n\t\t\tthis.error(error);\n\t\t}\n\t}\n\n\t/**\n\t * @param {unknown} error The deserialized error from the server's hydration comment\n\t */\n\t#hydrate_failed_content(error) {\n\t\tconst failed = this.#props.failed;\n\t\tif (!failed) return;\n\n\t\tthis.#failed_effect = branch(() => {\n\t\t\tfailed(\n\t\t\t\tthis.#anchor,\n\t\t\t\t() => error,\n\t\t\t\t() => () => {}\n\t\t\t);\n\t\t});\n\t}\n\n\t#hydrate_pending_content() {\n\t\tconst pending = this.#props.pending;\n\t\tif (!pending) return;\n\n\t\tthis.is_pending = true;\n\t\tthis.#pending_effect = branch(() => pending(this.#anchor));\n\n\t\tqueue_micro_task(() => {\n\t\t\tvar fragment = (this.#offscreen_fragment = document.createDocumentFragment());\n\t\t\tvar anchor = create_text();\n\n\t\t\tfragment.append(anchor);\n\n\t\t\tthis.#main_effect = this.#run(() => {\n\t\t\t\treturn branch(() => this.#children(anchor));\n\t\t\t});\n\n\t\t\tif (this.#pending_count === 0) {\n\t\t\t\tthis.#anchor.before(fragment);\n\t\t\t\tthis.#offscreen_fragment = null;\n\n\t\t\t\tpause_effect(/** @type {Effect} */ (this.#pending_effect), () => {\n\t\t\t\t\tthis.#pending_effect = null;\n\t\t\t\t});\n\n\t\t\t\tthis.#resolve(/** @type {Batch} */ (current_batch));\n\t\t\t}\n\t\t});\n\t}\n\n\t#render() {\n\t\ttry {\n\t\t\tthis.is_pending = this.has_pending_snippet();\n\t\t\tthis.#pending_count = 0;\n\t\t\tthis.#local_pending_count = 0;\n\n\t\t\tthis.#main_effect = branch(() => {\n\t\t\t\tthis.#children(this.#anchor);\n\t\t\t});\n\n\t\t\tif (this.#pending_count > 0) {\n\t\t\t\tvar fragment = (this.#offscreen_fragment = document.createDocumentFragment());\n\t\t\t\tmove_effect(this.#main_effect, fragment);\n\n\t\t\t\tconst pending = /** @type {(anchor: Node) => void} */ (this.#props.pending);\n\t\t\t\tthis.#pending_effect = branch(() => pending(this.#anchor));\n\t\t\t} else {\n\t\t\t\tthis.#resolve(/** @type {Batch} */ (current_batch));\n\t\t\t}\n\t\t} catch (error) {\n\t\t\tthis.error(error);\n\t\t}\n\t}\n\n\t/**\n\t * @param {Batch} batch\n\t */\n\t#resolve(batch) {\n\t\tthis.is_pending = false;\n\n\t\t// any effects that were previously deferred should be rescheduled —\n\t\t// after the next traversal (which will happen immediately, due to the\n\t\t// same update that brought us here) the effects will be flushed\n\t\tfor (const e of this.#dirty_effects) {\n\t\t\tset_signal_status(e, DIRTY);\n\t\t\tbatch.schedule(e);\n\t\t}\n\n\t\tfor (const e of this.#maybe_dirty_effects) {\n\t\t\tset_signal_status(e, MAYBE_DIRTY);\n\t\t\tbatch.schedule(e);\n\t\t}\n\n\t\tthis.#dirty_effects.clear();\n\t\tthis.#maybe_dirty_effects.clear();\n\t}\n\n\t/**\n\t * Defer an effect inside a pending boundary until the boundary resolves\n\t * @param {Effect} effect\n\t */\n\tdefer_effect(effect) {\n\t\tdefer_effect(effect, this.#dirty_effects, this.#maybe_dirty_effects);\n\t}\n\n\t/**\n\t * Returns `false` if the effect exists inside a boundary whose pending snippet is shown\n\t * @returns {boolean}\n\t */\n\tis_rendered() {\n\t\treturn !this.is_pending && (!this.parent || this.parent.is_rendered());\n\t}\n\n\thas_pending_snippet() {\n\t\treturn !!this.#props.pending;\n\t}\n\n\t/**\n\t * @template T\n\t * @param {() => T} fn\n\t */\n\t#run(fn) {\n\t\tvar previous_effect = active_effect;\n\t\tvar previous_reaction = active_reaction;\n\t\tvar previous_ctx = component_context;\n\n\t\tset_active_effect(this.#effect);\n\t\tset_active_reaction(this.#effect);\n\t\tset_component_context(this.#effect.ctx);\n\n\t\ttry {\n\t\t\tBatch.ensure();\n\t\t\treturn fn();\n\t\t} catch (e) {\n\t\t\thandle_error(e);\n\t\t\treturn null;\n\t\t} finally {\n\t\t\tset_active_effect(previous_effect);\n\t\t\tset_active_reaction(previous_reaction);\n\t\t\tset_component_context(previous_ctx);\n\t\t}\n\t}\n\n\t/**\n\t * Updates the pending count associated with the currently visible pending snippet,\n\t * if any, such that we can replace the snippet with content once work is done\n\t * @param {1 | -1} d\n\t * @param {Batch} batch\n\t */\n\t#update_pending_count(d, batch) {\n\t\tif (!this.has_pending_snippet()) {\n\t\t\tif (this.parent) {\n\t\t\t\tthis.parent.#update_pending_count(d, batch);\n\t\t\t}\n\n\t\t\t// if there's no parent, we're in a scope with no pending snippet\n\t\t\treturn;\n\t\t}\n\n\t\tthis.#pending_count += d;\n\n\t\tif (this.#pending_count === 0) {\n\t\t\tthis.#resolve(batch);\n\n\t\t\tif (this.#pending_effect) {\n\t\t\t\tpause_effect(this.#pending_effect, () => {\n\t\t\t\t\tthis.#pending_effect = null;\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tif (this.#offscreen_fragment) {\n\t\t\t\tthis.#anchor.before(this.#offscreen_fragment);\n\t\t\t\tthis.#offscreen_fragment = null;\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * Update the source that powers `$effect.pending()` inside this boundary,\n\t * and controls when the current `pending` snippet (if any) is removed.\n\t * Do not call from inside the class\n\t * @param {1 | -1} d\n\t * @param {Batch} batch\n\t */\n\tupdate_pending_count(d, batch) {\n\t\tthis.#update_pending_count(d, batch);\n\n\t\tthis.#local_pending_count += d;\n\n\t\tif (!this.#effect_pending || this.#pending_count_update_queued) return;\n\t\tthis.#pending_count_update_queued = true;\n\n\t\tqueue_micro_task(() => {\n\t\t\tthis.#pending_count_update_queued = false;\n\t\t\tif (this.#effect_pending) {\n\t\t\t\tinternal_set(this.#effect_pending, this.#local_pending_count);\n\t\t\t}\n\t\t});\n\t}\n\n\tget_effect_pending() {\n\t\tthis.#effect_pending_subscriber();\n\t\treturn get(/** @type {Source} */ (this.#effect_pending));\n\t}\n\n\t/** @param {unknown} error */\n\terror(error) {\n\t\tvar onerror = this.#props.onerror;\n\t\tlet failed = this.#props.failed;\n\n\t\t// If we have nothing to capture the error, or if we hit an error while\n\t\t// rendering the fallback, re-throw for another boundary to handle\n\t\tif (!onerror && !failed) {\n\t\t\tthrow error;\n\t\t}\n\n\t\tif (this.#main_effect) {\n\t\t\tdestroy_effect(this.#main_effect);\n\t\t\tthis.#main_effect = null;\n\t\t}\n\n\t\tif (this.#pending_effect) {\n\t\t\tdestroy_effect(this.#pending_effect);\n\t\t\tthis.#pending_effect = null;\n\t\t}\n\n\t\tif (this.#failed_effect) {\n\t\t\tdestroy_effect(this.#failed_effect);\n\t\t\tthis.#failed_effect = null;\n\t\t}\n\n\t\tif (hydrating) {\n\t\t\tset_hydrate_node(/** @type {TemplateNode} */ (this.#hydrate_open));\n\t\t\tnext();\n\t\t\tset_hydrate_node(skip_nodes());\n\t\t}\n\n\t\tvar did_reset = false;\n\t\tvar calling_on_error = false;\n\n\t\tconst reset = () => {\n\t\t\tif (did_reset) {\n\t\t\t\tw.svelte_boundary_reset_noop();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tdid_reset = true;\n\n\t\t\tif (calling_on_error) {\n\t\t\t\te.svelte_boundary_reset_onerror();\n\t\t\t}\n\n\t\t\tif (this.#failed_effect !== null) {\n\t\t\t\tpause_effect(this.#failed_effect, () => {\n\t\t\t\t\tthis.#failed_effect = null;\n\t\t\t\t});\n\t\t\t}\n\n\t\t\tthis.#run(() => {\n\t\t\t\tthis.#render();\n\t\t\t});\n\t\t};\n\n\t\t/** @param {unknown} transformed_error */\n\t\tconst handle_error_result = (transformed_error) => {\n\t\t\ttry {\n\t\t\t\tcalling_on_error = true;\n\t\t\t\tonerror?.(transformed_error, reset);\n\t\t\t\tcalling_on_error = false;\n\t\t\t} catch (error) {\n\t\t\t\tinvoke_error_boundary(error, this.#effect && this.#effect.parent);\n\t\t\t}\n\n\t\t\tif (failed) {\n\t\t\t\tthis.#failed_effect = this.#run(() => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\treturn branch(() => {\n\t\t\t\t\t\t\t// errors in `failed` snippets cause the boundary to error again\n\t\t\t\t\t\t\t// TODO Svelte 6: revisit this decision, most likely better to go to parent boundary instead\n\t\t\t\t\t\t\tvar effect = /** @type {Effect} */ (active_effect);\n\n\t\t\t\t\t\t\teffect.b = this;\n\t\t\t\t\t\t\teffect.f |= BOUNDARY_EFFECT;\n\n\t\t\t\t\t\t\tfailed(\n\t\t\t\t\t\t\t\tthis.#anchor,\n\t\t\t\t\t\t\t\t() => transformed_error,\n\t\t\t\t\t\t\t\t() => reset\n\t\t\t\t\t\t\t);\n\t\t\t\t\t\t});\n\t\t\t\t\t} catch (error) {\n\t\t\t\t\t\tinvoke_error_boundary(error, /** @type {Effect} */ (this.#effect.parent));\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t}\n\t\t};\n\n\t\tqueue_micro_task(() => {\n\t\t\t// Run the error through the API-level transformError transform (e.g. SvelteKit's handleError)\n\t\t\t/** @type {unknown} */\n\t\t\tvar result;\n\t\t\ttry {\n\t\t\t\tresult = this.transform_error(error);\n\t\t\t} catch (e) {\n\t\t\t\tinvoke_error_boundary(e, this.#effect && this.#effect.parent);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (\n\t\t\t\tresult !== null &&\n\t\t\t\ttypeof result === 'object' &&\n\t\t\t\ttypeof (/** @type {any} */ (result).then) === 'function'\n\t\t\t) {\n\t\t\t\t// transformError returned a Promise — wait for it\n\t\t\t\t/** @type {any} */ (result).then(\n\t\t\t\t\thandle_error_result,\n\t\t\t\t\t/** @param {unknown} e */\n\t\t\t\t\t(e) => invoke_error_boundary(e, this.#effect && this.#effect.parent)\n\t\t\t\t);\n\t\t\t} else {\n\t\t\t\t// Synchronous result — handle immediately\n\t\t\t\thandle_error_result(result);\n\t\t\t}\n\t\t});\n\t}\n}\n\nexport function pending() {\n\tif (active_effect === null) {\n\t\te.effect_pending_outside_reaction();\n\t}\n\n\tvar boundary = active_effect.b;\n\n\tif (boundary === null) {\n\t\treturn 0; // TODO eventually we will need this to be global\n\t}\n\n\treturn boundary.get_effect_pending();\n}\n","/** @import { Blocker, Effect, Value } from '#client' */\nimport { DESTROYED, STALE_REACTION } from '#client/constants';\nimport { DEV } from 'esm-env';\nimport {\n\tcomponent_context,\n\tdev_stack,\n\tis_runes,\n\tset_component_context,\n\tset_dev_stack\n} from '../context.js';\nimport { Boundary } from '../dom/blocks/boundary.js';\nimport { invoke_error_boundary } from '../error-handling.js';\nimport {\n\tactive_effect,\n\tactive_reaction,\n\tset_active_effect,\n\tset_active_reaction\n} from '../runtime.js';\nimport { Batch, current_batch } from './batch.js';\nimport {\n\tasync_derived,\n\tcurrent_async_effect,\n\tderived,\n\tderived_safe_equal,\n\tset_from_async_derived\n} from './deriveds.js';\nimport { aborted } from './effects.js';\n\n/**\n * @param {Blocker[]} blockers\n * @param {Array<() => any>} sync\n * @param {Array<() => Promise>} async\n * @param {(values: Value[]) => any} fn\n */\nexport function flatten(blockers, sync, async, fn) {\n\tconst d = is_runes() ? derived : derived_safe_equal;\n\n\t// Filter out already-settled blockers - no need to wait for them\n\tvar pending = blockers.filter((b) => !b.settled);\n\n\tif (async.length === 0 && pending.length === 0) {\n\t\tfn(sync.map(d));\n\t\treturn;\n\t}\n\n\tvar parent = /** @type {Effect} */ (active_effect);\n\n\tvar restore = capture();\n\tvar blocker_promise =\n\t\tpending.length === 1\n\t\t\t? pending[0].promise\n\t\t\t: pending.length > 1\n\t\t\t\t? Promise.all(pending.map((b) => b.promise))\n\t\t\t\t: null;\n\n\t/** @param {Value[]} values */\n\tfunction finish(values) {\n\t\trestore();\n\n\t\ttry {\n\t\t\tfn(values);\n\t\t} catch (error) {\n\t\t\tif ((parent.f & DESTROYED) === 0) {\n\t\t\t\tinvoke_error_boundary(error, parent);\n\t\t\t}\n\t\t}\n\n\t\tunset_context();\n\t}\n\n\t// Fast path: blockers but no async expressions\n\tif (async.length === 0) {\n\t\t/** @type {Promise} */ (blocker_promise).then(() => finish(sync.map(d)));\n\t\treturn;\n\t}\n\n\tvar decrement_pending = increment_pending();\n\n\t// Full path: has async expressions\n\tfunction run() {\n\t\tPromise.all(async.map((expression) => async_derived(expression)))\n\t\t\t.then((result) => finish([...sync.map(d), ...result]))\n\t\t\t.catch((error) => invoke_error_boundary(error, parent))\n\t\t\t.finally(() => decrement_pending());\n\t}\n\n\tif (blocker_promise) {\n\t\tblocker_promise.then(() => {\n\t\t\trestore();\n\t\t\trun();\n\t\t\tunset_context();\n\t\t});\n\t} else {\n\t\trun();\n\t}\n}\n\n/**\n * @param {Blocker[]} blockers\n * @param {(values: Value[]) => any} fn\n */\nexport function run_after_blockers(blockers, fn) {\n\tflatten(blockers, [], [], fn);\n}\n\n/**\n * Captures the current effect context so that we can restore it after\n * some asynchronous work has happened (so that e.g. `await a + b`\n * causes `b` to be registered as a dependency).\n */\nexport function capture() {\n\tvar previous_effect = /** @type {Effect} */ (active_effect);\n\tvar previous_reaction = active_reaction;\n\tvar previous_component_context = component_context;\n\tvar previous_batch = /** @type {Batch} */ (current_batch);\n\n\tif (DEV) {\n\t\tvar previous_dev_stack = dev_stack;\n\t}\n\n\treturn function restore(activate_batch = true) {\n\t\tset_active_effect(previous_effect);\n\t\tset_active_reaction(previous_reaction);\n\t\tset_component_context(previous_component_context);\n\n\t\tif (activate_batch && (previous_effect.f & DESTROYED) === 0) {\n\t\t\t// TODO we only need optional chaining here because `{#await ...}` blocks\n\t\t\t// are anomalous. Once we retire them we can get rid of it\n\t\t\tprevious_batch?.activate();\n\t\t\tprevious_batch?.apply();\n\t\t}\n\n\t\tif (DEV) {\n\t\t\tset_from_async_derived(null);\n\t\t\tset_dev_stack(previous_dev_stack);\n\t\t}\n\t};\n}\n\n/**\n * Wraps an `await` expression in such a way that the effect context that was\n * active before the expression evaluated can be reapplied afterwards —\n * `await a + b` becomes `(await $.save(a))() + b`\n * @template T\n * @param {Promise} promise\n * @returns {Promise<() => T>}\n */\nexport async function save(promise) {\n\tvar restore = capture();\n\tvar value = await promise;\n\n\treturn () => {\n\t\trestore();\n\t\treturn value;\n\t};\n}\n\n/**\n * Reset `current_async_effect` after the `promise` resolves, so\n * that we can emit `await_reactivity_loss` warnings\n * @template T\n * @param {Promise} promise\n * @returns {Promise<() => T>}\n */\nexport async function track_reactivity_loss(promise) {\n\tvar previous_async_effect = current_async_effect;\n\tvar value = await promise;\n\n\treturn () => {\n\t\tset_from_async_derived(previous_async_effect);\n\t\treturn value;\n\t};\n}\n\n/**\n * Used in `for await` loops in DEV, so\n * that we can emit `await_reactivity_loss` warnings\n * after each `async_iterator` result resolves and\n * after the `async_iterator` return resolves (if it runs)\n * @template T\n * @template TReturn\n * @param {Iterable | AsyncIterable} iterable\n * @returns {AsyncGenerator}\n */\nexport async function* for_await_track_reactivity_loss(iterable) {\n\t// This is based on the algorithms described in ECMA-262:\n\t// ForIn/OfBodyEvaluation\n\t// https://tc39.es/ecma262/multipage/ecmascript-language-statements-and-declarations.html#sec-runtime-semantics-forin-div-ofbodyevaluation-lhs-stmt-iterator-lhskind-labelset\n\t// AsyncIteratorClose\n\t// https://tc39.es/ecma262/multipage/abstract-operations.html#sec-asynciteratorclose\n\n\t/** @type {AsyncIterator} */\n\t// @ts-ignore\n\tconst iterator = iterable[Symbol.asyncIterator]?.() ?? iterable[Symbol.iterator]?.();\n\n\tif (iterator === undefined) {\n\t\tthrow new TypeError('value is not async iterable');\n\t}\n\n\t/** Whether the completion of the iterator was \"normal\", meaning it wasn't ended via `break` or a similar method */\n\tlet normal_completion = false;\n\ttry {\n\t\twhile (true) {\n\t\t\tconst { done, value } = (await track_reactivity_loss(iterator.next()))();\n\t\t\tif (done) {\n\t\t\t\tnormal_completion = true;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tyield value;\n\t\t}\n\t} finally {\n\t\t// If the iterator had a normal completion and `return` is defined on the iterator, call it and return the value\n\t\tif (normal_completion && iterator.return !== undefined) {\n\t\t\t// eslint-disable-next-line no-unsafe-finally\n\t\t\treturn /** @type {TReturn} */ ((await track_reactivity_loss(iterator.return()))().value);\n\t\t}\n\t}\n}\n\nexport function unset_context(deactivate_batch = true) {\n\tset_active_effect(null);\n\tset_active_reaction(null);\n\tset_component_context(null);\n\tif (deactivate_batch) current_batch?.deactivate();\n\n\tif (DEV) {\n\t\tset_from_async_derived(null);\n\t\tset_dev_stack(null);\n\t}\n}\n\n/**\n * @param {Array<() => void | Promise>} thunks\n */\nexport function run(thunks) {\n\tconst restore = capture();\n\n\tconst decrement_pending = increment_pending();\n\n\tvar active = /** @type {Effect} */ (active_effect);\n\n\t/** @type {null | { error: any }} */\n\tvar errored = null;\n\n\t/** @param {any} error */\n\tconst handle_error = (error) => {\n\t\terrored = { error }; // wrap in object in case a promise rejects with a falsy value\n\n\t\tif (!aborted(active)) {\n\t\t\tinvoke_error_boundary(error, active);\n\t\t}\n\t};\n\n\tvar promise = Promise.resolve(thunks[0]()).catch(handle_error);\n\n\t/** @type {Blocker} */\n\tvar blocker = { promise, settled: false };\n\tvar blockers = [blocker];\n\n\tpromise.finally(() => {\n\t\tblocker.settled = true;\n\t\tunset_context();\n\t});\n\n\tfor (const fn of thunks.slice(1)) {\n\t\tpromise = promise\n\t\t\t.then(() => {\n\t\t\t\tif (errored) {\n\t\t\t\t\tthrow errored.error;\n\t\t\t\t}\n\n\t\t\t\tif (aborted(active)) {\n\t\t\t\t\tthrow STALE_REACTION;\n\t\t\t\t}\n\n\t\t\t\trestore();\n\t\t\t\treturn fn();\n\t\t\t})\n\t\t\t.catch(handle_error);\n\n\t\tconst blocker = { promise, settled: false };\n\t\tblockers.push(blocker);\n\n\t\tpromise.finally(() => {\n\t\t\tblocker.settled = true;\n\t\t\tunset_context();\n\t\t});\n\t}\n\n\tpromise\n\t\t// wait one more tick, so that template effects are\n\t\t// guaranteed to run before `$effect(...)`\n\t\t.then(() => Promise.resolve())\n\t\t.finally(() => decrement_pending());\n\n\treturn blockers;\n}\n\n/**\n * @param {Blocker[]} blockers\n */\nexport function wait(blockers) {\n\treturn Promise.all(blockers.map((b) => b.promise));\n}\n\n/**\n * @returns {(skip?: boolean) => void}\n */\nexport function increment_pending() {\n\tvar boundary = /** @type {Boundary} */ (/** @type {Effect} */ (active_effect).b);\n\tvar batch = /** @type {Batch} */ (current_batch);\n\tvar blocking = boundary.is_rendered();\n\n\tboundary.update_pending_count(1, batch);\n\tbatch.increment(blocking);\n\n\treturn (skip = false) => {\n\t\tboundary.update_pending_count(-1, batch);\n\t\tbatch.decrement(blocking, skip);\n\t};\n}\n","/** @import { Derived, Effect, Source } from '#client' */\n/** @import { Batch } from './batch.js'; */\n/** @import { Boundary } from '../dom/blocks/boundary.js'; */\nimport { DEV } from 'esm-env';\nimport {\n\tERROR_VALUE,\n\tDERIVED,\n\tDIRTY,\n\tEFFECT_PRESERVED,\n\tSTALE_REACTION,\n\tASYNC,\n\tWAS_MARKED,\n\tDESTROYED,\n\tCLEAN,\n\tREACTION_RAN\n} from '#client/constants';\nimport {\n\tactive_reaction,\n\tactive_effect,\n\tupdate_reaction,\n\tincrement_write_version,\n\tset_active_effect,\n\tpush_reaction_value,\n\tis_destroying_effect,\n\tupdate_effect,\n\tremove_reactions\n} from '../runtime.js';\nimport { equals, safe_equals } from './equality.js';\nimport * as e from '../errors.js';\nimport * as w from '../warnings.js';\nimport {\n\tasync_effect,\n\tdestroy_effect,\n\tdestroy_effect_children,\n\teffect_tracking,\n\tteardown\n} from './effects.js';\nimport { eager_effects, internal_set, set_eager_effects, source } from './sources.js';\nimport { get_error } from '../../shared/dev.js';\nimport { async_mode_flag, tracing_mode_flag } from '../../flags/index.js';\nimport { component_context } from '../context.js';\nimport { UNINITIALIZED } from '../../../constants.js';\nimport { batch_values, current_batch } from './batch.js';\nimport { increment_pending, unset_context } from './async.js';\nimport { deferred, includes, noop } from '../../shared/utils.js';\nimport { set_signal_status, update_derived_status } from './status.js';\n\n/** @type {Effect | null} */\nexport let current_async_effect = null;\n\n/** @param {Effect | null} v */\nexport function set_from_async_derived(v) {\n\tcurrent_async_effect = v;\n}\n\nexport const recent_async_deriveds = new Set();\n\n/**\n * @template V\n * @param {() => V} fn\n * @returns {Derived}\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function derived(fn) {\n\tvar flags = DERIVED | DIRTY;\n\tvar parent_derived =\n\t\tactive_reaction !== null && (active_reaction.f & DERIVED) !== 0\n\t\t\t? /** @type {Derived} */ (active_reaction)\n\t\t\t: null;\n\n\tif (active_effect !== null) {\n\t\t// Since deriveds are evaluated lazily, any effects created inside them are\n\t\t// created too late to ensure that the parent effect is added to the tree\n\t\tactive_effect.f |= EFFECT_PRESERVED;\n\t}\n\n\t/** @type {Derived} */\n\tconst signal = {\n\t\tctx: component_context,\n\t\tdeps: null,\n\t\teffects: null,\n\t\tequals,\n\t\tf: flags,\n\t\tfn,\n\t\treactions: null,\n\t\trv: 0,\n\t\tv: /** @type {V} */ (UNINITIALIZED),\n\t\twv: 0,\n\t\tparent: parent_derived ?? active_effect,\n\t\tac: null\n\t};\n\n\tif (DEV && tracing_mode_flag) {\n\t\tsignal.created = get_error('created at');\n\t}\n\n\treturn signal;\n}\n\n/**\n * @template V\n * @param {() => V | Promise} fn\n * @param {string} [label]\n * @param {string} [location] If provided, print a warning if the value is not read immediately after update\n * @returns {Promise>}\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function async_derived(fn, label, location) {\n\tlet parent = /** @type {Effect | null} */ (active_effect);\n\n\tif (parent === null) {\n\t\te.async_derived_orphan();\n\t}\n\n\tvar promise = /** @type {Promise} */ (/** @type {unknown} */ (undefined));\n\tvar signal = source(/** @type {V} */ (UNINITIALIZED));\n\n\tif (DEV) signal.label = label;\n\n\t// only suspend in async deriveds created on initialisation\n\tvar should_suspend = !active_reaction;\n\n\t/** @type {Map>>} */\n\tvar deferreds = new Map();\n\n\tasync_effect(() => {\n\t\tif (DEV) current_async_effect = active_effect;\n\n\t\tvar effect = /** @type {Effect} */ (active_effect);\n\n\t\t/** @type {ReturnType>} */\n\t\tvar d = deferred();\n\t\tpromise = d.promise;\n\n\t\ttry {\n\t\t\t// If this code is changed at some point, make sure to still access the then property\n\t\t\t// of fn() to read any signals it might access, so that we track them as dependencies.\n\t\t\t// We call `unset_context` to undo any `save` calls that happen inside `fn()`\n\t\t\tPromise.resolve(fn()).then(d.resolve, d.reject).finally(unset_context);\n\t\t} catch (error) {\n\t\t\td.reject(error);\n\t\t\tunset_context();\n\t\t}\n\n\t\tif (DEV) current_async_effect = null;\n\n\t\tvar batch = /** @type {Batch} */ (current_batch);\n\n\t\tif (should_suspend) {\n\t\t\t// we only increment the batch's pending state for updates, not creation, otherwise\n\t\t\t// we will decrement to zero before the work that depends on this promise (e.g. a\n\t\t\t// template effect) has initialized, causing the batch to resolve prematurely\n\t\t\tif ((effect.f & REACTION_RAN) !== 0) {\n\t\t\t\tvar decrement_pending = increment_pending();\n\t\t\t}\n\n\t\t\tif (/** @type {Boundary} */ (parent.b).is_rendered()) {\n\t\t\t\tdeferreds.get(batch)?.reject(STALE_REACTION);\n\t\t\t\tdeferreds.delete(batch); // delete to ensure correct order in Map iteration below\n\t\t\t} else {\n\t\t\t\t// While the boundary is still showing pending, a new run supersedes all older in-flight runs\n\t\t\t\t// for this async expression. Cancel eagerly so resolution cannot commit stale values.\n\t\t\t\tfor (const d of deferreds.values()) {\n\t\t\t\t\td.reject(STALE_REACTION);\n\t\t\t\t}\n\t\t\t\tdeferreds.clear();\n\t\t\t}\n\n\t\t\tdeferreds.set(batch, d);\n\t\t}\n\n\t\t/**\n\t\t * @param {any} value\n\t\t * @param {unknown} error\n\t\t */\n\t\tconst handler = (value, error = undefined) => {\n\t\t\tif (DEV) current_async_effect = null;\n\n\t\t\tif (decrement_pending) {\n\t\t\t\t// don't trigger an update if we're only here because\n\t\t\t\t// the promise was superseded before it could resolve\n\t\t\t\tvar skip = error === STALE_REACTION;\n\t\t\t\tdecrement_pending(skip);\n\t\t\t}\n\n\t\t\tif (error === STALE_REACTION || (effect.f & DESTROYED) !== 0) {\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tbatch.activate();\n\n\t\t\tif (error) {\n\t\t\t\tsignal.f |= ERROR_VALUE;\n\n\t\t\t\t// @ts-expect-error the error is the wrong type, but we don't care\n\t\t\t\tinternal_set(signal, error);\n\t\t\t} else {\n\t\t\t\tif ((signal.f & ERROR_VALUE) !== 0) {\n\t\t\t\t\tsignal.f ^= ERROR_VALUE;\n\t\t\t\t}\n\n\t\t\t\tinternal_set(signal, value);\n\n\t\t\t\t// All prior async derived runs are now stale\n\t\t\t\tfor (const [b, d] of deferreds) {\n\t\t\t\t\tdeferreds.delete(b);\n\t\t\t\t\tif (b === batch) break;\n\t\t\t\t\td.reject(STALE_REACTION);\n\t\t\t\t}\n\n\t\t\t\tif (DEV && location !== undefined) {\n\t\t\t\t\trecent_async_deriveds.add(signal);\n\n\t\t\t\t\tsetTimeout(() => {\n\t\t\t\t\t\tif (recent_async_deriveds.has(signal)) {\n\t\t\t\t\t\t\tw.await_waterfall(/** @type {string} */ (signal.label), location);\n\t\t\t\t\t\t\trecent_async_deriveds.delete(signal);\n\t\t\t\t\t\t}\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tbatch.deactivate();\n\t\t};\n\n\t\td.promise.then(handler, (e) => handler(null, e || 'unknown'));\n\t});\n\n\tteardown(() => {\n\t\tfor (const d of deferreds.values()) {\n\t\t\td.reject(STALE_REACTION);\n\t\t}\n\t});\n\n\tif (DEV) {\n\t\t// add a flag that lets this be printed as a derived\n\t\t// when using `$inspect.trace()`\n\t\tsignal.f |= ASYNC;\n\t}\n\n\treturn new Promise((fulfil) => {\n\t\t/** @param {Promise} p */\n\t\tfunction next(p) {\n\t\t\tfunction go() {\n\t\t\t\tif (p === promise) {\n\t\t\t\t\tfulfil(signal);\n\t\t\t\t} else {\n\t\t\t\t\t// if the effect re-runs before the initial promise\n\t\t\t\t\t// resolves, delay resolution until we have a value\n\t\t\t\t\tnext(promise);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tp.then(go, go);\n\t\t}\n\n\t\tnext(promise);\n\t});\n}\n\n/**\n * @template V\n * @param {() => V} fn\n * @returns {Derived}\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function user_derived(fn) {\n\tconst d = derived(fn);\n\n\tif (!async_mode_flag) push_reaction_value(d);\n\n\treturn d;\n}\n\n/**\n * @template V\n * @param {() => V} fn\n * @returns {Derived}\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function derived_safe_equal(fn) {\n\tconst signal = derived(fn);\n\tsignal.equals = safe_equals;\n\treturn signal;\n}\n\n/**\n * @param {Derived} derived\n * @returns {void}\n */\nexport function destroy_derived_effects(derived) {\n\tvar effects = derived.effects;\n\n\tif (effects !== null) {\n\t\tderived.effects = null;\n\n\t\tfor (var i = 0; i < effects.length; i += 1) {\n\t\t\tdestroy_effect(/** @type {Effect} */ (effects[i]));\n\t\t}\n\t}\n}\n\n/**\n * The currently updating deriveds, used to detect infinite recursion\n * in dev mode and provide a nicer error than 'too much recursion'\n * @type {Derived[]}\n */\nlet stack = [];\n\n/**\n * @param {Derived} derived\n * @returns {Effect | null}\n */\nfunction get_derived_parent_effect(derived) {\n\tvar parent = derived.parent;\n\twhile (parent !== null) {\n\t\tif ((parent.f & DERIVED) === 0) {\n\t\t\t// The original parent effect might've been destroyed but the derived\n\t\t\t// is used elsewhere now - do not return the destroyed effect in that case\n\t\t\treturn (parent.f & DESTROYED) === 0 ? /** @type {Effect} */ (parent) : null;\n\t\t}\n\t\tparent = parent.parent;\n\t}\n\treturn null;\n}\n\n/**\n * @template T\n * @param {Derived} derived\n * @returns {T}\n */\nexport function execute_derived(derived) {\n\tvar value;\n\tvar prev_active_effect = active_effect;\n\n\tset_active_effect(get_derived_parent_effect(derived));\n\n\tif (DEV) {\n\t\tlet prev_eager_effects = eager_effects;\n\t\tset_eager_effects(new Set());\n\t\ttry {\n\t\t\tif (includes.call(stack, derived)) {\n\t\t\t\te.derived_references_self();\n\t\t\t}\n\n\t\t\tstack.push(derived);\n\n\t\t\tderived.f &= ~WAS_MARKED;\n\t\t\tdestroy_derived_effects(derived);\n\t\t\tvalue = update_reaction(derived);\n\t\t} finally {\n\t\t\tset_active_effect(prev_active_effect);\n\t\t\tset_eager_effects(prev_eager_effects);\n\t\t\tstack.pop();\n\t\t}\n\t} else {\n\t\ttry {\n\t\t\tderived.f &= ~WAS_MARKED;\n\t\t\tdestroy_derived_effects(derived);\n\t\t\tvalue = update_reaction(derived);\n\t\t} finally {\n\t\t\tset_active_effect(prev_active_effect);\n\t\t}\n\t}\n\n\treturn value;\n}\n\n/**\n * @param {Derived} derived\n * @returns {void}\n */\nexport function update_derived(derived) {\n\tvar value = execute_derived(derived);\n\n\tif (!derived.equals(value)) {\n\t\tderived.wv = increment_write_version();\n\n\t\t// in a fork, we don't update the underlying value, just `batch_values`.\n\t\t// the underlying value will be updated when the fork is committed.\n\t\t// otherwise, the next time we get here after a 'real world' state\n\t\t// change, `derived.equals` may incorrectly return `true`\n\t\tif (!current_batch?.is_fork || derived.deps === null) {\n\t\t\tderived.v = value;\n\n\t\t\t// deriveds without dependencies should never be recomputed\n\t\t\tif (derived.deps === null) {\n\t\t\t\tset_signal_status(derived, CLEAN);\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\t}\n\n\t// don't mark derived clean if we're reading it inside a\n\t// cleanup function, or it will cache a stale value\n\tif (is_destroying_effect) {\n\t\treturn;\n\t}\n\n\t// During time traveling we don't want to reset the status so that\n\t// traversal of the graph in the other batches still happens\n\tif (batch_values !== null) {\n\t\t// only cache the value if we're in a tracking context, otherwise we won't\n\t\t// clear the cache in `mark_reactions` when dependencies are updated\n\t\tif (effect_tracking() || current_batch?.is_fork) {\n\t\t\tbatch_values.set(derived, value);\n\t\t}\n\t} else {\n\t\tupdate_derived_status(derived);\n\t}\n}\n\n/**\n * @param {Derived} derived\n */\nexport function freeze_derived_effects(derived) {\n\tif (derived.effects === null) return;\n\n\tfor (const e of derived.effects) {\n\t\t// if the effect has a teardown function or abort signal, call it\n\t\tif (e.teardown || e.ac) {\n\t\t\te.teardown?.();\n\t\t\te.ac?.abort(STALE_REACTION);\n\n\t\t\t// make it a noop so it doesn't get called again if the derived\n\t\t\t// is unfrozen. we don't set it to `null`, because the existence\n\t\t\t// of a teardown function is what determines whether the\n\t\t\t// effect runs again during unfreezing\n\t\t\te.teardown = noop;\n\t\t\te.ac = null;\n\n\t\t\tremove_reactions(e, 0);\n\t\t\tdestroy_effect_children(e);\n\t\t}\n\t}\n}\n\n/**\n * @param {Derived} derived\n */\nexport function unfreeze_derived_effects(derived) {\n\tif (derived.effects === null) return;\n\n\tfor (const e of derived.effects) {\n\t\t// if the effect was previously frozen — indicated by the presence\n\t\t// of a teardown function — unfreeze it\n\t\tif (e.teardown) {\n\t\t\tupdate_effect(e);\n\t\t}\n\t}\n}\n","/** @import { Derived, Effect, Source, Value } from '#client' */\nimport { DEV } from 'esm-env';\nimport {\n\tactive_reaction,\n\tactive_effect,\n\tuntracked_writes,\n\tget,\n\tset_untracked_writes,\n\tuntrack,\n\tincrement_write_version,\n\tupdate_effect,\n\tcurrent_sources,\n\tis_dirty,\n\tuntracking,\n\tis_destroying_effect,\n\tpush_reaction_value\n} from '../runtime.js';\nimport { equals, safe_equals } from './equality.js';\nimport {\n\tCLEAN,\n\tDERIVED,\n\tDIRTY,\n\tBRANCH_EFFECT,\n\tEAGER_EFFECT,\n\tMAYBE_DIRTY,\n\tBLOCK_EFFECT,\n\tROOT_EFFECT,\n\tASYNC,\n\tWAS_MARKED,\n\tCONNECTED\n} from '#client/constants';\nimport * as e from '../errors.js';\nimport { legacy_mode_flag, tracing_mode_flag } from '../../flags/index.js';\nimport { includes } from '../../shared/utils.js';\nimport { tag_proxy } from '../dev/tracing.js';\nimport { get_error } from '../../shared/dev.js';\nimport { component_context, is_runes } from '../context.js';\nimport {\n\tBatch,\n\tbatch_values,\n\teager_block_effects,\n\tschedule_effect,\n\tlegacy_updates\n} from './batch.js';\nimport { proxy } from '../proxy.js';\nimport { execute_derived } from './deriveds.js';\nimport { set_signal_status, update_derived_status } from './status.js';\n\n/** @type {Set} */\nexport let eager_effects = new Set();\n\n/** @type {Map} */\nexport const old_values = new Map();\n\n/**\n * @param {Set} v\n */\nexport function set_eager_effects(v) {\n\teager_effects = v;\n}\n\nlet eager_effects_deferred = false;\n\nexport function set_eager_effects_deferred() {\n\teager_effects_deferred = true;\n}\n\n/**\n * @template V\n * @param {V} v\n * @param {Error | null} [stack]\n * @returns {Source}\n */\n// TODO rename this to `state` throughout the codebase\nexport function source(v, stack) {\n\t/** @type {Value} */\n\tvar signal = {\n\t\tf: 0, // TODO ideally we could skip this altogether, but it causes type errors\n\t\tv,\n\t\treactions: null,\n\t\tequals,\n\t\trv: 0,\n\t\twv: 0\n\t};\n\n\tif (DEV && tracing_mode_flag) {\n\t\tsignal.created = stack ?? get_error('created at');\n\t\tsignal.updated = null;\n\t\tsignal.set_during_effect = false;\n\t\tsignal.trace = null;\n\t}\n\n\treturn signal;\n}\n\n/**\n * @template V\n * @param {V} v\n * @param {Error | null} [stack]\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function state(v, stack) {\n\tconst s = source(v, stack);\n\n\tpush_reaction_value(s);\n\n\treturn s;\n}\n\n/**\n * @template V\n * @param {V} initial_value\n * @param {boolean} [immutable]\n * @returns {Source}\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function mutable_source(initial_value, immutable = false, trackable = true) {\n\tconst s = source(initial_value);\n\tif (!immutable) {\n\t\ts.equals = safe_equals;\n\t}\n\n\t// bind the signal to the component context, in case we need to\n\t// track updates to trigger beforeUpdate/afterUpdate callbacks\n\tif (legacy_mode_flag && trackable && component_context !== null && component_context.l !== null) {\n\t\t(component_context.l.s ??= []).push(s);\n\t}\n\n\treturn s;\n}\n\n/**\n * @template V\n * @param {Value} source\n * @param {V} value\n */\nexport function mutate(source, value) {\n\tset(\n\t\tsource,\n\t\tuntrack(() => get(source))\n\t);\n\treturn value;\n}\n\n/**\n * @template V\n * @param {Source} source\n * @param {V} value\n * @param {boolean} [should_proxy]\n * @returns {V}\n */\nexport function set(source, value, should_proxy = false) {\n\tif (\n\t\tactive_reaction !== null &&\n\t\t// since we are untracking the function inside `$inspect.with` we need to add this check\n\t\t// to ensure we error if state is set inside an inspect effect\n\t\t(!untracking || (active_reaction.f & EAGER_EFFECT) !== 0) &&\n\t\tis_runes() &&\n\t\t(active_reaction.f & (DERIVED | BLOCK_EFFECT | ASYNC | EAGER_EFFECT)) !== 0 &&\n\t\t(current_sources === null || !includes.call(current_sources, source))\n\t) {\n\t\te.state_unsafe_mutation();\n\t}\n\n\tlet new_value = should_proxy ? proxy(value) : value;\n\n\tif (DEV) {\n\t\ttag_proxy(new_value, /** @type {string} */ (source.label));\n\t}\n\n\treturn internal_set(source, new_value, legacy_updates);\n}\n\n/**\n * @template V\n * @param {Source} source\n * @param {V} value\n * @param {Effect[] | null} [updated_during_traversal]\n * @returns {V}\n */\nexport function internal_set(source, value, updated_during_traversal = null) {\n\tif (!source.equals(value)) {\n\t\tvar old_value = source.v;\n\n\t\tif (is_destroying_effect) {\n\t\t\told_values.set(source, value);\n\t\t} else {\n\t\t\told_values.set(source, old_value);\n\t\t}\n\n\t\tsource.v = value;\n\n\t\tvar batch = Batch.ensure();\n\t\tbatch.capture(source, old_value);\n\n\t\tif (DEV) {\n\t\t\tif (tracing_mode_flag || active_effect !== null) {\n\t\t\t\tsource.updated ??= new Map();\n\n\t\t\t\t// For performance reasons, when not using $inspect.trace, we only start collecting stack traces\n\t\t\t\t// after the same source has been updated more than 5 times in the same flush cycle.\n\t\t\t\tconst count = (source.updated.get('')?.count ?? 0) + 1;\n\t\t\t\tsource.updated.set('', { error: /** @type {any} */ (null), count });\n\n\t\t\t\tif (tracing_mode_flag || count > 5) {\n\t\t\t\t\tconst error = get_error('updated at');\n\n\t\t\t\t\tif (error !== null) {\n\t\t\t\t\t\tlet entry = source.updated.get(error.stack);\n\n\t\t\t\t\t\tif (!entry) {\n\t\t\t\t\t\t\tentry = { error, count: 0 };\n\t\t\t\t\t\t\tsource.updated.set(error.stack, entry);\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\tentry.count++;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (active_effect !== null) {\n\t\t\t\tsource.set_during_effect = true;\n\t\t\t}\n\t\t}\n\n\t\tif ((source.f & DERIVED) !== 0) {\n\t\t\tconst derived = /** @type {Derived} */ (source);\n\n\t\t\t// if we are assigning to a dirty derived we set it to clean/maybe dirty but we also eagerly execute it to track the dependencies\n\t\t\tif ((source.f & DIRTY) !== 0) {\n\t\t\t\texecute_derived(derived);\n\t\t\t}\n\n\t\t\tupdate_derived_status(derived);\n\t\t}\n\n\t\tsource.wv = increment_write_version();\n\n\t\t// For debugging, in case you want to know which reactions are being scheduled:\n\t\t// log_reactions(source);\n\t\tmark_reactions(source, DIRTY, updated_during_traversal);\n\n\t\t// It's possible that the current reaction might not have up-to-date dependencies\n\t\t// whilst it's actively running. So in the case of ensuring it registers the reaction\n\t\t// properly for itself, we need to ensure the current effect actually gets\n\t\t// scheduled. i.e: `$effect(() => x++)`\n\t\tif (\n\t\t\tis_runes() &&\n\t\t\tactive_effect !== null &&\n\t\t\t(active_effect.f & CLEAN) !== 0 &&\n\t\t\t(active_effect.f & (BRANCH_EFFECT | ROOT_EFFECT)) === 0\n\t\t) {\n\t\t\tif (untracked_writes === null) {\n\t\t\t\tset_untracked_writes([source]);\n\t\t\t} else {\n\t\t\t\tuntracked_writes.push(source);\n\t\t\t}\n\t\t}\n\n\t\tif (!batch.is_fork && eager_effects.size > 0 && !eager_effects_deferred) {\n\t\t\tflush_eager_effects();\n\t\t}\n\t}\n\n\treturn value;\n}\n\nexport function flush_eager_effects() {\n\teager_effects_deferred = false;\n\n\tfor (const effect of eager_effects) {\n\t\t// Mark clean inspect-effects as maybe dirty and then check their dirtiness\n\t\t// instead of just updating the effects - this way we avoid overfiring.\n\t\tif ((effect.f & CLEAN) !== 0) {\n\t\t\tset_signal_status(effect, MAYBE_DIRTY);\n\t\t}\n\n\t\tif (is_dirty(effect)) {\n\t\t\tupdate_effect(effect);\n\t\t}\n\t}\n\n\teager_effects.clear();\n}\n\n/**\n * @template {number | bigint} T\n * @param {Source} source\n * @param {1 | -1} [d]\n * @returns {T}\n */\nexport function update(source, d = 1) {\n\tvar value = get(source);\n\tvar result = d === 1 ? value++ : value--;\n\n\tset(source, value);\n\n\t// @ts-expect-error\n\treturn result;\n}\n\n/**\n * @template {number | bigint} T\n * @param {Source} source\n * @param {1 | -1} [d]\n * @returns {T}\n */\nexport function update_pre(source, d = 1) {\n\tvar value = get(source);\n\n\t// @ts-expect-error\n\t// eslint-disable-next-line no-useless-assignment -- `++`/`--` used for return value, not side effect on `value`\n\treturn set(source, d === 1 ? ++value : --value);\n}\n\n/**\n * Silently (without using `get`) increment a source\n * @param {Source} source\n */\nexport function increment(source) {\n\tset(source, source.v + 1);\n}\n\n/**\n * @param {Value} signal\n * @param {number} status should be DIRTY or MAYBE_DIRTY\n * @param {Effect[] | null} updated_during_traversal\n * @returns {void}\n */\nfunction mark_reactions(signal, status, updated_during_traversal) {\n\tvar reactions = signal.reactions;\n\tif (reactions === null) return;\n\n\tvar runes = is_runes();\n\tvar length = reactions.length;\n\n\tfor (var i = 0; i < length; i++) {\n\t\tvar reaction = reactions[i];\n\t\tvar flags = reaction.f;\n\n\t\t// In legacy mode, skip the current effect to prevent infinite loops\n\t\tif (!runes && reaction === active_effect) continue;\n\n\t\t// Inspect effects need to run immediately, so that the stack trace makes sense\n\t\tif (DEV && (flags & EAGER_EFFECT) !== 0) {\n\t\t\teager_effects.add(reaction);\n\t\t\tcontinue;\n\t\t}\n\n\t\tvar not_dirty = (flags & DIRTY) === 0;\n\n\t\t// don't set a DIRTY reaction to MAYBE_DIRTY\n\t\tif (not_dirty) {\n\t\t\tset_signal_status(reaction, status);\n\t\t}\n\n\t\tif ((flags & DERIVED) !== 0) {\n\t\t\tvar derived = /** @type {Derived} */ (reaction);\n\n\t\t\tbatch_values?.delete(derived);\n\n\t\t\tif ((flags & WAS_MARKED) === 0) {\n\t\t\t\t// Only connected deriveds can be reliably unmarked right away\n\t\t\t\tif (flags & CONNECTED) {\n\t\t\t\t\treaction.f |= WAS_MARKED;\n\t\t\t\t}\n\n\t\t\t\tmark_reactions(derived, MAYBE_DIRTY, updated_during_traversal);\n\t\t\t}\n\t\t} else if (not_dirty) {\n\t\t\tvar effect = /** @type {Effect} */ (reaction);\n\n\t\t\tif ((flags & BLOCK_EFFECT) !== 0 && eager_block_effects !== null) {\n\t\t\t\teager_block_effects.add(effect);\n\t\t\t}\n\n\t\t\tif (updated_during_traversal !== null) {\n\t\t\t\tupdated_during_traversal.push(effect);\n\t\t\t} else {\n\t\t\t\tschedule_effect(effect);\n\t\t\t}\n\t\t}\n\t}\n}\n","/** @import { Source } from '#client' */\nimport { DEV } from 'esm-env';\nimport {\n\tget,\n\tactive_effect,\n\tupdate_version,\n\tactive_reaction,\n\tset_update_version,\n\tset_active_reaction\n} from './runtime.js';\nimport {\n\tarray_prototype,\n\tget_descriptor,\n\tget_prototype_of,\n\tis_array,\n\tobject_prototype\n} from '../shared/utils.js';\nimport {\n\tstate as source,\n\tset,\n\tincrement,\n\tflush_eager_effects,\n\tset_eager_effects_deferred\n} from './reactivity/sources.js';\nimport { PROXY_PATH_SYMBOL, STATE_SYMBOL } from '#client/constants';\nimport { UNINITIALIZED } from '../../constants.js';\nimport * as e from './errors.js';\nimport { tag } from './dev/tracing.js';\nimport { get_error } from '../shared/dev.js';\nimport { tracing_mode_flag } from '../flags/index.js';\n\n// TODO move all regexes into shared module?\nconst regex_is_valid_identifier = /^[a-zA-Z_$][a-zA-Z_$0-9]*$/;\n\n/**\n * @template T\n * @param {T} value\n * @returns {T}\n */\nexport function proxy(value) {\n\t// if non-proxyable, or is already a proxy, return `value`\n\tif (typeof value !== 'object' || value === null || STATE_SYMBOL in value) {\n\t\treturn value;\n\t}\n\n\tconst prototype = get_prototype_of(value);\n\n\tif (prototype !== object_prototype && prototype !== array_prototype) {\n\t\treturn value;\n\t}\n\n\t/** @type {Map>} */\n\tvar sources = new Map();\n\tvar is_proxied_array = is_array(value);\n\tvar version = source(0);\n\n\tvar stack = DEV && tracing_mode_flag ? get_error('created at') : null;\n\tvar parent_version = update_version;\n\n\t/**\n\t * Executes the proxy in the context of the reaction it was originally created in, if any\n\t * @template T\n\t * @param {() => T} fn\n\t */\n\tvar with_parent = (fn) => {\n\t\tif (update_version === parent_version) {\n\t\t\treturn fn();\n\t\t}\n\n\t\t// child source is being created after the initial proxy —\n\t\t// prevent it from being associated with the current reaction\n\t\tvar reaction = active_reaction;\n\t\tvar version = update_version;\n\n\t\tset_active_reaction(null);\n\t\tset_update_version(parent_version);\n\n\t\tvar result = fn();\n\n\t\tset_active_reaction(reaction);\n\t\tset_update_version(version);\n\n\t\treturn result;\n\t};\n\n\tif (is_proxied_array) {\n\t\t// We need to create the length source eagerly to ensure that\n\t\t// mutations to the array are properly synced with our proxy\n\t\tsources.set('length', source(/** @type {any[]} */ (value).length, stack));\n\t\tif (DEV) {\n\t\t\tvalue = /** @type {any} */ (inspectable_array(/** @type {any[]} */ (value)));\n\t\t}\n\t}\n\n\t/** Used in dev for $inspect.trace() */\n\tvar path = '';\n\tlet updating = false;\n\t/** @param {string} new_path */\n\tfunction update_path(new_path) {\n\t\tif (updating) return;\n\t\tupdating = true;\n\t\tpath = new_path;\n\n\t\ttag(version, `${path} version`);\n\n\t\t// rename all child sources and child proxies\n\t\tfor (const [prop, source] of sources) {\n\t\t\ttag(source, get_label(path, prop));\n\t\t}\n\t\tupdating = false;\n\t}\n\n\treturn new Proxy(/** @type {any} */ (value), {\n\t\tdefineProperty(_, prop, descriptor) {\n\t\t\tif (\n\t\t\t\t!('value' in descriptor) ||\n\t\t\t\tdescriptor.configurable === false ||\n\t\t\t\tdescriptor.enumerable === false ||\n\t\t\t\tdescriptor.writable === false\n\t\t\t) {\n\t\t\t\t// we disallow non-basic descriptors, because unless they are applied to the\n\t\t\t\t// target object — which we avoid, so that state can be forked — we will run\n\t\t\t\t// afoul of the various invariants\n\t\t\t\t// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/Proxy/getOwnPropertyDescriptor#invariants\n\t\t\t\te.state_descriptors_fixed();\n\t\t\t}\n\t\t\tvar s = sources.get(prop);\n\t\t\tif (s === undefined) {\n\t\t\t\twith_parent(() => {\n\t\t\t\t\tvar s = source(descriptor.value, stack);\n\t\t\t\t\tsources.set(prop, s);\n\t\t\t\t\tif (DEV && typeof prop === 'string') {\n\t\t\t\t\t\ttag(s, get_label(path, prop));\n\t\t\t\t\t}\n\t\t\t\t\treturn s;\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tset(s, descriptor.value, true);\n\t\t\t}\n\n\t\t\treturn true;\n\t\t},\n\n\t\tdeleteProperty(target, prop) {\n\t\t\tvar s = sources.get(prop);\n\n\t\t\tif (s === undefined) {\n\t\t\t\tif (prop in target) {\n\t\t\t\t\tconst s = with_parent(() => source(UNINITIALIZED, stack));\n\t\t\t\t\tsources.set(prop, s);\n\t\t\t\t\tincrement(version);\n\n\t\t\t\t\tif (DEV) {\n\t\t\t\t\t\ttag(s, get_label(path, prop));\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tset(s, UNINITIALIZED);\n\t\t\t\tincrement(version);\n\t\t\t}\n\n\t\t\treturn true;\n\t\t},\n\n\t\tget(target, prop, receiver) {\n\t\t\tif (prop === STATE_SYMBOL) {\n\t\t\t\treturn value;\n\t\t\t}\n\n\t\t\tif (DEV && prop === PROXY_PATH_SYMBOL) {\n\t\t\t\treturn update_path;\n\t\t\t}\n\n\t\t\tvar s = sources.get(prop);\n\t\t\tvar exists = prop in target;\n\n\t\t\t// create a source, but only if it's an own property and not a prototype property\n\t\t\tif (s === undefined && (!exists || get_descriptor(target, prop)?.writable)) {\n\t\t\t\ts = with_parent(() => {\n\t\t\t\t\tvar p = proxy(exists ? target[prop] : UNINITIALIZED);\n\t\t\t\t\tvar s = source(p, stack);\n\n\t\t\t\t\tif (DEV) {\n\t\t\t\t\t\ttag(s, get_label(path, prop));\n\t\t\t\t\t}\n\n\t\t\t\t\treturn s;\n\t\t\t\t});\n\n\t\t\t\tsources.set(prop, s);\n\t\t\t}\n\n\t\t\tif (s !== undefined) {\n\t\t\t\tvar v = get(s);\n\t\t\t\treturn v === UNINITIALIZED ? undefined : v;\n\t\t\t}\n\n\t\t\treturn Reflect.get(target, prop, receiver);\n\t\t},\n\n\t\tgetOwnPropertyDescriptor(target, prop) {\n\t\t\tvar descriptor = Reflect.getOwnPropertyDescriptor(target, prop);\n\n\t\t\tif (descriptor && 'value' in descriptor) {\n\t\t\t\tvar s = sources.get(prop);\n\t\t\t\tif (s) descriptor.value = get(s);\n\t\t\t} else if (descriptor === undefined) {\n\t\t\t\tvar source = sources.get(prop);\n\t\t\t\tvar value = source?.v;\n\n\t\t\t\tif (source !== undefined && value !== UNINITIALIZED) {\n\t\t\t\t\treturn {\n\t\t\t\t\t\tenumerable: true,\n\t\t\t\t\t\tconfigurable: true,\n\t\t\t\t\t\tvalue,\n\t\t\t\t\t\twritable: true\n\t\t\t\t\t};\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn descriptor;\n\t\t},\n\n\t\thas(target, prop) {\n\t\t\tif (prop === STATE_SYMBOL) {\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tvar s = sources.get(prop);\n\t\t\tvar has = (s !== undefined && s.v !== UNINITIALIZED) || Reflect.has(target, prop);\n\n\t\t\tif (\n\t\t\t\ts !== undefined ||\n\t\t\t\t(active_effect !== null && (!has || get_descriptor(target, prop)?.writable))\n\t\t\t) {\n\t\t\t\tif (s === undefined) {\n\t\t\t\t\ts = with_parent(() => {\n\t\t\t\t\t\tvar p = has ? proxy(target[prop]) : UNINITIALIZED;\n\t\t\t\t\t\tvar s = source(p, stack);\n\n\t\t\t\t\t\tif (DEV) {\n\t\t\t\t\t\t\ttag(s, get_label(path, prop));\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\treturn s;\n\t\t\t\t\t});\n\n\t\t\t\t\tsources.set(prop, s);\n\t\t\t\t}\n\n\t\t\t\tvar value = get(s);\n\t\t\t\tif (value === UNINITIALIZED) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn has;\n\t\t},\n\n\t\tset(target, prop, value, receiver) {\n\t\t\tvar s = sources.get(prop);\n\t\t\tvar has = prop in target;\n\n\t\t\t// variable.length = value -> clear all signals with index >= value\n\t\t\tif (is_proxied_array && prop === 'length') {\n\t\t\t\tfor (var i = value; i < /** @type {Source} */ (s).v; i += 1) {\n\t\t\t\t\tvar other_s = sources.get(i + '');\n\t\t\t\t\tif (other_s !== undefined) {\n\t\t\t\t\t\tset(other_s, UNINITIALIZED);\n\t\t\t\t\t} else if (i in target) {\n\t\t\t\t\t\t// If the item exists in the original, we need to create an uninitialized source,\n\t\t\t\t\t\t// else a later read of the property would result in a source being created with\n\t\t\t\t\t\t// the value of the original item at that index.\n\t\t\t\t\t\tother_s = with_parent(() => source(UNINITIALIZED, stack));\n\t\t\t\t\t\tsources.set(i + '', other_s);\n\n\t\t\t\t\t\tif (DEV) {\n\t\t\t\t\t\t\ttag(other_s, get_label(path, i));\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// If we haven't yet created a source for this property, we need to ensure\n\t\t\t// we do so otherwise if we read it later, then the write won't be tracked and\n\t\t\t// the heuristics of effects will be different vs if we had read the proxied\n\t\t\t// object property before writing to that property.\n\t\t\tif (s === undefined) {\n\t\t\t\tif (!has || get_descriptor(target, prop)?.writable) {\n\t\t\t\t\ts = with_parent(() => source(undefined, stack));\n\n\t\t\t\t\tif (DEV) {\n\t\t\t\t\t\ttag(s, get_label(path, prop));\n\t\t\t\t\t}\n\t\t\t\t\tset(s, proxy(value));\n\n\t\t\t\t\tsources.set(prop, s);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\thas = s.v !== UNINITIALIZED;\n\n\t\t\t\tvar p = with_parent(() => proxy(value));\n\t\t\t\tset(s, p);\n\t\t\t}\n\n\t\t\tvar descriptor = Reflect.getOwnPropertyDescriptor(target, prop);\n\n\t\t\t// Set the new value before updating any signals so that any listeners get the new value\n\t\t\tif (descriptor?.set) {\n\t\t\t\tdescriptor.set.call(receiver, value);\n\t\t\t}\n\n\t\t\tif (!has) {\n\t\t\t\t// If we have mutated an array directly, we might need to\n\t\t\t\t// signal that length has also changed. Do it before updating metadata\n\t\t\t\t// to ensure that iterating over the array as a result of a metadata update\n\t\t\t\t// will not cause the length to be out of sync.\n\t\t\t\tif (is_proxied_array && typeof prop === 'string') {\n\t\t\t\t\tvar ls = /** @type {Source} */ (sources.get('length'));\n\t\t\t\t\tvar n = Number(prop);\n\n\t\t\t\t\tif (Number.isInteger(n) && n >= ls.v) {\n\t\t\t\t\t\tset(ls, n + 1);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tincrement(version);\n\t\t\t}\n\n\t\t\treturn true;\n\t\t},\n\n\t\townKeys(target) {\n\t\t\tget(version);\n\n\t\t\tvar own_keys = Reflect.ownKeys(target).filter((key) => {\n\t\t\t\tvar source = sources.get(key);\n\t\t\t\treturn source === undefined || source.v !== UNINITIALIZED;\n\t\t\t});\n\n\t\t\tfor (var [key, source] of sources) {\n\t\t\t\tif (source.v !== UNINITIALIZED && !(key in target)) {\n\t\t\t\t\town_keys.push(key);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn own_keys;\n\t\t},\n\n\t\tsetPrototypeOf() {\n\t\t\te.state_prototype_fixed();\n\t\t}\n\t});\n}\n\n/**\n * @param {string} path\n * @param {string | symbol} prop\n */\nfunction get_label(path, prop) {\n\tif (typeof prop === 'symbol') return `${path}[Symbol(${prop.description ?? ''})]`;\n\tif (regex_is_valid_identifier.test(prop)) return `${path}.${prop}`;\n\treturn /^\\d+$/.test(prop) ? `${path}[${prop}]` : `${path}['${prop}']`;\n}\n\n/**\n * @param {any} value\n */\nexport function get_proxied_value(value) {\n\ttry {\n\t\tif (value !== null && typeof value === 'object' && STATE_SYMBOL in value) {\n\t\t\treturn value[STATE_SYMBOL];\n\t\t}\n\t} catch {\n\t\t// the above if check can throw an error if the value in question\n\t\t// is the contentWindow of an iframe on another domain, in which\n\t\t// case we want to just return the value (because it's definitely\n\t\t// not a proxied value) so we don't break any JavaScript interacting\n\t\t// with that iframe (such as various payment companies client side\n\t\t// JavaScript libraries interacting with their iframes on the same\n\t\t// domain)\n\t}\n\n\treturn value;\n}\n\n/**\n * @param {any} a\n * @param {any} b\n */\nexport function is(a, b) {\n\treturn Object.is(get_proxied_value(a), get_proxied_value(b));\n}\n\nconst ARRAY_MUTATING_METHODS = new Set([\n\t'copyWithin',\n\t'fill',\n\t'pop',\n\t'push',\n\t'reverse',\n\t'shift',\n\t'sort',\n\t'splice',\n\t'unshift'\n]);\n\n/**\n * Wrap array mutating methods so $inspect is triggered only once and\n * to prevent logging an array in intermediate state (e.g. with an empty slot)\n * @param {any[]} array\n */\nfunction inspectable_array(array) {\n\treturn new Proxy(array, {\n\t\tget(target, prop, receiver) {\n\t\t\tvar value = Reflect.get(target, prop, receiver);\n\t\t\tif (!ARRAY_MUTATING_METHODS.has(/** @type {string} */ (prop))) {\n\t\t\t\treturn value;\n\t\t\t}\n\n\t\t\t/**\n\t\t\t * @this {any[]}\n\t\t\t * @param {any[]} args\n\t\t\t */\n\t\t\treturn function (...args) {\n\t\t\t\tset_eager_effects_deferred();\n\t\t\t\tvar result = value.apply(this, args);\n\t\t\t\tflush_eager_effects();\n\t\t\t\treturn result;\n\t\t\t};\n\t\t}\n\t});\n}\n","/** @import { Effect, TemplateNode } from '#client' */\nimport { hydrate_node, hydrating, set_hydrate_node } from './hydration.js';\nimport { DEV } from 'esm-env';\nimport { init_array_prototype_warnings } from '../dev/equality.js';\nimport { get_descriptor, is_extensible } from '../../shared/utils.js';\nimport { active_effect } from '../runtime.js';\nimport { async_mode_flag } from '../../flags/index.js';\nimport { TEXT_NODE, REACTION_RAN } from '#client/constants';\nimport { eager_block_effects } from '../reactivity/batch.js';\nimport { NAMESPACE_HTML } from '../../../constants.js';\n\n// export these for reference in the compiled code, making global name deduplication unnecessary\n/** @type {Window} */\nexport var $window;\n\n/** @type {Document} */\nexport var $document;\n\n/** @type {boolean} */\nexport var is_firefox;\n\n/** @type {() => Node | null} */\nvar first_child_getter;\n/** @type {() => Node | null} */\nvar next_sibling_getter;\n\n/**\n * Initialize these lazily to avoid issues when using the runtime in a server context\n * where these globals are not available while avoiding a separate server entry point\n */\nexport function init_operations() {\n\tif ($window !== undefined) {\n\t\treturn;\n\t}\n\n\t$window = window;\n\t$document = document;\n\tis_firefox = /Firefox/.test(navigator.userAgent);\n\n\tvar element_prototype = Element.prototype;\n\tvar node_prototype = Node.prototype;\n\tvar text_prototype = Text.prototype;\n\n\t// @ts-ignore\n\tfirst_child_getter = get_descriptor(node_prototype, 'firstChild').get;\n\t// @ts-ignore\n\tnext_sibling_getter = get_descriptor(node_prototype, 'nextSibling').get;\n\n\tif (is_extensible(element_prototype)) {\n\t\t// the following assignments improve perf of lookups on DOM nodes\n\t\t// @ts-expect-error\n\t\telement_prototype.__click = undefined;\n\t\t// @ts-expect-error\n\t\telement_prototype.__className = undefined;\n\t\t// @ts-expect-error\n\t\telement_prototype.__attributes = null;\n\t\t// @ts-expect-error\n\t\telement_prototype.__style = undefined;\n\t\t// @ts-expect-error\n\t\telement_prototype.__e = undefined;\n\t}\n\n\tif (is_extensible(text_prototype)) {\n\t\t// @ts-expect-error\n\t\ttext_prototype.__t = undefined;\n\t}\n\n\tif (DEV) {\n\t\t// @ts-expect-error\n\t\telement_prototype.__svelte_meta = null;\n\n\t\tinit_array_prototype_warnings();\n\t}\n}\n\n/**\n * @param {string} value\n * @returns {Text}\n */\nexport function create_text(value = '') {\n\treturn document.createTextNode(value);\n}\n\n/**\n * @template {Node} N\n * @param {N} node\n */\n/*@__NO_SIDE_EFFECTS__*/\nexport function get_first_child(node) {\n\treturn /** @type {TemplateNode | null} */ (first_child_getter.call(node));\n}\n\n/**\n * @template {Node} N\n * @param {N} node\n */\n/*@__NO_SIDE_EFFECTS__*/\nexport function get_next_sibling(node) {\n\treturn /** @type {TemplateNode | null} */ (next_sibling_getter.call(node));\n}\n\n/**\n * Don't mark this as side-effect-free, hydration needs to walk all nodes\n * @template {Node} N\n * @param {N} node\n * @param {boolean} is_text\n * @returns {TemplateNode | null}\n */\nexport function child(node, is_text) {\n\tif (!hydrating) {\n\t\treturn get_first_child(node);\n\t}\n\n\tvar child = get_first_child(hydrate_node);\n\n\t// Child can be null if we have an element with a single child, like `

                {text}

                `, where `text` is empty\n\tif (child === null) {\n\t\tchild = hydrate_node.appendChild(create_text());\n\t} else if (is_text && child.nodeType !== TEXT_NODE) {\n\t\tvar text = create_text();\n\t\tchild?.before(text);\n\t\tset_hydrate_node(text);\n\t\treturn text;\n\t}\n\n\tif (is_text) {\n\t\tmerge_text_nodes(/** @type {Text} */ (child));\n\t}\n\n\tset_hydrate_node(child);\n\treturn child;\n}\n\n/**\n * Don't mark this as side-effect-free, hydration needs to walk all nodes\n * @param {TemplateNode} node\n * @param {boolean} [is_text]\n * @returns {TemplateNode | null}\n */\nexport function first_child(node, is_text = false) {\n\tif (!hydrating) {\n\t\tvar first = get_first_child(node);\n\n\t\t// TODO prevent user comments with the empty string when preserveComments is true\n\t\tif (first instanceof Comment && first.data === '') return get_next_sibling(first);\n\n\t\treturn first;\n\t}\n\n\tif (is_text) {\n\t\t// if an {expression} is empty during SSR, there might be no\n\t\t// text node to hydrate — we must therefore create one\n\t\tif (hydrate_node?.nodeType !== TEXT_NODE) {\n\t\t\tvar text = create_text();\n\n\t\t\thydrate_node?.before(text);\n\t\t\tset_hydrate_node(text);\n\t\t\treturn text;\n\t\t}\n\n\t\tmerge_text_nodes(/** @type {Text} */ (hydrate_node));\n\t}\n\n\treturn hydrate_node;\n}\n\n/**\n * Don't mark this as side-effect-free, hydration needs to walk all nodes\n * @param {TemplateNode} node\n * @param {number} count\n * @param {boolean} is_text\n * @returns {TemplateNode | null}\n */\nexport function sibling(node, count = 1, is_text = false) {\n\tlet next_sibling = hydrating ? hydrate_node : node;\n\tvar last_sibling;\n\n\twhile (count--) {\n\t\tlast_sibling = next_sibling;\n\t\tnext_sibling = /** @type {TemplateNode} */ (get_next_sibling(next_sibling));\n\t}\n\n\tif (!hydrating) {\n\t\treturn next_sibling;\n\t}\n\n\tif (is_text) {\n\t\t// if a sibling {expression} is empty during SSR, there might be no\n\t\t// text node to hydrate — we must therefore create one\n\t\tif (next_sibling?.nodeType !== TEXT_NODE) {\n\t\t\tvar text = create_text();\n\t\t\t// If the next sibling is `null` and we're handling text then it's because\n\t\t\t// the SSR content was empty for the text, so we need to generate a new text\n\t\t\t// node and insert it after the last sibling\n\t\t\tif (next_sibling === null) {\n\t\t\t\tlast_sibling?.after(text);\n\t\t\t} else {\n\t\t\t\tnext_sibling.before(text);\n\t\t\t}\n\t\t\tset_hydrate_node(text);\n\t\t\treturn text;\n\t\t}\n\n\t\tmerge_text_nodes(/** @type {Text} */ (next_sibling));\n\t}\n\n\tset_hydrate_node(next_sibling);\n\treturn next_sibling;\n}\n\n/**\n * @template {Node} N\n * @param {N} node\n * @returns {void}\n */\nexport function clear_text_content(node) {\n\tnode.textContent = '';\n}\n\n/**\n * Returns `true` if we're updating the current block, for example `condition` in\n * an `{#if condition}` block just changed. In this case, the branch should be\n * appended (or removed) at the same time as other updates within the\n * current ``\n */\nexport function should_defer_append() {\n\tif (!async_mode_flag) return false;\n\tif (eager_block_effects !== null) return false;\n\n\tvar flags = /** @type {Effect} */ (active_effect).f;\n\treturn (flags & REACTION_RAN) !== 0;\n}\n\n/**\n * @template {keyof HTMLElementTagNameMap | string} T\n * @param {T} tag\n * @param {string} [namespace]\n * @param {string} [is]\n * @returns {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element}\n */\nexport function create_element(tag, namespace, is) {\n\tlet options = is ? { is } : undefined;\n\treturn /** @type {T extends keyof HTMLElementTagNameMap ? HTMLElementTagNameMap[T] : Element} */ (\n\t\tdocument.createElementNS(namespace ?? NAMESPACE_HTML, tag, options)\n\t);\n}\n\nexport function create_fragment() {\n\treturn document.createDocumentFragment();\n}\n\n/**\n * @param {string} data\n * @returns\n */\nexport function create_comment(data = '') {\n\treturn document.createComment(data);\n}\n\n/**\n * @param {Element} element\n * @param {string} key\n * @param {string} value\n * @returns\n */\nexport function set_attribute(element, key, value = '') {\n\tif (key.startsWith('xlink:')) {\n\t\telement.setAttributeNS('http://www.w3.org/1999/xlink', key, value);\n\t\treturn;\n\t}\n\treturn element.setAttribute(key, value);\n}\n\n/**\n * Browsers split text nodes larger than 65536 bytes when parsing.\n * For hydration to succeed, we need to stitch them back together\n * @param {Text} text\n */\nexport function merge_text_nodes(text) {\n\tif (/** @type {string} */ (text.nodeValue).length < 65536) {\n\t\treturn;\n\t}\n\n\tlet next = text.nextSibling;\n\n\twhile (next !== null && next.nodeType === TEXT_NODE) {\n\t\tnext.remove();\n\n\t\t/** @type {string} */ (text.nodeValue) += /** @type {string} */ (next.nodeValue);\n\n\t\tnext = text.nextSibling;\n\t}\n}\n","import { hydrating } from '../hydration.js';\nimport { clear_text_content, get_first_child } from '../operations.js';\nimport { queue_micro_task } from '../task.js';\n\n/**\n * @param {HTMLElement} dom\n * @param {boolean} value\n * @returns {void}\n */\nexport function autofocus(dom, value) {\n\tif (value) {\n\t\tconst body = document.body;\n\t\tdom.autofocus = true;\n\n\t\tqueue_micro_task(() => {\n\t\t\tif (document.activeElement === body) {\n\t\t\t\tdom.focus();\n\t\t\t}\n\t\t});\n\t}\n}\n\n/**\n * The child of a textarea actually corresponds to the defaultValue property, so we need\n * to remove it upon hydration to avoid a bug when someone resets the form value.\n * @param {HTMLTextAreaElement} dom\n * @returns {void}\n */\nexport function remove_textarea_child(dom) {\n\tif (hydrating && get_first_child(dom) !== null) {\n\t\tclear_text_content(dom);\n\t}\n}\n\nlet listening_to_form_reset = false;\n\nexport function add_form_reset_listener() {\n\tif (!listening_to_form_reset) {\n\t\tlistening_to_form_reset = true;\n\t\tdocument.addEventListener(\n\t\t\t'reset',\n\t\t\t(evt) => {\n\t\t\t\t// Needs to happen one tick later or else the dom properties of the form\n\t\t\t\t// elements have not updated to their reset values yet\n\t\t\t\tPromise.resolve().then(() => {\n\t\t\t\t\tif (!evt.defaultPrevented) {\n\t\t\t\t\t\tfor (const e of /**@type {HTMLFormElement} */ (evt.target).elements) {\n\t\t\t\t\t\t\t// @ts-expect-error\n\t\t\t\t\t\t\te.__on_r?.();\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t},\n\t\t\t// In the capture phase to guarantee we get noticed of it (no possibility of stopPropagation)\n\t\t\t{ capture: true }\n\t\t);\n\t}\n}\n","import { teardown } from '../../../reactivity/effects.js';\nimport {\n\tactive_effect,\n\tactive_reaction,\n\tset_active_effect,\n\tset_active_reaction\n} from '../../../runtime.js';\nimport { add_form_reset_listener } from '../misc.js';\n\n/**\n * Fires the handler once immediately (unless corresponding arg is set to `false`),\n * then listens to the given events until the render effect context is destroyed\n * @param {EventTarget} target\n * @param {Array} events\n * @param {(event?: Event) => void} handler\n * @param {any} call_handler_immediately\n */\nexport function listen(target, events, handler, call_handler_immediately = true) {\n\tif (call_handler_immediately) {\n\t\thandler();\n\t}\n\n\tfor (var name of events) {\n\t\ttarget.addEventListener(name, handler);\n\t}\n\n\tteardown(() => {\n\t\tfor (var name of events) {\n\t\t\ttarget.removeEventListener(name, handler);\n\t\t}\n\t});\n}\n\n/**\n * @template T\n * @param {() => T} fn\n */\nexport function without_reactive_context(fn) {\n\tvar previous_reaction = active_reaction;\n\tvar previous_effect = active_effect;\n\tset_active_reaction(null);\n\tset_active_effect(null);\n\ttry {\n\t\treturn fn();\n\t} finally {\n\t\tset_active_reaction(previous_reaction);\n\t\tset_active_effect(previous_effect);\n\t}\n}\n\n/**\n * Listen to the given event, and then instantiate a global form reset listener if not already done,\n * to notify all bindings when the form is reset\n * @param {HTMLElement} element\n * @param {string} event\n * @param {(is_reset?: true) => void} handler\n * @param {(is_reset?: true) => void} [on_reset]\n */\nexport function listen_to_event_and_reset_event(element, event, handler, on_reset = handler) {\n\telement.addEventListener(event, () => without_reactive_context(handler));\n\t// @ts-expect-error\n\tconst prev = element.__on_r;\n\tif (prev) {\n\t\t// special case for checkbox that can have multiple binds (group & checked)\n\t\t// @ts-expect-error\n\t\telement.__on_r = () => {\n\t\t\tprev();\n\t\t\ton_reset(true);\n\t\t};\n\t} else {\n\t\t// @ts-expect-error\n\t\telement.__on_r = () => on_reset(true);\n\t}\n\n\tadd_form_reset_listener();\n}\n","/** @import { Blocker, ComponentContext, ComponentContextLegacy, Derived, Effect, TemplateNode, TransitionManager } from '#client' */\nimport {\n\tis_dirty,\n\tactive_effect,\n\tactive_reaction,\n\tupdate_effect,\n\tget,\n\tis_destroying_effect,\n\tremove_reactions,\n\tset_active_reaction,\n\tset_is_destroying_effect,\n\tuntrack,\n\tuntracking,\n\tset_active_effect\n} from '../runtime.js';\nimport {\n\tDIRTY,\n\tBRANCH_EFFECT,\n\tRENDER_EFFECT,\n\tEFFECT,\n\tDESTROYED,\n\tINERT,\n\tREACTION_RAN,\n\tBLOCK_EFFECT,\n\tROOT_EFFECT,\n\tEFFECT_TRANSPARENT,\n\tDERIVED,\n\tCLEAN,\n\tEAGER_EFFECT,\n\tHEAD_EFFECT,\n\tMAYBE_DIRTY,\n\tEFFECT_PRESERVED,\n\tSTALE_REACTION,\n\tUSER_EFFECT,\n\tASYNC,\n\tCONNECTED,\n\tMANAGED_EFFECT,\n\tDESTROYING\n} from '#client/constants';\nimport * as e from '../errors.js';\nimport { DEV } from 'esm-env';\nimport { define_property } from '../../shared/utils.js';\nimport { get_next_sibling } from '../dom/operations.js';\nimport { component_context, dev_current_component_function, dev_stack } from '../context.js';\nimport { Batch, collected_effects } from './batch.js';\nimport { flatten, increment_pending } from './async.js';\nimport { without_reactive_context } from '../dom/elements/bindings/shared.js';\nimport { set_signal_status } from './status.js';\n\n/**\n * @param {'$effect' | '$effect.pre' | '$inspect'} rune\n */\nexport function validate_effect(rune) {\n\tif (active_effect === null) {\n\t\tif (active_reaction === null) {\n\t\t\te.effect_orphan(rune);\n\t\t}\n\n\t\te.effect_in_unowned_derived();\n\t}\n\n\tif (is_destroying_effect) {\n\t\te.effect_in_teardown(rune);\n\t}\n}\n\n/**\n * @param {Effect} effect\n * @param {Effect} parent_effect\n */\nfunction push_effect(effect, parent_effect) {\n\tvar parent_last = parent_effect.last;\n\tif (parent_last === null) {\n\t\tparent_effect.last = parent_effect.first = effect;\n\t} else {\n\t\tparent_last.next = effect;\n\t\teffect.prev = parent_last;\n\t\tparent_effect.last = effect;\n\t}\n}\n\n/**\n * @param {number} type\n * @param {null | (() => void | (() => void))} fn\n * @returns {Effect}\n */\nfunction create_effect(type, fn) {\n\tvar parent = active_effect;\n\n\tif (DEV) {\n\t\t// Ensure the parent is never an inspect effect\n\t\twhile (parent !== null && (parent.f & EAGER_EFFECT) !== 0) {\n\t\t\tparent = parent.parent;\n\t\t}\n\t}\n\n\tif (parent !== null && (parent.f & INERT) !== 0) {\n\t\ttype |= INERT;\n\t}\n\n\t/** @type {Effect} */\n\tvar effect = {\n\t\tctx: component_context,\n\t\tdeps: null,\n\t\tnodes: null,\n\t\tf: type | DIRTY | CONNECTED,\n\t\tfirst: null,\n\t\tfn,\n\t\tlast: null,\n\t\tnext: null,\n\t\tparent,\n\t\tb: parent && parent.b,\n\t\tprev: null,\n\t\tteardown: null,\n\t\twv: 0,\n\t\tac: null\n\t};\n\n\tif (DEV) {\n\t\teffect.component_function = dev_current_component_function;\n\t}\n\n\t/** @type {Effect | null} */\n\tvar e = effect;\n\n\tif ((type & EFFECT) !== 0) {\n\t\tif (collected_effects !== null) {\n\t\t\t// created during traversal — collect and run afterwards\n\t\t\tcollected_effects.push(effect);\n\t\t} else {\n\t\t\t// schedule for later\n\t\t\tBatch.ensure().schedule(effect);\n\t\t}\n\t} else if (fn !== null) {\n\t\ttry {\n\t\t\tupdate_effect(effect);\n\t\t} catch (e) {\n\t\t\tdestroy_effect(effect);\n\t\t\tthrow e;\n\t\t}\n\n\t\t// if an effect doesn't need to be kept in the tree (because it\n\t\t// won't re-run, has no DOM, and has no teardown etc)\n\t\t// then we skip it and go to its child (if any)\n\t\tif (\n\t\t\te.deps === null &&\n\t\t\te.teardown === null &&\n\t\t\te.nodes === null &&\n\t\t\te.first === e.last && // either `null`, or a singular child\n\t\t\t(e.f & EFFECT_PRESERVED) === 0\n\t\t) {\n\t\t\te = e.first;\n\t\t\tif ((type & BLOCK_EFFECT) !== 0 && (type & EFFECT_TRANSPARENT) !== 0 && e !== null) {\n\t\t\t\te.f |= EFFECT_TRANSPARENT;\n\t\t\t}\n\t\t}\n\t}\n\n\tif (e !== null) {\n\t\te.parent = parent;\n\n\t\tif (parent !== null) {\n\t\t\tpush_effect(e, parent);\n\t\t}\n\n\t\t// if we're in a derived, add the effect there too\n\t\tif (\n\t\t\tactive_reaction !== null &&\n\t\t\t(active_reaction.f & DERIVED) !== 0 &&\n\t\t\t(type & ROOT_EFFECT) === 0\n\t\t) {\n\t\t\tvar derived = /** @type {Derived} */ (active_reaction);\n\t\t\t(derived.effects ??= []).push(e);\n\t\t}\n\t}\n\n\treturn effect;\n}\n\n/**\n * Internal representation of `$effect.tracking()`\n * @returns {boolean}\n */\nexport function effect_tracking() {\n\treturn active_reaction !== null && !untracking;\n}\n\n/**\n * @param {() => void} fn\n */\nexport function teardown(fn) {\n\tconst effect = create_effect(RENDER_EFFECT, null);\n\tset_signal_status(effect, CLEAN);\n\teffect.teardown = fn;\n\treturn effect;\n}\n\n/**\n * Internal representation of `$effect(...)`\n * @param {() => void | (() => void)} fn\n */\nexport function user_effect(fn) {\n\tvalidate_effect('$effect');\n\n\tif (DEV) {\n\t\tdefine_property(fn, 'name', {\n\t\t\tvalue: '$effect'\n\t\t});\n\t}\n\n\t// Non-nested `$effect(...)` in a component should be deferred\n\t// until the component is mounted\n\tvar flags = /** @type {Effect} */ (active_effect).f;\n\tvar defer = !active_reaction && (flags & BRANCH_EFFECT) !== 0 && (flags & REACTION_RAN) === 0;\n\n\tif (defer) {\n\t\t// Top-level `$effect(...)` in an unmounted component — defer until mount\n\t\tvar context = /** @type {ComponentContext} */ (component_context);\n\t\t(context.e ??= []).push(fn);\n\t} else {\n\t\t// Everything else — create immediately\n\t\treturn create_user_effect(fn);\n\t}\n}\n\n/**\n * @param {() => void | (() => void)} fn\n */\nexport function create_user_effect(fn) {\n\treturn create_effect(EFFECT | USER_EFFECT, fn);\n}\n\n/**\n * Internal representation of `$effect.pre(...)`\n * @param {() => void | (() => void)} fn\n * @returns {Effect}\n */\nexport function user_pre_effect(fn) {\n\tvalidate_effect('$effect.pre');\n\tif (DEV) {\n\t\tdefine_property(fn, 'name', {\n\t\t\tvalue: '$effect.pre'\n\t\t});\n\t}\n\treturn create_effect(RENDER_EFFECT | USER_EFFECT, fn);\n}\n\n/** @param {() => void | (() => void)} fn */\nexport function eager_effect(fn) {\n\treturn create_effect(EAGER_EFFECT, fn);\n}\n\n/**\n * Internal representation of `$effect.root(...)`\n * @param {() => void | (() => void)} fn\n * @returns {() => void}\n */\nexport function effect_root(fn) {\n\tBatch.ensure();\n\tconst effect = create_effect(ROOT_EFFECT | EFFECT_PRESERVED, fn);\n\n\treturn () => {\n\t\tdestroy_effect(effect);\n\t};\n}\n\n/**\n * An effect root whose children can transition out\n * @param {() => void} fn\n * @returns {(options?: { outro?: boolean }) => Promise}\n */\nexport function component_root(fn) {\n\tBatch.ensure();\n\tconst effect = create_effect(ROOT_EFFECT | EFFECT_PRESERVED, fn);\n\n\treturn (options = {}) => {\n\t\treturn new Promise((fulfil) => {\n\t\t\tif (options.outro) {\n\t\t\t\tpause_effect(effect, () => {\n\t\t\t\t\tdestroy_effect(effect);\n\t\t\t\t\tfulfil(undefined);\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tdestroy_effect(effect);\n\t\t\t\tfulfil(undefined);\n\t\t\t}\n\t\t});\n\t};\n}\n\n/**\n * @param {() => void | (() => void)} fn\n * @returns {Effect}\n */\nexport function effect(fn) {\n\treturn create_effect(EFFECT, fn);\n}\n\n/**\n * Internal representation of `$: ..`\n * @param {() => any} deps\n * @param {() => void | (() => void)} fn\n */\nexport function legacy_pre_effect(deps, fn) {\n\tvar context = /** @type {ComponentContextLegacy} */ (component_context);\n\n\t/** @type {{ effect: null | Effect, ran: boolean, deps: () => any }} */\n\tvar token = { effect: null, ran: false, deps };\n\n\tcontext.l.$.push(token);\n\n\ttoken.effect = render_effect(() => {\n\t\tdeps();\n\n\t\t// If this legacy pre effect has already run before the end of the reset, then\n\t\t// bail out to emulate the same behavior.\n\t\tif (token.ran) return;\n\n\t\ttoken.ran = true;\n\n\t\tvar effect = /** @type {Effect} */ (active_effect);\n\n\t\t// here, we lie: by setting `active_effect` to be the parent branch, any writes\n\t\t// that happen inside `fn` will _not_ cause an unnecessary reschedule, because\n\t\t// the affected effects will be children of `active_effect`. this is safe\n\t\t// because these effects are known to run in the correct order\n\t\ttry {\n\t\t\tset_active_effect(effect.parent);\n\t\t\tuntrack(fn);\n\t\t} finally {\n\t\t\tset_active_effect(effect);\n\t\t}\n\t});\n}\n\nexport function legacy_pre_effect_reset() {\n\tvar context = /** @type {ComponentContextLegacy} */ (component_context);\n\n\trender_effect(() => {\n\t\t// Run dirty `$:` statements\n\t\tfor (var token of context.l.$) {\n\t\t\ttoken.deps();\n\n\t\t\tvar effect = token.effect;\n\n\t\t\t// If the effect is CLEAN, then make it MAYBE_DIRTY. This ensures we traverse through\n\t\t\t// the effects dependencies and correctly ensure each dependency is up-to-date.\n\t\t\tif ((effect.f & CLEAN) !== 0 && effect.deps !== null) {\n\t\t\t\tset_signal_status(effect, MAYBE_DIRTY);\n\t\t\t}\n\n\t\t\tif (is_dirty(effect)) {\n\t\t\t\tupdate_effect(effect);\n\t\t\t}\n\n\t\t\ttoken.ran = false;\n\t\t}\n\t});\n}\n\n/**\n * @param {() => void | (() => void)} fn\n * @returns {Effect}\n */\nexport function async_effect(fn) {\n\treturn create_effect(ASYNC | EFFECT_PRESERVED, fn);\n}\n\n/**\n * @param {() => void | (() => void)} fn\n * @returns {Effect}\n */\nexport function render_effect(fn, flags = 0) {\n\treturn create_effect(RENDER_EFFECT | flags, fn);\n}\n\n/**\n * @param {(...expressions: any) => void | (() => void)} fn\n * @param {Array<() => any>} sync\n * @param {Array<() => Promise>} async\n * @param {Blocker[]} blockers\n */\nexport function template_effect(fn, sync = [], async = [], blockers = []) {\n\tflatten(blockers, sync, async, (values) => {\n\t\tcreate_effect(RENDER_EFFECT, () => fn(...values.map(get)));\n\t});\n}\n\n/**\n * Like `template_effect`, but with an effect which is deferred until the batch commits\n * @param {(...expressions: any) => void | (() => void)} fn\n * @param {Array<() => any>} sync\n * @param {Array<() => Promise>} async\n * @param {Blocker[]} blockers\n */\nexport function deferred_template_effect(fn, sync = [], async = [], blockers = []) {\n\tif (async.length > 0 || blockers.length > 0) {\n\t\tvar decrement_pending = increment_pending();\n\t}\n\n\tflatten(blockers, sync, async, (values) => {\n\t\tcreate_effect(EFFECT, () => fn(...values.map(get)));\n\n\t\tif (decrement_pending) {\n\t\t\tdecrement_pending();\n\t\t}\n\t});\n}\n\n/**\n * @param {(() => void)} fn\n * @param {number} flags\n */\nexport function block(fn, flags = 0) {\n\tvar effect = create_effect(BLOCK_EFFECT | flags, fn);\n\tif (DEV) {\n\t\teffect.dev_stack = dev_stack;\n\t}\n\treturn effect;\n}\n\n/**\n * @param {(() => void)} fn\n * @param {number} flags\n */\nexport function managed(fn, flags = 0) {\n\tvar effect = create_effect(MANAGED_EFFECT | flags, fn);\n\tif (DEV) {\n\t\teffect.dev_stack = dev_stack;\n\t}\n\treturn effect;\n}\n\n/**\n * @param {(() => void)} fn\n */\nexport function branch(fn) {\n\treturn create_effect(BRANCH_EFFECT | EFFECT_PRESERVED, fn);\n}\n\n/**\n * @param {Effect} effect\n */\nexport function execute_effect_teardown(effect) {\n\tvar teardown = effect.teardown;\n\tif (teardown !== null) {\n\t\tconst previously_destroying_effect = is_destroying_effect;\n\t\tconst previous_reaction = active_reaction;\n\t\tset_is_destroying_effect(true);\n\t\tset_active_reaction(null);\n\t\ttry {\n\t\t\tteardown.call(null);\n\t\t} finally {\n\t\t\tset_is_destroying_effect(previously_destroying_effect);\n\t\t\tset_active_reaction(previous_reaction);\n\t\t}\n\t}\n}\n\n/**\n * @param {Effect} signal\n * @param {boolean} remove_dom\n * @returns {void}\n */\nexport function destroy_effect_children(signal, remove_dom = false) {\n\tvar effect = signal.first;\n\tsignal.first = signal.last = null;\n\n\twhile (effect !== null) {\n\t\tconst controller = effect.ac;\n\n\t\tif (controller !== null) {\n\t\t\twithout_reactive_context(() => {\n\t\t\t\tcontroller.abort(STALE_REACTION);\n\t\t\t});\n\t\t}\n\n\t\tvar next = effect.next;\n\n\t\tif ((effect.f & ROOT_EFFECT) !== 0) {\n\t\t\t// this is now an independent root\n\t\t\teffect.parent = null;\n\t\t} else {\n\t\t\tdestroy_effect(effect, remove_dom);\n\t\t}\n\n\t\teffect = next;\n\t}\n}\n\n/**\n * @param {Effect} signal\n * @returns {void}\n */\nexport function destroy_block_effect_children(signal) {\n\tvar effect = signal.first;\n\n\twhile (effect !== null) {\n\t\tvar next = effect.next;\n\t\tif ((effect.f & BRANCH_EFFECT) === 0) {\n\t\t\tdestroy_effect(effect);\n\t\t}\n\t\teffect = next;\n\t}\n}\n\n/**\n * @param {Effect} effect\n * @param {boolean} [remove_dom]\n * @returns {void}\n */\nexport function destroy_effect(effect, remove_dom = true) {\n\tvar removed = false;\n\n\tif (\n\t\t(remove_dom || (effect.f & HEAD_EFFECT) !== 0) &&\n\t\teffect.nodes !== null &&\n\t\teffect.nodes.end !== null\n\t) {\n\t\tremove_effect_dom(effect.nodes.start, /** @type {TemplateNode} */ (effect.nodes.end));\n\t\tremoved = true;\n\t}\n\n\tset_signal_status(effect, DESTROYING);\n\tdestroy_effect_children(effect, remove_dom && !removed);\n\tremove_reactions(effect, 0);\n\n\tvar transitions = effect.nodes && effect.nodes.t;\n\n\tif (transitions !== null) {\n\t\tfor (const transition of transitions) {\n\t\t\ttransition.stop();\n\t\t}\n\t}\n\n\texecute_effect_teardown(effect);\n\n\teffect.f ^= DESTROYING;\n\teffect.f |= DESTROYED;\n\n\tvar parent = effect.parent;\n\n\t// If the parent doesn't have any children, then skip this work altogether\n\tif (parent !== null && parent.first !== null) {\n\t\tunlink_effect(effect);\n\t}\n\n\tif (DEV) {\n\t\teffect.component_function = null;\n\t}\n\n\t// `first` and `child` are nulled out in destroy_effect_children\n\t// we don't null out `parent` so that error propagation can work correctly\n\teffect.next =\n\t\teffect.prev =\n\t\teffect.teardown =\n\t\teffect.ctx =\n\t\teffect.deps =\n\t\teffect.fn =\n\t\teffect.nodes =\n\t\teffect.ac =\n\t\t\tnull;\n}\n\n/**\n *\n * @param {TemplateNode | null} node\n * @param {TemplateNode} end\n */\nexport function remove_effect_dom(node, end) {\n\twhile (node !== null) {\n\t\t/** @type {TemplateNode | null} */\n\t\tvar next = node === end ? null : get_next_sibling(node);\n\n\t\tnode.remove();\n\t\tnode = next;\n\t}\n}\n\n/**\n * Detach an effect from the effect tree, freeing up memory and\n * reducing the amount of work that happens on subsequent traversals\n * @param {Effect} effect\n */\nexport function unlink_effect(effect) {\n\tvar parent = effect.parent;\n\tvar prev = effect.prev;\n\tvar next = effect.next;\n\n\tif (prev !== null) prev.next = next;\n\tif (next !== null) next.prev = prev;\n\n\tif (parent !== null) {\n\t\tif (parent.first === effect) parent.first = next;\n\t\tif (parent.last === effect) parent.last = prev;\n\t}\n}\n\n/**\n * When a block effect is removed, we don't immediately destroy it or yank it\n * out of the DOM, because it might have transitions. Instead, we 'pause' it.\n * It stays around (in memory, and in the DOM) until outro transitions have\n * completed, and if the state change is reversed then we _resume_ it.\n * A paused effect does not update, and the DOM subtree becomes inert.\n * @param {Effect} effect\n * @param {() => void} [callback]\n * @param {boolean} [destroy]\n */\nexport function pause_effect(effect, callback, destroy = true) {\n\t/** @type {TransitionManager[]} */\n\tvar transitions = [];\n\n\tpause_children(effect, transitions, true);\n\n\tvar fn = () => {\n\t\tif (destroy) destroy_effect(effect);\n\t\tif (callback) callback();\n\t};\n\n\tvar remaining = transitions.length;\n\tif (remaining > 0) {\n\t\tvar check = () => --remaining || fn();\n\t\tfor (var transition of transitions) {\n\t\t\ttransition.out(check);\n\t\t}\n\t} else {\n\t\tfn();\n\t}\n}\n\n/**\n * @param {Effect} effect\n * @param {TransitionManager[]} transitions\n * @param {boolean} local\n */\nfunction pause_children(effect, transitions, local) {\n\tif ((effect.f & INERT) !== 0) return;\n\teffect.f ^= INERT;\n\n\tvar t = effect.nodes && effect.nodes.t;\n\n\tif (t !== null) {\n\t\tfor (const transition of t) {\n\t\t\tif (transition.is_global || local) {\n\t\t\t\ttransitions.push(transition);\n\t\t\t}\n\t\t}\n\t}\n\n\tvar child = effect.first;\n\n\twhile (child !== null) {\n\t\tvar sibling = child.next;\n\t\tvar transparent =\n\t\t\t(child.f & EFFECT_TRANSPARENT) !== 0 ||\n\t\t\t// If this is a branch effect without a block effect parent,\n\t\t\t// it means the parent block effect was pruned. In that case,\n\t\t\t// transparency information was transferred to the branch effect.\n\t\t\t((child.f & BRANCH_EFFECT) !== 0 && (effect.f & BLOCK_EFFECT) !== 0);\n\t\t// TODO we don't need to call pause_children recursively with a linked list in place\n\t\t// it's slightly more involved though as we have to account for `transparent` changing\n\t\t// through the tree.\n\t\tpause_children(child, transitions, transparent ? local : false);\n\t\tchild = sibling;\n\t}\n}\n\n/**\n * The opposite of `pause_effect`. We call this if (for example)\n * `x` becomes falsy then truthy: `{#if x}...{/if}`\n * @param {Effect} effect\n */\nexport function resume_effect(effect) {\n\tresume_children(effect, true);\n}\n\n/**\n * @param {Effect} effect\n * @param {boolean} local\n */\nfunction resume_children(effect, local) {\n\tif ((effect.f & INERT) === 0) return;\n\teffect.f ^= INERT;\n\n\t// If a dependency of this effect changed while it was paused,\n\t// schedule the effect to update. we don't use `is_dirty`\n\t// here because we don't want to eagerly recompute a derived like\n\t// `{#if foo}{foo.bar()}{/if}` if `foo` is now `undefined\n\tif ((effect.f & CLEAN) === 0) {\n\t\tset_signal_status(effect, DIRTY);\n\t\tBatch.ensure().schedule(effect); // Assumption: This happens during the commit phase of the batch, causing another flush, but it's safe\n\t}\n\n\tvar child = effect.first;\n\n\twhile (child !== null) {\n\t\tvar sibling = child.next;\n\t\tvar transparent = (child.f & EFFECT_TRANSPARENT) !== 0 || (child.f & BRANCH_EFFECT) !== 0;\n\t\t// TODO we don't need to call resume_children recursively with a linked list in place\n\t\t// it's slightly more involved though as we have to account for `transparent` changing\n\t\t// through the tree.\n\t\tresume_children(child, transparent ? local : false);\n\t\tchild = sibling;\n\t}\n\n\tvar t = effect.nodes && effect.nodes.t;\n\n\tif (t !== null) {\n\t\tfor (const transition of t) {\n\t\t\tif (transition.is_global || local) {\n\t\t\t\ttransition.in();\n\t\t\t}\n\t\t}\n\t}\n}\n\nexport function aborted(effect = /** @type {Effect} */ (active_effect)) {\n\treturn (effect.f & DESTROYED) !== 0;\n}\n\n/**\n * @param {Effect} effect\n * @param {DocumentFragment} fragment\n */\nexport function move_effect(effect, fragment) {\n\tif (!effect.nodes) return;\n\n\t/** @type {TemplateNode | null} */\n\tvar node = effect.nodes.start;\n\tvar end = effect.nodes.end;\n\n\twhile (node !== null) {\n\t\t/** @type {TemplateNode | null} */\n\t\tvar next = node === end ? null : get_next_sibling(node);\n\n\t\tfragment.append(node);\n\t\tnode = next;\n\t}\n}\n","/** @import { Derived, Effect, Reaction, Source, Value } from '#client' */\nimport { DEV } from 'esm-env';\nimport { get_descriptors, get_prototype_of, includes, index_of } from '../shared/utils.js';\nimport {\n\tdestroy_block_effect_children,\n\tdestroy_effect_children,\n\teffect_tracking,\n\texecute_effect_teardown\n} from './reactivity/effects.js';\nimport {\n\tDIRTY,\n\tMAYBE_DIRTY,\n\tCLEAN,\n\tDERIVED,\n\tDESTROYED,\n\tBRANCH_EFFECT,\n\tSTATE_SYMBOL,\n\tBLOCK_EFFECT,\n\tROOT_EFFECT,\n\tCONNECTED,\n\tREACTION_IS_UPDATING,\n\tSTALE_REACTION,\n\tERROR_VALUE,\n\tWAS_MARKED,\n\tMANAGED_EFFECT,\n\tREACTION_RAN\n} from './constants.js';\nimport { old_values } from './reactivity/sources.js';\nimport {\n\tdestroy_derived_effects,\n\texecute_derived,\n\tfreeze_derived_effects,\n\trecent_async_deriveds,\n\tunfreeze_derived_effects,\n\tupdate_derived\n} from './reactivity/deriveds.js';\nimport { async_mode_flag, tracing_mode_flag } from '../flags/index.js';\nimport { tracing_expressions } from './dev/tracing.js';\nimport { get_error } from '../shared/dev.js';\nimport {\n\tcomponent_context,\n\tdev_current_component_function,\n\tdev_stack,\n\tis_runes,\n\tset_component_context,\n\tset_dev_current_component_function,\n\tset_dev_stack\n} from './context.js';\nimport {\n\tBatch,\n\tbatch_values,\n\tcurrent_batch,\n\tflushSync,\n\tschedule_effect\n} from './reactivity/batch.js';\nimport { handle_error } from './error-handling.js';\nimport { UNINITIALIZED } from '../../constants.js';\nimport { captured_signals } from './legacy.js';\nimport { without_reactive_context } from './dom/elements/bindings/shared.js';\nimport { set_signal_status, update_derived_status } from './reactivity/status.js';\n\nlet is_updating_effect = false;\n\nexport let is_destroying_effect = false;\n\n/** @param {boolean} value */\nexport function set_is_destroying_effect(value) {\n\tis_destroying_effect = value;\n}\n\n/** @type {null | Reaction} */\nexport let active_reaction = null;\n\nexport let untracking = false;\n\n/** @param {null | Reaction} reaction */\nexport function set_active_reaction(reaction) {\n\tactive_reaction = reaction;\n}\n\n/** @type {null | Effect} */\nexport let active_effect = null;\n\n/** @param {null | Effect} effect */\nexport function set_active_effect(effect) {\n\tactive_effect = effect;\n}\n\n/**\n * When sources are created within a reaction, reading and writing\n * them within that reaction should not cause a re-run\n * @type {null | Source[]}\n */\nexport let current_sources = null;\n\n/** @param {Value} value */\nexport function push_reaction_value(value) {\n\tif (active_reaction !== null && (!async_mode_flag || (active_reaction.f & DERIVED) !== 0)) {\n\t\tif (current_sources === null) {\n\t\t\tcurrent_sources = [value];\n\t\t} else {\n\t\t\tcurrent_sources.push(value);\n\t\t}\n\t}\n}\n\n/**\n * The dependencies of the reaction that is currently being executed. In many cases,\n * the dependencies are unchanged between runs, and so this will be `null` unless\n * and until a new dependency is accessed — we track this via `skipped_deps`\n * @type {null | Value[]}\n */\nlet new_deps = null;\n\nlet skipped_deps = 0;\n\n/**\n * Tracks writes that the effect it's executed in doesn't listen to yet,\n * so that the dependency can be added to the effect later on if it then reads it\n * @type {null | Source[]}\n */\nexport let untracked_writes = null;\n\n/** @param {null | Source[]} value */\nexport function set_untracked_writes(value) {\n\tuntracked_writes = value;\n}\n\n/**\n * @type {number} Used by sources and deriveds for handling updates.\n * Version starts from 1 so that unowned deriveds differentiate between a created effect and a run one for tracing\n **/\nexport let write_version = 1;\n\n/** @type {number} Used to version each read of a source of derived to avoid duplicating depedencies inside a reaction */\nlet read_version = 0;\n\nexport let update_version = read_version;\n\n/** @param {number} value */\nexport function set_update_version(value) {\n\tupdate_version = value;\n}\n\nexport function increment_write_version() {\n\treturn ++write_version;\n}\n\n/**\n * Determines whether a derived or effect is dirty.\n * If it is MAYBE_DIRTY, will set the status to CLEAN\n * @param {Reaction} reaction\n * @returns {boolean}\n */\nexport function is_dirty(reaction) {\n\tvar flags = reaction.f;\n\n\tif ((flags & DIRTY) !== 0) {\n\t\treturn true;\n\t}\n\n\tif (flags & DERIVED) {\n\t\treaction.f &= ~WAS_MARKED;\n\t}\n\n\tif ((flags & MAYBE_DIRTY) !== 0) {\n\t\tvar dependencies = /** @type {Value[]} */ (reaction.deps);\n\t\tvar length = dependencies.length;\n\n\t\tfor (var i = 0; i < length; i++) {\n\t\t\tvar dependency = dependencies[i];\n\n\t\t\tif (is_dirty(/** @type {Derived} */ (dependency))) {\n\t\t\t\tupdate_derived(/** @type {Derived} */ (dependency));\n\t\t\t}\n\n\t\t\tif (dependency.wv > reaction.wv) {\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\n\t\tif (\n\t\t\t(flags & CONNECTED) !== 0 &&\n\t\t\t// During time traveling we don't want to reset the status so that\n\t\t\t// traversal of the graph in the other batches still happens\n\t\t\tbatch_values === null\n\t\t) {\n\t\t\tset_signal_status(reaction, CLEAN);\n\t\t}\n\t}\n\n\treturn false;\n}\n\n/**\n * @param {Value} signal\n * @param {Effect} effect\n * @param {boolean} [root]\n */\nfunction schedule_possible_effect_self_invalidation(signal, effect, root = true) {\n\tvar reactions = signal.reactions;\n\tif (reactions === null) return;\n\n\tif (!async_mode_flag && current_sources !== null && includes.call(current_sources, signal)) {\n\t\treturn;\n\t}\n\n\tfor (var i = 0; i < reactions.length; i++) {\n\t\tvar reaction = reactions[i];\n\n\t\tif ((reaction.f & DERIVED) !== 0) {\n\t\t\tschedule_possible_effect_self_invalidation(/** @type {Derived} */ (reaction), effect, false);\n\t\t} else if (effect === reaction) {\n\t\t\tif (root) {\n\t\t\t\tset_signal_status(reaction, DIRTY);\n\t\t\t} else if ((reaction.f & CLEAN) !== 0) {\n\t\t\t\tset_signal_status(reaction, MAYBE_DIRTY);\n\t\t\t}\n\t\t\tschedule_effect(/** @type {Effect} */ (reaction));\n\t\t}\n\t}\n}\n\n/** @param {Reaction} reaction */\nexport function update_reaction(reaction) {\n\tvar previous_deps = new_deps;\n\tvar previous_skipped_deps = skipped_deps;\n\tvar previous_untracked_writes = untracked_writes;\n\tvar previous_reaction = active_reaction;\n\tvar previous_sources = current_sources;\n\tvar previous_component_context = component_context;\n\tvar previous_untracking = untracking;\n\tvar previous_update_version = update_version;\n\n\tvar flags = reaction.f;\n\n\tnew_deps = /** @type {null | Value[]} */ (null);\n\tskipped_deps = 0;\n\tuntracked_writes = null;\n\tactive_reaction = (flags & (BRANCH_EFFECT | ROOT_EFFECT)) === 0 ? reaction : null;\n\n\tcurrent_sources = null;\n\tset_component_context(reaction.ctx);\n\tuntracking = false;\n\tupdate_version = ++read_version;\n\n\tif (reaction.ac !== null) {\n\t\twithout_reactive_context(() => {\n\t\t\t/** @type {AbortController} */ (reaction.ac).abort(STALE_REACTION);\n\t\t});\n\n\t\treaction.ac = null;\n\t}\n\n\ttry {\n\t\treaction.f |= REACTION_IS_UPDATING;\n\t\tvar fn = /** @type {Function} */ (reaction.fn);\n\t\tvar result = fn();\n\t\treaction.f |= REACTION_RAN;\n\t\tvar deps = reaction.deps;\n\n\t\t// Don't remove reactions during fork;\n\t\t// they must remain for when fork is discarded\n\t\tvar is_fork = current_batch?.is_fork;\n\n\t\tif (new_deps !== null) {\n\t\t\tvar i;\n\n\t\t\tif (!is_fork) {\n\t\t\t\tremove_reactions(reaction, skipped_deps);\n\t\t\t}\n\n\t\t\tif (deps !== null && skipped_deps > 0) {\n\t\t\t\tdeps.length = skipped_deps + new_deps.length;\n\t\t\t\tfor (i = 0; i < new_deps.length; i++) {\n\t\t\t\t\tdeps[skipped_deps + i] = new_deps[i];\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\treaction.deps = deps = new_deps;\n\t\t\t}\n\n\t\t\tif (effect_tracking() && (reaction.f & CONNECTED) !== 0) {\n\t\t\t\tfor (i = skipped_deps; i < deps.length; i++) {\n\t\t\t\t\t(deps[i].reactions ??= []).push(reaction);\n\t\t\t\t}\n\t\t\t}\n\t\t} else if (!is_fork && deps !== null && skipped_deps < deps.length) {\n\t\t\tremove_reactions(reaction, skipped_deps);\n\t\t\tdeps.length = skipped_deps;\n\t\t}\n\n\t\t// If we're inside an effect and we have untracked writes, then we need to\n\t\t// ensure that if any of those untracked writes result in re-invalidation\n\t\t// of the current effect, then that happens accordingly\n\t\tif (\n\t\t\tis_runes() &&\n\t\t\tuntracked_writes !== null &&\n\t\t\t!untracking &&\n\t\t\tdeps !== null &&\n\t\t\t(reaction.f & (DERIVED | MAYBE_DIRTY | DIRTY)) === 0\n\t\t) {\n\t\t\tfor (i = 0; i < /** @type {Source[]} */ (untracked_writes).length; i++) {\n\t\t\t\tschedule_possible_effect_self_invalidation(\n\t\t\t\t\tuntracked_writes[i],\n\t\t\t\t\t/** @type {Effect} */ (reaction)\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\t// If we are returning to an previous reaction then\n\t\t// we need to increment the read version to ensure that\n\t\t// any dependencies in this reaction aren't marked with\n\t\t// the same version\n\t\tif (previous_reaction !== null && previous_reaction !== reaction) {\n\t\t\tread_version++;\n\n\t\t\t// update the `rv` of the previous reaction's deps — both existing and new —\n\t\t\t// so that they are not added again\n\t\t\tif (previous_reaction.deps !== null) {\n\t\t\t\tfor (let i = 0; i < previous_skipped_deps; i += 1) {\n\t\t\t\t\tprevious_reaction.deps[i].rv = read_version;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (previous_deps !== null) {\n\t\t\t\tfor (const dep of previous_deps) {\n\t\t\t\t\tdep.rv = read_version;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (untracked_writes !== null) {\n\t\t\t\tif (previous_untracked_writes === null) {\n\t\t\t\t\tprevious_untracked_writes = untracked_writes;\n\t\t\t\t} else {\n\t\t\t\t\tprevious_untracked_writes.push(.../** @type {Source[]} */ (untracked_writes));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif ((reaction.f & ERROR_VALUE) !== 0) {\n\t\t\treaction.f ^= ERROR_VALUE;\n\t\t}\n\n\t\treturn result;\n\t} catch (error) {\n\t\treturn handle_error(error);\n\t} finally {\n\t\treaction.f ^= REACTION_IS_UPDATING;\n\t\tnew_deps = previous_deps;\n\t\tskipped_deps = previous_skipped_deps;\n\t\tuntracked_writes = previous_untracked_writes;\n\t\tactive_reaction = previous_reaction;\n\t\tcurrent_sources = previous_sources;\n\t\tset_component_context(previous_component_context);\n\t\tuntracking = previous_untracking;\n\t\tupdate_version = previous_update_version;\n\t}\n}\n\n/**\n * @template V\n * @param {Reaction} signal\n * @param {Value} dependency\n * @returns {void}\n */\nfunction remove_reaction(signal, dependency) {\n\tlet reactions = dependency.reactions;\n\tif (reactions !== null) {\n\t\tvar index = index_of.call(reactions, signal);\n\t\tif (index !== -1) {\n\t\t\tvar new_length = reactions.length - 1;\n\t\t\tif (new_length === 0) {\n\t\t\t\treactions = dependency.reactions = null;\n\t\t\t} else {\n\t\t\t\t// Swap with last element and then remove.\n\t\t\t\treactions[index] = reactions[new_length];\n\t\t\t\treactions.pop();\n\t\t\t}\n\t\t}\n\t}\n\n\t// If the derived has no reactions, then we can disconnect it from the graph,\n\t// allowing it to either reconnect in the future, or be GC'd by the VM.\n\tif (\n\t\treactions === null &&\n\t\t(dependency.f & DERIVED) !== 0 &&\n\t\t// Destroying a child effect while updating a parent effect can cause a dependency to appear\n\t\t// to be unused, when in fact it is used by the currently-updating parent. Checking `new_deps`\n\t\t// allows us to skip the expensive work of disconnecting and immediately reconnecting it\n\t\t(new_deps === null || !includes.call(new_deps, dependency))\n\t) {\n\t\tvar derived = /** @type {Derived} */ (dependency);\n\n\t\t// If we are working with a derived that is owned by an effect, then mark it as being\n\t\t// disconnected and remove the mark flag, as it cannot be reliably removed otherwise\n\t\tif ((derived.f & CONNECTED) !== 0) {\n\t\t\tderived.f ^= CONNECTED;\n\t\t\tderived.f &= ~WAS_MARKED;\n\t\t}\n\n\t\tupdate_derived_status(derived);\n\n\t\t// freeze any effects inside this derived\n\t\tfreeze_derived_effects(derived);\n\n\t\t// Disconnect any reactions owned by this reaction\n\t\tremove_reactions(derived, 0);\n\t}\n}\n\n/**\n * @param {Reaction} signal\n * @param {number} start_index\n * @returns {void}\n */\nexport function remove_reactions(signal, start_index) {\n\tvar dependencies = signal.deps;\n\tif (dependencies === null) return;\n\n\tfor (var i = start_index; i < dependencies.length; i++) {\n\t\tremove_reaction(signal, dependencies[i]);\n\t}\n}\n\n/**\n * @param {Effect} effect\n * @returns {void}\n */\nexport function update_effect(effect) {\n\tvar flags = effect.f;\n\n\tif ((flags & DESTROYED) !== 0) {\n\t\treturn;\n\t}\n\n\tset_signal_status(effect, CLEAN);\n\n\tvar previous_effect = active_effect;\n\tvar was_updating_effect = is_updating_effect;\n\n\tactive_effect = effect;\n\tis_updating_effect = true;\n\n\tif (DEV) {\n\t\tvar previous_component_fn = dev_current_component_function;\n\t\tset_dev_current_component_function(effect.component_function);\n\t\tvar previous_stack = /** @type {any} */ (dev_stack);\n\t\t// only block effects have a dev stack, keep the current one otherwise\n\t\tset_dev_stack(effect.dev_stack ?? dev_stack);\n\t}\n\n\ttry {\n\t\tif ((flags & (BLOCK_EFFECT | MANAGED_EFFECT)) !== 0) {\n\t\t\tdestroy_block_effect_children(effect);\n\t\t} else {\n\t\t\tdestroy_effect_children(effect);\n\t\t}\n\n\t\texecute_effect_teardown(effect);\n\t\tvar teardown = update_reaction(effect);\n\t\teffect.teardown = typeof teardown === 'function' ? teardown : null;\n\t\teffect.wv = write_version;\n\n\t\t// In DEV, increment versions of any sources that were written to during the effect,\n\t\t// so that they are correctly marked as dirty when the effect re-runs\n\t\tif (DEV && tracing_mode_flag && (effect.f & DIRTY) !== 0 && effect.deps !== null) {\n\t\t\tfor (var dep of effect.deps) {\n\t\t\t\tif (dep.set_during_effect) {\n\t\t\t\t\tdep.wv = increment_write_version();\n\t\t\t\t\tdep.set_during_effect = false;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t} finally {\n\t\tis_updating_effect = was_updating_effect;\n\t\tactive_effect = previous_effect;\n\n\t\tif (DEV) {\n\t\t\tset_dev_current_component_function(previous_component_fn);\n\t\t\tset_dev_stack(previous_stack);\n\t\t}\n\t}\n}\n\n/**\n * Returns a promise that resolves once any pending state changes have been applied.\n * @returns {Promise}\n */\nexport async function tick() {\n\tif (async_mode_flag) {\n\t\treturn new Promise((f) => {\n\t\t\t// Race them against each other - in almost all cases requestAnimationFrame will fire first,\n\t\t\t// but e.g. in case the window is not focused or a view transition happens, requestAnimationFrame\n\t\t\t// will be delayed and setTimeout helps us resolve fast enough in that case\n\t\t\trequestAnimationFrame(() => f());\n\t\t\tsetTimeout(() => f());\n\t\t});\n\t}\n\n\tawait Promise.resolve();\n\n\t// By calling flushSync we guarantee that any pending state changes are applied after one tick.\n\t// TODO look into whether we can make flushing subsequent updates synchronously in the future.\n\tflushSync();\n}\n\n/**\n * Returns a promise that resolves once any state changes, and asynchronous work resulting from them,\n * have resolved and the DOM has been updated\n * @returns {Promise}\n * @since 5.36\n */\nexport function settled() {\n\treturn Batch.ensure().settled();\n}\n\n/**\n * @template V\n * @param {Value} signal\n * @returns {V}\n */\nexport function get(signal) {\n\tvar flags = signal.f;\n\tvar is_derived = (flags & DERIVED) !== 0;\n\n\tcaptured_signals?.add(signal);\n\n\t// Register the dependency on the current reaction signal.\n\tif (active_reaction !== null && !untracking) {\n\t\t// if we're in a derived that is being read inside an _async_ derived,\n\t\t// it's possible that the effect was already destroyed. In this case,\n\t\t// we don't add the dependency, because that would create a memory leak\n\t\tvar destroyed = active_effect !== null && (active_effect.f & DESTROYED) !== 0;\n\n\t\tif (!destroyed && (current_sources === null || !includes.call(current_sources, signal))) {\n\t\t\tvar deps = active_reaction.deps;\n\n\t\t\tif ((active_reaction.f & REACTION_IS_UPDATING) !== 0) {\n\t\t\t\t// we're in the effect init/update cycle\n\t\t\t\tif (signal.rv < read_version) {\n\t\t\t\t\tsignal.rv = read_version;\n\n\t\t\t\t\t// If the signal is accessing the same dependencies in the same\n\t\t\t\t\t// order as it did last time, increment `skipped_deps`\n\t\t\t\t\t// rather than updating `new_deps`, which creates GC cost\n\t\t\t\t\tif (new_deps === null && deps !== null && deps[skipped_deps] === signal) {\n\t\t\t\t\t\tskipped_deps++;\n\t\t\t\t\t} else if (new_deps === null) {\n\t\t\t\t\t\tnew_deps = [signal];\n\t\t\t\t\t} else {\n\t\t\t\t\t\tnew_deps.push(signal);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// we're adding a dependency outside the init/update cycle\n\t\t\t\t// (i.e. after an `await`)\n\t\t\t\t(active_reaction.deps ??= []).push(signal);\n\n\t\t\t\tvar reactions = signal.reactions;\n\n\t\t\t\tif (reactions === null) {\n\t\t\t\t\tsignal.reactions = [active_reaction];\n\t\t\t\t} else if (!includes.call(reactions, active_reaction)) {\n\t\t\t\t\treactions.push(active_reaction);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif (DEV) {\n\t\t// TODO reinstate this, but make it actually work\n\t\t// if (current_async_effect) {\n\t\t// \tvar tracking = (current_async_effect.f & REACTION_IS_UPDATING) !== 0;\n\t\t// \tvar was_read = current_async_effect.deps?.includes(signal);\n\n\t\t// \tif (!tracking && !untracking && !was_read) {\n\t\t// \t\tw.await_reactivity_loss(/** @type {string} */ (signal.label));\n\n\t\t// \t\tvar trace = get_error('traced at');\n\t\t// \t\t// eslint-disable-next-line no-console\n\t\t// \t\tif (trace) console.warn(trace);\n\t\t// \t}\n\t\t// }\n\n\t\trecent_async_deriveds.delete(signal);\n\n\t\tif (\n\t\t\ttracing_mode_flag &&\n\t\t\t!untracking &&\n\t\t\ttracing_expressions !== null &&\n\t\t\tactive_reaction !== null &&\n\t\t\ttracing_expressions.reaction === active_reaction\n\t\t) {\n\t\t\t// Used when mapping state between special blocks like `each`\n\t\t\tif (signal.trace) {\n\t\t\t\tsignal.trace();\n\t\t\t} else {\n\t\t\t\tvar trace = get_error('traced at');\n\n\t\t\t\tif (trace) {\n\t\t\t\t\tvar entry = tracing_expressions.entries.get(signal);\n\n\t\t\t\t\tif (entry === undefined) {\n\t\t\t\t\t\tentry = { traces: [] };\n\t\t\t\t\t\ttracing_expressions.entries.set(signal, entry);\n\t\t\t\t\t}\n\n\t\t\t\t\tvar last = entry.traces[entry.traces.length - 1];\n\n\t\t\t\t\t// traces can be duplicated, e.g. by `snapshot` invoking both\n\t\t\t\t\t// both `getOwnPropertyDescriptor` and `get` traps at once\n\t\t\t\t\tif (trace.stack !== last?.stack) {\n\t\t\t\t\t\tentry.traces.push(trace);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tif (is_destroying_effect && old_values.has(signal)) {\n\t\treturn old_values.get(signal);\n\t}\n\n\tif (is_derived) {\n\t\tvar derived = /** @type {Derived} */ (signal);\n\n\t\tif (is_destroying_effect) {\n\t\t\tvar value = derived.v;\n\n\t\t\t// if the derived is dirty and has reactions, or depends on the values that just changed, re-execute\n\t\t\t// (a derived can be maybe_dirty due to the effect destroy removing its last reaction)\n\t\t\tif (\n\t\t\t\t((derived.f & CLEAN) === 0 && derived.reactions !== null) ||\n\t\t\t\tdepends_on_old_values(derived)\n\t\t\t) {\n\t\t\t\tvalue = execute_derived(derived);\n\t\t\t}\n\n\t\t\told_values.set(derived, value);\n\n\t\t\treturn value;\n\t\t}\n\n\t\t// connect disconnected deriveds if we are reading them inside an effect,\n\t\t// or inside another derived that is already connected\n\t\tvar should_connect =\n\t\t\t(derived.f & CONNECTED) === 0 &&\n\t\t\t!untracking &&\n\t\t\tactive_reaction !== null &&\n\t\t\t(is_updating_effect || (active_reaction.f & CONNECTED) !== 0);\n\n\t\tvar is_new = (derived.f & REACTION_RAN) === 0;\n\n\t\tif (is_dirty(derived)) {\n\t\t\tif (should_connect) {\n\t\t\t\t// set the flag before `update_derived`, so that the derived\n\t\t\t\t// is added as a reaction to its dependencies\n\t\t\t\tderived.f |= CONNECTED;\n\t\t\t}\n\n\t\t\tupdate_derived(derived);\n\t\t}\n\n\t\tif (should_connect && !is_new) {\n\t\t\tunfreeze_derived_effects(derived);\n\t\t\treconnect(derived);\n\t\t}\n\t}\n\n\tif (batch_values?.has(signal)) {\n\t\treturn batch_values.get(signal);\n\t}\n\n\tif ((signal.f & ERROR_VALUE) !== 0) {\n\t\tthrow signal.v;\n\t}\n\n\treturn signal.v;\n}\n\n/**\n * (Re)connect a disconnected derived, so that it is notified\n * of changes in `mark_reactions`\n * @param {Derived} derived\n */\nfunction reconnect(derived) {\n\tderived.f |= CONNECTED;\n\n\tif (derived.deps === null) return;\n\n\tfor (const dep of derived.deps) {\n\t\t(dep.reactions ??= []).push(derived);\n\n\t\tif ((dep.f & DERIVED) !== 0 && (dep.f & CONNECTED) === 0) {\n\t\t\tunfreeze_derived_effects(/** @type {Derived} */ (dep));\n\t\t\treconnect(/** @type {Derived} */ (dep));\n\t\t}\n\t}\n}\n\n/** @param {Derived} derived */\nfunction depends_on_old_values(derived) {\n\tif (derived.v === UNINITIALIZED) return true; // we don't know, so assume the worst\n\tif (derived.deps === null) return false;\n\n\tfor (const dep of derived.deps) {\n\t\tif (old_values.has(dep)) {\n\t\t\treturn true;\n\t\t}\n\n\t\tif ((dep.f & DERIVED) !== 0 && depends_on_old_values(/** @type {Derived} */ (dep))) {\n\t\t\treturn true;\n\t\t}\n\t}\n\n\treturn false;\n}\n\n/**\n * Like `get`, but checks for `undefined`. Used for `var` declarations because they can be accessed before being declared\n * @template V\n * @param {Value | undefined} signal\n * @returns {V | undefined}\n */\nexport function safe_get(signal) {\n\treturn signal && get(signal);\n}\n\n/**\n * When used inside a [`$derived`](https://svelte.dev/docs/svelte/$derived) or [`$effect`](https://svelte.dev/docs/svelte/$effect),\n * any state read inside `fn` will not be treated as a dependency.\n *\n * ```ts\n * $effect(() => {\n * // this will run when `data` changes, but not when `time` changes\n * save(data, {\n * timestamp: untrack(() => time)\n * });\n * });\n * ```\n * @template T\n * @param {() => T} fn\n * @returns {T}\n */\nexport function untrack(fn) {\n\tvar previous_untracking = untracking;\n\ttry {\n\t\tuntracking = true;\n\t\treturn fn();\n\t} finally {\n\t\tuntracking = previous_untracking;\n\t}\n}\n\n/**\n * Possibly traverse an object and read all its properties so that they're all reactive in case this is `$state`.\n * Does only check first level of an object for performance reasons (heuristic should be good for 99% of all cases).\n * @param {any} value\n * @returns {void}\n */\nexport function deep_read_state(value) {\n\tif (typeof value !== 'object' || !value || value instanceof EventTarget) {\n\t\treturn;\n\t}\n\n\tif (STATE_SYMBOL in value) {\n\t\tdeep_read(value);\n\t} else if (!Array.isArray(value)) {\n\t\tfor (let key in value) {\n\t\t\tconst prop = value[key];\n\t\t\tif (typeof prop === 'object' && prop && STATE_SYMBOL in prop) {\n\t\t\t\tdeep_read(prop);\n\t\t\t}\n\t\t}\n\t}\n}\n\n/**\n * Deeply traverse an object and read all its properties\n * so that they're all reactive in case this is `$state`\n * @param {any} value\n * @param {Set} visited\n * @returns {void}\n */\nexport function deep_read(value, visited = new Set()) {\n\tif (\n\t\ttypeof value === 'object' &&\n\t\tvalue !== null &&\n\t\t// We don't want to traverse DOM elements\n\t\t!(value instanceof EventTarget) &&\n\t\t!visited.has(value)\n\t) {\n\t\tvisited.add(value);\n\t\t// When working with a possible SvelteDate, this\n\t\t// will ensure we capture changes to it.\n\t\tif (value instanceof Date) {\n\t\t\tvalue.getTime();\n\t\t}\n\t\tfor (let key in value) {\n\t\t\ttry {\n\t\t\t\tdeep_read(value[key], visited);\n\t\t\t} catch (e) {\n\t\t\t\t// continue\n\t\t\t}\n\t\t}\n\t\tconst proto = get_prototype_of(value);\n\t\tif (\n\t\t\tproto !== Object.prototype &&\n\t\t\tproto !== Array.prototype &&\n\t\t\tproto !== Map.prototype &&\n\t\t\tproto !== Set.prototype &&\n\t\t\tproto !== Date.prototype\n\t\t) {\n\t\t\tconst descriptors = get_descriptors(proto);\n\t\t\tfor (let key in descriptors) {\n\t\t\t\tconst get = descriptors[key].get;\n\t\t\t\tif (get) {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tget.call(value);\n\t\t\t\t\t} catch (e) {\n\t\t\t\t\t\t// continue\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n","const regex_return_characters = /\\r/g;\n\n/**\n * @param {string} str\n * @returns {string}\n */\nexport function hash(str) {\n\tstr = str.replace(regex_return_characters, '');\n\tlet hash = 5381;\n\tlet i = str.length;\n\n\twhile (i--) hash = ((hash << 5) - hash) ^ str.charCodeAt(i);\n\treturn (hash >>> 0).toString(36);\n}\n\nconst VOID_ELEMENT_NAMES = [\n\t'area',\n\t'base',\n\t'br',\n\t'col',\n\t'command',\n\t'embed',\n\t'hr',\n\t'img',\n\t'input',\n\t'keygen',\n\t'link',\n\t'meta',\n\t'param',\n\t'source',\n\t'track',\n\t'wbr'\n];\n\n/**\n * Returns `true` if `name` is of a void element\n * @param {string} name\n */\nexport function is_void(name) {\n\treturn VOID_ELEMENT_NAMES.includes(name) || name.toLowerCase() === '!doctype';\n}\n\nconst RESERVED_WORDS = [\n\t'arguments',\n\t'await',\n\t'break',\n\t'case',\n\t'catch',\n\t'class',\n\t'const',\n\t'continue',\n\t'debugger',\n\t'default',\n\t'delete',\n\t'do',\n\t'else',\n\t'enum',\n\t'eval',\n\t'export',\n\t'extends',\n\t'false',\n\t'finally',\n\t'for',\n\t'function',\n\t'if',\n\t'implements',\n\t'import',\n\t'in',\n\t'instanceof',\n\t'interface',\n\t'let',\n\t'new',\n\t'null',\n\t'package',\n\t'private',\n\t'protected',\n\t'public',\n\t'return',\n\t'static',\n\t'super',\n\t'switch',\n\t'this',\n\t'throw',\n\t'true',\n\t'try',\n\t'typeof',\n\t'var',\n\t'void',\n\t'while',\n\t'with',\n\t'yield'\n];\n\n/**\n * Returns `true` if `word` is a reserved JavaScript keyword\n * @param {string} word\n */\nexport function is_reserved(word) {\n\treturn RESERVED_WORDS.includes(word);\n}\n\n/**\n * @param {string} name\n */\nexport function is_capture_event(name) {\n\treturn name.endsWith('capture') && name !== 'gotpointercapture' && name !== 'lostpointercapture';\n}\n\n/** List of Element events that will be delegated */\nconst DELEGATED_EVENTS = [\n\t'beforeinput',\n\t'click',\n\t'change',\n\t'dblclick',\n\t'contextmenu',\n\t'focusin',\n\t'focusout',\n\t'input',\n\t'keydown',\n\t'keyup',\n\t'mousedown',\n\t'mousemove',\n\t'mouseout',\n\t'mouseover',\n\t'mouseup',\n\t'pointerdown',\n\t'pointermove',\n\t'pointerout',\n\t'pointerover',\n\t'pointerup',\n\t'touchend',\n\t'touchmove',\n\t'touchstart'\n];\n\n/**\n * Returns `true` if `event_name` is a delegated event\n * @param {string} event_name\n */\nexport function can_delegate_event(event_name) {\n\treturn DELEGATED_EVENTS.includes(event_name);\n}\n\n/**\n * Attributes that are boolean, i.e. they are present or not present.\n */\nconst DOM_BOOLEAN_ATTRIBUTES = [\n\t'allowfullscreen',\n\t'async',\n\t'autofocus',\n\t'autoplay',\n\t'checked',\n\t'controls',\n\t'default',\n\t'disabled',\n\t'formnovalidate',\n\t'indeterminate',\n\t'inert',\n\t'ismap',\n\t'loop',\n\t'multiple',\n\t'muted',\n\t'nomodule',\n\t'novalidate',\n\t'open',\n\t'playsinline',\n\t'readonly',\n\t'required',\n\t'reversed',\n\t'seamless',\n\t'selected',\n\t'webkitdirectory',\n\t'defer',\n\t'disablepictureinpicture',\n\t'disableremoteplayback'\n];\n\n/**\n * Returns `true` if `name` is a boolean attribute\n * @param {string} name\n */\nexport function is_boolean_attribute(name) {\n\treturn DOM_BOOLEAN_ATTRIBUTES.includes(name);\n}\n\n/**\n * @type {Record}\n * List of attribute names that should be aliased to their property names\n * because they behave differently between setting them as an attribute and\n * setting them as a property.\n */\nconst ATTRIBUTE_ALIASES = {\n\t// no `class: 'className'` because we handle that separately\n\tformnovalidate: 'formNoValidate',\n\tismap: 'isMap',\n\tnomodule: 'noModule',\n\tplaysinline: 'playsInline',\n\treadonly: 'readOnly',\n\tdefaultvalue: 'defaultValue',\n\tdefaultchecked: 'defaultChecked',\n\tsrcobject: 'srcObject',\n\tnovalidate: 'noValidate',\n\tallowfullscreen: 'allowFullscreen',\n\tdisablepictureinpicture: 'disablePictureInPicture',\n\tdisableremoteplayback: 'disableRemotePlayback'\n};\n\n/**\n * @param {string} name\n */\nexport function normalize_attribute(name) {\n\tname = name.toLowerCase();\n\treturn ATTRIBUTE_ALIASES[name] ?? name;\n}\n\nconst DOM_PROPERTIES = [\n\t...DOM_BOOLEAN_ATTRIBUTES,\n\t'formNoValidate',\n\t'isMap',\n\t'noModule',\n\t'playsInline',\n\t'readOnly',\n\t'value',\n\t'volume',\n\t'defaultValue',\n\t'defaultChecked',\n\t'srcObject',\n\t'noValidate',\n\t'allowFullscreen',\n\t'disablePictureInPicture',\n\t'disableRemotePlayback'\n];\n\n/**\n * @param {string} name\n */\nexport function is_dom_property(name) {\n\treturn DOM_PROPERTIES.includes(name);\n}\n\nconst NON_STATIC_PROPERTIES = ['autofocus', 'muted', 'defaultValue', 'defaultChecked'];\n\n/**\n * Returns `true` if the given attribute cannot be set through the template\n * string, i.e. needs some kind of JavaScript handling to work.\n * @param {string} name\n */\nexport function cannot_be_set_statically(name) {\n\treturn NON_STATIC_PROPERTIES.includes(name);\n}\n\n/**\n * Subset of delegated events which should be passive by default.\n * These two are already passive via browser defaults on window, document and body.\n * But since\n * - we're delegating them\n * - they happen often\n * - they apply to mobile which is generally less performant\n * we're marking them as passive by default for other elements, too.\n */\nconst PASSIVE_EVENTS = ['touchstart', 'touchmove'];\n\n/**\n * Returns `true` if `name` is a passive event\n * @param {string} name\n */\nexport function is_passive_event(name) {\n\treturn PASSIVE_EVENTS.includes(name);\n}\n\nconst CONTENT_EDITABLE_BINDINGS = ['textContent', 'innerHTML', 'innerText'];\n\n/** @param {string} name */\nexport function is_content_editable_binding(name) {\n\treturn CONTENT_EDITABLE_BINDINGS.includes(name);\n}\n\nconst LOAD_ERROR_ELEMENTS = [\n\t'body',\n\t'embed',\n\t'iframe',\n\t'img',\n\t'link',\n\t'object',\n\t'script',\n\t'style',\n\t'track'\n];\n\n/**\n * Returns `true` if the element emits `load` and `error` events\n * @param {string} name\n */\nexport function is_load_error_element(name) {\n\treturn LOAD_ERROR_ELEMENTS.includes(name);\n}\n\nconst SVG_ELEMENTS = [\n\t'altGlyph',\n\t'altGlyphDef',\n\t'altGlyphItem',\n\t'animate',\n\t'animateColor',\n\t'animateMotion',\n\t'animateTransform',\n\t'circle',\n\t'clipPath',\n\t'color-profile',\n\t'cursor',\n\t'defs',\n\t'desc',\n\t'discard',\n\t'ellipse',\n\t'feBlend',\n\t'feColorMatrix',\n\t'feComponentTransfer',\n\t'feComposite',\n\t'feConvolveMatrix',\n\t'feDiffuseLighting',\n\t'feDisplacementMap',\n\t'feDistantLight',\n\t'feDropShadow',\n\t'feFlood',\n\t'feFuncA',\n\t'feFuncB',\n\t'feFuncG',\n\t'feFuncR',\n\t'feGaussianBlur',\n\t'feImage',\n\t'feMerge',\n\t'feMergeNode',\n\t'feMorphology',\n\t'feOffset',\n\t'fePointLight',\n\t'feSpecularLighting',\n\t'feSpotLight',\n\t'feTile',\n\t'feTurbulence',\n\t'filter',\n\t'font',\n\t'font-face',\n\t'font-face-format',\n\t'font-face-name',\n\t'font-face-src',\n\t'font-face-uri',\n\t'foreignObject',\n\t'g',\n\t'glyph',\n\t'glyphRef',\n\t'hatch',\n\t'hatchpath',\n\t'hkern',\n\t'image',\n\t'line',\n\t'linearGradient',\n\t'marker',\n\t'mask',\n\t'mesh',\n\t'meshgradient',\n\t'meshpatch',\n\t'meshrow',\n\t'metadata',\n\t'missing-glyph',\n\t'mpath',\n\t'path',\n\t'pattern',\n\t'polygon',\n\t'polyline',\n\t'radialGradient',\n\t'rect',\n\t'set',\n\t'solidcolor',\n\t'stop',\n\t'svg',\n\t'switch',\n\t'symbol',\n\t'text',\n\t'textPath',\n\t'tref',\n\t'tspan',\n\t'unknown',\n\t'use',\n\t'view',\n\t'vkern'\n];\n\n/** @param {string} name */\nexport function is_svg(name) {\n\treturn SVG_ELEMENTS.includes(name);\n}\n\nconst MATHML_ELEMENTS = [\n\t'annotation',\n\t'annotation-xml',\n\t'maction',\n\t'math',\n\t'merror',\n\t'mfrac',\n\t'mi',\n\t'mmultiscripts',\n\t'mn',\n\t'mo',\n\t'mover',\n\t'mpadded',\n\t'mphantom',\n\t'mprescripts',\n\t'mroot',\n\t'mrow',\n\t'ms',\n\t'mspace',\n\t'msqrt',\n\t'mstyle',\n\t'msub',\n\t'msubsup',\n\t'msup',\n\t'mtable',\n\t'mtd',\n\t'mtext',\n\t'mtr',\n\t'munder',\n\t'munderover',\n\t'semantics'\n];\n\n/** @param {string} name */\nexport function is_mathml(name) {\n\treturn MATHML_ELEMENTS.includes(name);\n}\n\nconst STATE_CREATION_RUNES = /** @type {const} */ ([\n\t'$state',\n\t'$state.raw',\n\t'$derived',\n\t'$derived.by'\n]);\n\nconst RUNES = /** @type {const} */ ([\n\t...STATE_CREATION_RUNES,\n\t'$state.eager',\n\t'$state.snapshot',\n\t'$props',\n\t'$props.id',\n\t'$bindable',\n\t'$effect',\n\t'$effect.pre',\n\t'$effect.tracking',\n\t'$effect.root',\n\t'$effect.pending',\n\t'$inspect',\n\t'$inspect().with',\n\t'$inspect.trace',\n\t'$host'\n]);\n\n/** @typedef {typeof RUNES[number]} RuneName */\n\n/**\n * @param {string} name\n * @returns {name is RuneName}\n */\nexport function is_rune(name) {\n\treturn RUNES.includes(/** @type {RuneName} */ (name));\n}\n\n/** @typedef {typeof STATE_CREATION_RUNES[number]} StateCreationRuneName */\n\n/**\n * @param {string} name\n * @returns {name is StateCreationRuneName}\n */\nexport function is_state_creation_rune(name) {\n\treturn STATE_CREATION_RUNES.includes(/** @type {StateCreationRuneName} */ (name));\n}\n\n/** List of elements that require raw contents and should not have SSR comments put in them */\nconst RAW_TEXT_ELEMENTS = /** @type {const} */ (['textarea', 'script', 'style', 'title']);\n\n/** @param {string} name */\nexport function is_raw_text_element(name) {\n\treturn RAW_TEXT_ELEMENTS.includes(/** @type {typeof RAW_TEXT_ELEMENTS[number]} */ (name));\n}\n\n// Matches valid HTML/SVG/MathML element names and custom element names.\n// https://html.spec.whatwg.org/multipage/custom-elements.html#valid-custom-element-name\n//\n// Standard elements: ASCII alpha start, followed by ASCII alphanumerics.\n// Custom elements: ASCII alpha start, followed by any mix of PCENChar (which\n// includes ASCII alphanumerics, `-`, `.`, `_`, and specified Unicode ranges),\n// with at least one hyphen required somewhere after the first character.\n//\n// Rejects strings containing whitespace, quotes, angle brackets, slashes, equals,\n// or other characters that could break out of a tag-name token and enable markup injection.\nexport const REGEX_VALID_TAG_NAME =\n\t/^[a-zA-Z][a-zA-Z0-9]*(-[a-zA-Z0-9.\\-_\\u00B7\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u203F-\\u2040\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\u{10000}-\\u{EFFFF}]+)*$/u;\n\n/**\n * Prevent devtools trying to make `location` a clickable link by inserting a zero-width space\n * @template {string | undefined} T\n * @param {T} location\n * @returns {T};\n */\nexport function sanitize_location(location) {\n\treturn /** @type {T} */ (location?.replace(/\\//g, '/\\u200b'));\n}\n","import { teardown } from '../../reactivity/effects.js';\nimport { define_property } from '../../../shared/utils.js';\nimport { hydrating } from '../hydration.js';\nimport { queue_micro_task } from '../task.js';\nimport { FILENAME } from '../../../../constants.js';\nimport * as w from '../../warnings.js';\nimport {\n\tactive_effect,\n\tactive_reaction,\n\tset_active_effect,\n\tset_active_reaction\n} from '../../runtime.js';\nimport { without_reactive_context } from './bindings/shared.js';\n\n/**\n * Used on elements, as a map of event type -> event handler,\n * and on events themselves to track which element handled an event\n */\nexport const event_symbol = Symbol('events');\n\n/** @type {Set} */\nexport const all_registered_events = new Set();\n\n/** @type {Set<(events: Array) => void>} */\nexport const root_event_handles = new Set();\n\n/**\n * SSR adds onload and onerror attributes to catch those events before the hydration.\n * This function detects those cases, removes the attributes and replays the events.\n * @param {HTMLElement} dom\n */\nexport function replay_events(dom) {\n\tif (!hydrating) return;\n\n\tdom.removeAttribute('onload');\n\tdom.removeAttribute('onerror');\n\t// @ts-expect-error\n\tconst event = dom.__e;\n\tif (event !== undefined) {\n\t\t// @ts-expect-error\n\t\tdom.__e = undefined;\n\t\tqueueMicrotask(() => {\n\t\t\tif (dom.isConnected) {\n\t\t\t\tdom.dispatchEvent(event);\n\t\t\t}\n\t\t});\n\t}\n}\n\n/**\n * @param {string} event_name\n * @param {EventTarget} dom\n * @param {EventListener} [handler]\n * @param {AddEventListenerOptions} [options]\n */\nexport function create_event(event_name, dom, handler, options = {}) {\n\t/**\n\t * @this {EventTarget}\n\t */\n\tfunction target_handler(/** @type {Event} */ event) {\n\t\tif (!options.capture) {\n\t\t\t// Only call in the bubble phase, else delegated events would be called before the capturing events\n\t\t\thandle_event_propagation.call(dom, event);\n\t\t}\n\t\tif (!event.cancelBubble) {\n\t\t\treturn without_reactive_context(() => {\n\t\t\t\treturn handler?.call(this, event);\n\t\t\t});\n\t\t}\n\t}\n\n\t// Chrome has a bug where pointer events don't work when attached to a DOM element that has been cloned\n\t// with cloneNode() and the DOM element is disconnected from the document. To ensure the event works, we\n\t// defer the attachment till after it's been appended to the document. TODO: remove this once Chrome fixes\n\t// this bug. The same applies to wheel events and touch events.\n\tif (\n\t\tevent_name.startsWith('pointer') ||\n\t\tevent_name.startsWith('touch') ||\n\t\tevent_name === 'wheel'\n\t) {\n\t\tqueue_micro_task(() => {\n\t\t\tdom.addEventListener(event_name, target_handler, options);\n\t\t});\n\t} else {\n\t\tdom.addEventListener(event_name, target_handler, options);\n\t}\n\n\treturn target_handler;\n}\n\n/**\n * Attaches an event handler to an element and returns a function that removes the handler. Using this\n * rather than `addEventListener` will preserve the correct order relative to handlers added declaratively\n * (with attributes like `onclick`), which use event delegation for performance reasons\n *\n * @param {EventTarget} element\n * @param {string} type\n * @param {EventListener} handler\n * @param {AddEventListenerOptions} [options]\n */\nexport function on(element, type, handler, options = {}) {\n\tvar target_handler = create_event(type, element, handler, options);\n\n\treturn () => {\n\t\telement.removeEventListener(type, target_handler, options);\n\t};\n}\n\n/**\n * @param {string} event_name\n * @param {Element} dom\n * @param {EventListener} [handler]\n * @param {boolean} [capture]\n * @param {boolean} [passive]\n * @returns {void}\n */\nexport function event(event_name, dom, handler, capture, passive) {\n\tvar options = { capture, passive };\n\tvar target_handler = create_event(event_name, dom, handler, options);\n\n\tif (\n\t\tdom === document.body ||\n\t\t// @ts-ignore\n\t\tdom === window ||\n\t\t// @ts-ignore\n\t\tdom === document ||\n\t\t// Firefox has quirky behavior, it can happen that we still get \"canplay\" events when the element is already removed\n\t\tdom instanceof HTMLMediaElement\n\t) {\n\t\tteardown(() => {\n\t\t\tdom.removeEventListener(event_name, target_handler, options);\n\t\t});\n\t}\n}\n\n/**\n * @param {string} event_name\n * @param {Element} element\n * @param {EventListener} [handler]\n * @returns {void}\n */\nexport function delegated(event_name, element, handler) {\n\t// @ts-expect-error\n\t(element[event_symbol] ??= {})[event_name] = handler;\n}\n\n/**\n * @param {Array} events\n * @returns {void}\n */\nexport function delegate(events) {\n\tfor (var i = 0; i < events.length; i++) {\n\t\tall_registered_events.add(events[i]);\n\t}\n\n\tfor (var fn of root_event_handles) {\n\t\tfn(events);\n\t}\n}\n\n// used to store the reference to the currently propagated event\n// to prevent garbage collection between microtasks in Firefox\n// If the event object is GCed too early, the expando __root property\n// set on the event object is lost, causing the event delegation\n// to process the event twice\nlet last_propagated_event = null;\n\n/**\n * @this {EventTarget}\n * @param {Event} event\n * @returns {void}\n */\nexport function handle_event_propagation(event) {\n\tvar handler_element = this;\n\tvar owner_document = /** @type {Node} */ (handler_element).ownerDocument;\n\tvar event_name = event.type;\n\tvar path = event.composedPath?.() || [];\n\tvar current_target = /** @type {null | Element} */ (path[0] || event.target);\n\n\tlast_propagated_event = event;\n\n\t// composedPath contains list of nodes the event has propagated through.\n\t// We check `event_symbol` to skip all nodes below it in case this is a\n\t// parent of the `event_symbol` node, which indicates that there's nested\n\t// mounted apps. In this case we don't want to trigger events multiple times.\n\tvar path_idx = 0;\n\n\t// the `last_propagated_event === event` check is redundant, but\n\t// without it the variable will be DCE'd and things will\n\t// fail mysteriously in Firefox\n\t// @ts-expect-error is added below\n\tvar handled_at = last_propagated_event === event && event[event_symbol];\n\n\tif (handled_at) {\n\t\tvar at_idx = path.indexOf(handled_at);\n\t\tif (\n\t\t\tat_idx !== -1 &&\n\t\t\t(handler_element === document || handler_element === /** @type {any} */ (window))\n\t\t) {\n\t\t\t// This is the fallback document listener or a window listener, but the event was already handled\n\t\t\t// -> ignore, but set handle_at to document/window so that we're resetting the event\n\t\t\t// chain in case someone manually dispatches the same event object again.\n\t\t\t// @ts-expect-error\n\t\t\tevent[event_symbol] = handler_element;\n\t\t\treturn;\n\t\t}\n\n\t\t// We're deliberately not skipping if the index is higher, because\n\t\t// someone could create an event programmatically and emit it multiple times,\n\t\t// in which case we want to handle the whole propagation chain properly each time.\n\t\t// (this will only be a false negative if the event is dispatched multiple times and\n\t\t// the fallback document listener isn't reached in between, but that's super rare)\n\t\tvar handler_idx = path.indexOf(handler_element);\n\t\tif (handler_idx === -1) {\n\t\t\t// handle_idx can theoretically be -1 (happened in some JSDOM testing scenarios with an event listener on the window object)\n\t\t\t// so guard against that, too, and assume that everything was handled at this point.\n\t\t\treturn;\n\t\t}\n\n\t\tif (at_idx <= handler_idx) {\n\t\t\tpath_idx = at_idx;\n\t\t}\n\t}\n\n\tcurrent_target = /** @type {Element} */ (path[path_idx] || event.target);\n\t// there can only be one delegated event per element, and we either already handled the current target,\n\t// or this is the very first target in the chain which has a non-delegated listener, in which case it's safe\n\t// to handle a possible delegated event on it later (through the root delegation listener for example).\n\tif (current_target === handler_element) return;\n\n\t// Proxy currentTarget to correct target\n\tdefine_property(event, 'currentTarget', {\n\t\tconfigurable: true,\n\t\tget() {\n\t\t\treturn current_target || owner_document;\n\t\t}\n\t});\n\n\t// This started because of Chromium issue https://chromestatus.com/feature/5128696823545856,\n\t// where removal or moving of of the DOM can cause sync `blur` events to fire, which can cause logic\n\t// to run inside the current `active_reaction`, which isn't what we want at all. However, on reflection,\n\t// it's probably best that all event handled by Svelte have this behaviour, as we don't really want\n\t// an event handler to run in the context of another reaction or effect.\n\tvar previous_reaction = active_reaction;\n\tvar previous_effect = active_effect;\n\tset_active_reaction(null);\n\tset_active_effect(null);\n\n\ttry {\n\t\t/**\n\t\t * @type {unknown}\n\t\t */\n\t\tvar throw_error;\n\t\t/**\n\t\t * @type {unknown[]}\n\t\t */\n\t\tvar other_errors = [];\n\n\t\twhile (current_target !== null) {\n\t\t\t/** @type {null | Element} */\n\t\t\tvar parent_element =\n\t\t\t\tcurrent_target.assignedSlot ||\n\t\t\t\tcurrent_target.parentNode ||\n\t\t\t\t/** @type {any} */ (current_target).host ||\n\t\t\t\tnull;\n\n\t\t\ttry {\n\t\t\t\t// @ts-expect-error\n\t\t\t\tvar delegated = current_target[event_symbol]?.[event_name];\n\n\t\t\t\tif (\n\t\t\t\t\tdelegated != null &&\n\t\t\t\t\t(!(/** @type {any} */ (current_target).disabled) ||\n\t\t\t\t\t\t// DOM could've been updated already by the time this is reached, so we check this as well\n\t\t\t\t\t\t// -> the target could not have been disabled because it emits the event in the first place\n\t\t\t\t\t\tevent.target === current_target)\n\t\t\t\t) {\n\t\t\t\t\tdelegated.call(current_target, event);\n\t\t\t\t}\n\t\t\t} catch (error) {\n\t\t\t\tif (throw_error) {\n\t\t\t\t\tother_errors.push(error);\n\t\t\t\t} else {\n\t\t\t\t\tthrow_error = error;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (event.cancelBubble || parent_element === handler_element || parent_element === null) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcurrent_target = parent_element;\n\t\t}\n\n\t\tif (throw_error) {\n\t\t\tfor (let error of other_errors) {\n\t\t\t\t// Throw the rest of the errors, one-by-one on a microtask\n\t\t\t\tqueueMicrotask(() => {\n\t\t\t\t\tthrow error;\n\t\t\t\t});\n\t\t\t}\n\t\t\tthrow throw_error;\n\t\t}\n\t} finally {\n\t\t// @ts-expect-error is used above\n\t\tevent[event_symbol] = handler_element;\n\t\t// @ts-ignore remove proxy on currentTarget\n\t\tdelete event.currentTarget;\n\t\tset_active_reaction(previous_reaction);\n\t\tset_active_effect(previous_effect);\n\t}\n}\n\n/**\n * In dev, warn if an event handler is not a function, as it means the\n * user probably called the handler or forgot to add a `() =>`\n * @param {() => (event: Event, ...args: any) => void} thunk\n * @param {EventTarget} element\n * @param {[Event, ...any]} args\n * @param {any} component\n * @param {[number, number]} [loc]\n * @param {boolean} [remove_parens]\n */\nexport function apply(\n\tthunk,\n\telement,\n\targs,\n\tcomponent,\n\tloc,\n\thas_side_effects = false,\n\tremove_parens = false\n) {\n\tlet handler;\n\tlet error;\n\n\ttry {\n\t\thandler = thunk();\n\t} catch (e) {\n\t\terror = e;\n\t}\n\n\tif (typeof handler !== 'function' && (has_side_effects || handler != null || error)) {\n\t\tconst filename = component?.[FILENAME];\n\t\tconst location = loc ? ` at ${filename}:${loc[0]}:${loc[1]}` : ` in ${filename}`;\n\t\tconst phase = args[0]?.eventPhase < Event.BUBBLING_PHASE ? 'capture' : '';\n\t\tconst event_name = args[0]?.type + phase;\n\t\tconst description = `\\`${event_name}\\` handler${location}`;\n\t\tconst suggestion = remove_parens ? 'remove the trailing `()`' : 'add a leading `() =>`';\n\n\t\tw.event_handler_invalid(description, suggestion);\n\n\t\tif (error) {\n\t\t\tthrow error;\n\t\t}\n\t}\n\thandler?.apply(element, args);\n}\n","import { create_element } from './operations.js';\n\nconst policy =\n\t// We gotta write it like this because after downleveling the pure comment may end up in the wrong location\n\tglobalThis?.window?.trustedTypes &&\n\t/* @__PURE__ */ globalThis.window.trustedTypes.createPolicy('svelte-trusted-html', {\n\t\t/** @param {string} html */\n\t\tcreateHTML: (html) => {\n\t\t\treturn html;\n\t\t}\n\t});\n\n/** @param {string} html */\nexport function create_trusted_html(html) {\n\treturn /** @type {string} */ (policy?.createHTML(html) ?? html);\n}\n\n/**\n * @param {string} html\n */\nexport function create_fragment_from_html(html) {\n\tvar elem = create_element('template');\n\telem.innerHTML = create_trusted_html(html.replaceAll('', '')); // XHTML compliance\n\treturn elem.content;\n}\n","/** @import { Effect, EffectNodes, TemplateNode } from '#client' */\n/** @import { TemplateStructure } from './types' */\nimport { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydration.js';\nimport {\n\tcreate_text,\n\tget_first_child,\n\tget_next_sibling,\n\tis_firefox,\n\tcreate_element,\n\tcreate_fragment,\n\tcreate_comment,\n\tset_attribute,\n\tmerge_text_nodes\n} from './operations.js';\nimport { create_fragment_from_html } from './reconciler.js';\nimport { active_effect } from '../runtime.js';\nimport {\n\tNAMESPACE_MATHML,\n\tNAMESPACE_SVG,\n\tTEMPLATE_FRAGMENT,\n\tTEMPLATE_USE_IMPORT_NODE,\n\tTEMPLATE_USE_MATHML,\n\tTEMPLATE_USE_SVG\n} from '../../../constants.js';\nimport {\n\tCOMMENT_NODE,\n\tDOCUMENT_FRAGMENT_NODE,\n\tIS_XHTML,\n\tREACTION_RAN,\n\tTEXT_NODE\n} from '#client/constants';\n\nconst TEMPLATE_TAG = IS_XHTML ? 'template' : 'TEMPLATE';\nconst SCRIPT_TAG = IS_XHTML ? 'script' : 'SCRIPT';\n\n/**\n * @param {TemplateNode} start\n * @param {TemplateNode | null} end\n */\nexport function assign_nodes(start, end) {\n\tvar effect = /** @type {Effect} */ (active_effect);\n\tif (effect.nodes === null) {\n\t\teffect.nodes = { start, end, a: null, t: null };\n\t}\n}\n\n/**\n * @param {string} content\n * @param {number} flags\n * @returns {() => Node | Node[]}\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function from_html(content, flags) {\n\tvar is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;\n\tvar use_import_node = (flags & TEMPLATE_USE_IMPORT_NODE) !== 0;\n\n\t/** @type {Node} */\n\tvar node;\n\n\t/**\n\t * Whether or not the first item is a text/element node. If not, we need to\n\t * create an additional comment node to act as `effect.nodes.start`\n\t */\n\tvar has_start = !content.startsWith('');\n\n\treturn () => {\n\t\tif (hydrating) {\n\t\t\tassign_nodes(hydrate_node, null);\n\t\t\treturn hydrate_node;\n\t\t}\n\n\t\tif (node === undefined) {\n\t\t\tnode = create_fragment_from_html(has_start ? content : '' + content);\n\t\t\tif (!is_fragment) node = /** @type {TemplateNode} */ (get_first_child(node));\n\t\t}\n\n\t\tvar clone = /** @type {TemplateNode} */ (\n\t\t\tuse_import_node || is_firefox ? document.importNode(node, true) : node.cloneNode(true)\n\t\t);\n\n\t\tif (is_fragment) {\n\t\t\tvar start = /** @type {TemplateNode} */ (get_first_child(clone));\n\t\t\tvar end = /** @type {TemplateNode} */ (clone.lastChild);\n\n\t\t\tassign_nodes(start, end);\n\t\t} else {\n\t\t\tassign_nodes(clone, clone);\n\t\t}\n\n\t\treturn clone;\n\t};\n}\n\n/**\n * @param {string} content\n * @param {number} flags\n * @param {'svg' | 'math'} ns\n * @returns {() => Node | Node[]}\n */\n/*#__NO_SIDE_EFFECTS__*/\nfunction from_namespace(content, flags, ns = 'svg') {\n\t/**\n\t * Whether or not the first item is a text/element node. If not, we need to\n\t * create an additional comment node to act as `effect.nodes.start`\n\t */\n\tvar has_start = !content.startsWith('');\n\n\tvar is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;\n\tvar wrapped = `<${ns}>${has_start ? content : '' + content}`;\n\n\t/** @type {Element | DocumentFragment} */\n\tvar node;\n\n\treturn () => {\n\t\tif (hydrating) {\n\t\t\tassign_nodes(hydrate_node, null);\n\t\t\treturn hydrate_node;\n\t\t}\n\n\t\tif (!node) {\n\t\t\tvar fragment = /** @type {DocumentFragment} */ (create_fragment_from_html(wrapped));\n\t\t\tvar root = /** @type {Element} */ (get_first_child(fragment));\n\n\t\t\tif (is_fragment) {\n\t\t\t\tnode = document.createDocumentFragment();\n\t\t\t\twhile (get_first_child(root)) {\n\t\t\t\t\tnode.appendChild(/** @type {TemplateNode} */ (get_first_child(root)));\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tnode = /** @type {Element} */ (get_first_child(root));\n\t\t\t}\n\t\t}\n\n\t\tvar clone = /** @type {TemplateNode} */ (node.cloneNode(true));\n\n\t\tif (is_fragment) {\n\t\t\tvar start = /** @type {TemplateNode} */ (get_first_child(clone));\n\t\t\tvar end = /** @type {TemplateNode} */ (clone.lastChild);\n\n\t\t\tassign_nodes(start, end);\n\t\t} else {\n\t\t\tassign_nodes(clone, clone);\n\t\t}\n\n\t\treturn clone;\n\t};\n}\n\n/**\n * @param {string} content\n * @param {number} flags\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function from_svg(content, flags) {\n\treturn from_namespace(content, flags, 'svg');\n}\n\n/**\n * @param {string} content\n * @param {number} flags\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function from_mathml(content, flags) {\n\treturn from_namespace(content, flags, 'math');\n}\n\n/**\n * @param {TemplateStructure[]} structure\n * @param {typeof NAMESPACE_SVG | typeof NAMESPACE_MATHML | undefined} [ns]\n */\nfunction fragment_from_tree(structure, ns) {\n\tvar fragment = create_fragment();\n\n\tfor (var item of structure) {\n\t\tif (typeof item === 'string') {\n\t\t\tfragment.append(create_text(item));\n\t\t\tcontinue;\n\t\t}\n\n\t\t// if `preserveComments === true`, comments are represented as `['// ']`\n\t\tif (item === undefined || item[0][0] === '/') {\n\t\t\tfragment.append(create_comment(item ? item[0].slice(3) : ''));\n\t\t\tcontinue;\n\t\t}\n\n\t\tconst [name, attributes, ...children] = item;\n\n\t\tconst namespace = name === 'svg' ? NAMESPACE_SVG : name === 'math' ? NAMESPACE_MATHML : ns;\n\n\t\tvar element = create_element(name, namespace, attributes?.is);\n\n\t\tfor (var key in attributes) {\n\t\t\tset_attribute(element, key, attributes[key]);\n\t\t}\n\n\t\tif (children.length > 0) {\n\t\t\tvar target =\n\t\t\t\telement.nodeName === TEMPLATE_TAG\n\t\t\t\t\t? /** @type {HTMLTemplateElement} */ (element).content\n\t\t\t\t\t: element;\n\n\t\t\ttarget.append(\n\t\t\t\tfragment_from_tree(children, element.nodeName === 'foreignObject' ? undefined : namespace)\n\t\t\t);\n\t\t}\n\n\t\tfragment.append(element);\n\t}\n\n\treturn fragment;\n}\n\n/**\n * @param {TemplateStructure[]} structure\n * @param {number} flags\n * @returns {() => Node | Node[]}\n */\n/*#__NO_SIDE_EFFECTS__*/\nexport function from_tree(structure, flags) {\n\tvar is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;\n\tvar use_import_node = (flags & TEMPLATE_USE_IMPORT_NODE) !== 0;\n\n\t/** @type {Node} */\n\tvar node;\n\n\treturn () => {\n\t\tif (hydrating) {\n\t\t\tassign_nodes(hydrate_node, null);\n\t\t\treturn hydrate_node;\n\t\t}\n\n\t\tif (node === undefined) {\n\t\t\tconst ns =\n\t\t\t\t(flags & TEMPLATE_USE_SVG) !== 0\n\t\t\t\t\t? NAMESPACE_SVG\n\t\t\t\t\t: (flags & TEMPLATE_USE_MATHML) !== 0\n\t\t\t\t\t\t? NAMESPACE_MATHML\n\t\t\t\t\t\t: undefined;\n\n\t\t\tnode = fragment_from_tree(structure, ns);\n\t\t\tif (!is_fragment) node = /** @type {TemplateNode} */ (get_first_child(node));\n\t\t}\n\n\t\tvar clone = /** @type {TemplateNode} */ (\n\t\t\tuse_import_node || is_firefox ? document.importNode(node, true) : node.cloneNode(true)\n\t\t);\n\n\t\tif (is_fragment) {\n\t\t\tvar start = /** @type {TemplateNode} */ (get_first_child(clone));\n\t\t\tvar end = /** @type {TemplateNode} */ (clone.lastChild);\n\n\t\t\tassign_nodes(start, end);\n\t\t} else {\n\t\t\tassign_nodes(clone, clone);\n\t\t}\n\n\t\treturn clone;\n\t};\n}\n\n/**\n * @param {() => Element | DocumentFragment} fn\n */\nexport function with_script(fn) {\n\treturn () => run_scripts(fn());\n}\n\n/**\n * Creating a document fragment from HTML that contains script tags will not execute\n * the scripts. We need to replace the script tags with new ones so that they are executed.\n * @param {Element | DocumentFragment} node\n * @returns {Node | Node[]}\n */\nfunction run_scripts(node) {\n\t// scripts were SSR'd, in which case they will run\n\tif (hydrating) return node;\n\n\tconst is_fragment = node.nodeType === DOCUMENT_FRAGMENT_NODE;\n\tconst scripts =\n\t\t/** @type {HTMLElement} */ (node).nodeName === SCRIPT_TAG\n\t\t\t? [/** @type {HTMLScriptElement} */ (node)]\n\t\t\t: node.querySelectorAll('script');\n\n\tconst effect = /** @type {Effect & { nodes: EffectNodes }} */ (active_effect);\n\n\tfor (const script of scripts) {\n\t\tconst clone = create_element('script');\n\t\tfor (var attribute of script.attributes) {\n\t\t\tclone.setAttribute(attribute.name, attribute.value);\n\t\t}\n\n\t\tclone.textContent = script.textContent;\n\n\t\t// The script has changed - if it's at the edges, the effect now points at dead nodes\n\t\tif (is_fragment ? node.firstChild === script : node === script) {\n\t\t\teffect.nodes.start = clone;\n\t\t}\n\t\tif (is_fragment ? node.lastChild === script : node === script) {\n\t\t\teffect.nodes.end = clone;\n\t\t}\n\n\t\tscript.replaceWith(clone);\n\t}\n\treturn node;\n}\n\n/**\n * Don't mark this as side-effect-free, hydration needs to walk all nodes\n * @param {any} value\n */\nexport function text(value = '') {\n\tif (!hydrating) {\n\t\tvar t = create_text(value + '');\n\t\tassign_nodes(t, t);\n\t\treturn t;\n\t}\n\n\tvar node = hydrate_node;\n\n\tif (node.nodeType !== TEXT_NODE) {\n\t\t// if an {expression} is empty during SSR, we need to insert an empty text node\n\t\tnode.before((node = create_text()));\n\t\tset_hydrate_node(node);\n\t} else {\n\t\tmerge_text_nodes(/** @type {Text} */ (node));\n\t}\n\n\tassign_nodes(node, node);\n\treturn node;\n}\n\n/**\n * @returns {TemplateNode | DocumentFragment}\n */\nexport function comment() {\n\t// we're not delegating to `template` here for performance reasons\n\tif (hydrating) {\n\t\tassign_nodes(hydrate_node, null);\n\t\treturn hydrate_node;\n\t}\n\n\tvar frag = document.createDocumentFragment();\n\tvar start = document.createComment('');\n\tvar anchor = create_text();\n\tfrag.append(start, anchor);\n\n\tassign_nodes(start, anchor);\n\n\treturn frag;\n}\n\n/**\n * Assign the created (or in hydration mode, traversed) dom elements to the current block\n * and insert the elements into the dom (in client mode).\n * @param {Text | Comment | Element} anchor\n * @param {DocumentFragment | Element} dom\n */\nexport function append(anchor, dom) {\n\tif (hydrating) {\n\t\tvar effect = /** @type {Effect & { nodes: EffectNodes }} */ (active_effect);\n\n\t\t// When hydrating and outer component and an inner component is async, i.e. blocked on a promise,\n\t\t// then by the time the inner resolves we have already advanced to the end of the hydrated nodes\n\t\t// of the parent component. Check for defined for that reason to avoid rewinding the parent's end marker.\n\t\tif ((effect.f & REACTION_RAN) === 0 || effect.nodes.end === null) {\n\t\t\teffect.nodes.end = hydrate_node;\n\t\t}\n\n\t\thydrate_next();\n\t\treturn;\n\t}\n\n\tif (anchor === null) {\n\t\t// edge case — void `` with content\n\t\treturn;\n\t}\n\n\tanchor.before(/** @type {Node} */ (dom));\n}\n\n/**\n * Create (or hydrate) an unique UID for the component instance.\n */\nexport function props_id() {\n\tif (\n\t\thydrating &&\n\t\thydrate_node &&\n\t\thydrate_node.nodeType === COMMENT_NODE &&\n\t\thydrate_node.textContent?.startsWith(`$`)\n\t) {\n\t\tconst id = hydrate_node.textContent.substring(1);\n\t\thydrate_next();\n\t\treturn id;\n\t}\n\n\t// @ts-expect-error This way we ensure the id is unique even across Svelte runtimes\n\t(window.__svelte ??= {}).uid ??= 1;\n\n\t// @ts-expect-error\n\treturn `c${window.__svelte.uid++}`;\n}\n","/** @import { ComponentContext, Effect, EffectNodes, TemplateNode } from '#client' */\n/** @import { Component, ComponentType, SvelteComponent, MountOptions } from '../../index.js' */\nimport { DEV } from 'esm-env';\nimport {\n\tclear_text_content,\n\tcreate_text,\n\tget_first_child,\n\tget_next_sibling,\n\tinit_operations\n} from './dom/operations.js';\nimport { HYDRATION_END, HYDRATION_ERROR, HYDRATION_START } from '../../constants.js';\nimport { active_effect } from './runtime.js';\nimport { push, pop, component_context } from './context.js';\nimport { component_root } from './reactivity/effects.js';\nimport { hydrate_node, hydrating, set_hydrate_node, set_hydrating } from './dom/hydration.js';\nimport { array_from } from '../shared/utils.js';\nimport {\n\tall_registered_events,\n\thandle_event_propagation,\n\troot_event_handles\n} from './dom/elements/events.js';\nimport * as w from './warnings.js';\nimport * as e from './errors.js';\nimport { assign_nodes } from './dom/template.js';\nimport { is_passive_event } from '../../utils.js';\nimport { COMMENT_NODE, STATE_SYMBOL } from './constants.js';\nimport { boundary } from './dom/blocks/boundary.js';\n\n/**\n * This is normally true — block effects should run their intro transitions —\n * but is false during hydration (unless `options.intro` is `true`) and\n * when creating the children of a `` that just changed tag\n */\nexport let should_intro = true;\n\n/** @param {boolean} value */\nexport function set_should_intro(value) {\n\tshould_intro = value;\n}\n\n/**\n * @param {Element} text\n * @param {string} value\n * @returns {void}\n */\nexport function set_text(text, value) {\n\t// For objects, we apply string coercion (which might make things like $state array references in the template reactive) before diffing\n\tvar str = value == null ? '' : typeof value === 'object' ? `${value}` : value;\n\t// @ts-expect-error\n\tif (str !== (text.__t ??= text.nodeValue)) {\n\t\t// @ts-expect-error\n\t\ttext.__t = str;\n\t\ttext.nodeValue = `${str}`;\n\t}\n}\n\n/**\n * Mounts a component to the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component.\n * Transitions will play during the initial render unless the `intro` option is set to `false`.\n *\n * @template {Record} Props\n * @template {Record} Exports\n * @param {ComponentType> | Component} component\n * @param {MountOptions} options\n * @returns {Exports}\n */\nexport function mount(component, options) {\n\treturn _mount(component, options);\n}\n\n/**\n * Hydrates a component on the given target and returns the exports and potentially the props (if compiled with `accessors: true`) of the component\n *\n * @template {Record} Props\n * @template {Record} Exports\n * @param {ComponentType> | Component} component\n * @param {{} extends Props ? {\n * \t\ttarget: Document | Element | ShadowRoot;\n * \t\tprops?: Props;\n * \t\tevents?: Record any>;\n * \tcontext?: Map;\n * \t\tintro?: boolean;\n * \t\trecover?: boolean;\n *\t\ttransformError?: (error: unknown) => unknown;\n * \t} : {\n * \t\ttarget: Document | Element | ShadowRoot;\n * \t\tprops: Props;\n * \t\tevents?: Record any>;\n * \tcontext?: Map;\n * \t\tintro?: boolean;\n * \t\trecover?: boolean;\n *\t\ttransformError?: (error: unknown) => unknown;\n * \t}} options\n * @returns {Exports}\n */\nexport function hydrate(component, options) {\n\tinit_operations();\n\toptions.intro = options.intro ?? false;\n\tconst target = options.target;\n\tconst was_hydrating = hydrating;\n\tconst previous_hydrate_node = hydrate_node;\n\n\ttry {\n\t\tvar anchor = get_first_child(target);\n\n\t\twhile (\n\t\t\tanchor &&\n\t\t\t(anchor.nodeType !== COMMENT_NODE || /** @type {Comment} */ (anchor).data !== HYDRATION_START)\n\t\t) {\n\t\t\tanchor = get_next_sibling(anchor);\n\t\t}\n\n\t\tif (!anchor) {\n\t\t\tthrow HYDRATION_ERROR;\n\t\t}\n\n\t\tset_hydrating(true);\n\t\tset_hydrate_node(/** @type {Comment} */ (anchor));\n\n\t\tconst instance = _mount(component, { ...options, anchor });\n\n\t\tset_hydrating(false);\n\n\t\treturn /** @type {Exports} */ (instance);\n\t} catch (error) {\n\t\t// re-throw Svelte errors - they are certainly not related to hydration\n\t\tif (\n\t\t\terror instanceof Error &&\n\t\t\terror.message.split('\\n').some((line) => line.startsWith('https://svelte.dev/e/'))\n\t\t) {\n\t\t\tthrow error;\n\t\t}\n\t\tif (error !== HYDRATION_ERROR) {\n\t\t\t// eslint-disable-next-line no-console\n\t\t\tconsole.warn('Failed to hydrate: ', error);\n\t\t}\n\n\t\tif (options.recover === false) {\n\t\t\te.hydration_failed();\n\t\t}\n\n\t\t// If an error occurred above, the operations might not yet have been initialised.\n\t\tinit_operations();\n\t\tclear_text_content(target);\n\n\t\tset_hydrating(false);\n\t\treturn mount(component, options);\n\t} finally {\n\t\tset_hydrating(was_hydrating);\n\t\tset_hydrate_node(previous_hydrate_node);\n\t}\n}\n\n/** @type {Map>} */\nconst listeners = new Map();\n\n/**\n * @template {Record} Exports\n * @param {ComponentType> | Component} Component\n * @param {MountOptions} options\n * @returns {Exports}\n */\nfunction _mount(\n\tComponent,\n\t{ target, anchor, props = {}, events, context, intro = true, transformError }\n) {\n\tinit_operations();\n\n\t/** @type {Exports} */\n\t// @ts-expect-error will be defined because the render effect runs synchronously\n\tvar component = undefined;\n\n\tvar unmount = component_root(() => {\n\t\tvar anchor_node = anchor ?? target.appendChild(create_text());\n\n\t\tboundary(\n\t\t\t/** @type {TemplateNode} */ (anchor_node),\n\t\t\t{\n\t\t\t\tpending: () => {}\n\t\t\t},\n\t\t\t(anchor_node) => {\n\t\t\t\tpush({});\n\t\t\t\tvar ctx = /** @type {ComponentContext} */ (component_context);\n\t\t\t\tif (context) ctx.c = context;\n\n\t\t\t\tif (events) {\n\t\t\t\t\t// We can't spread the object or else we'd lose the state proxy stuff, if it is one\n\t\t\t\t\t/** @type {any} */ (props).$$events = events;\n\t\t\t\t}\n\n\t\t\t\tif (hydrating) {\n\t\t\t\t\tassign_nodes(/** @type {TemplateNode} */ (anchor_node), null);\n\t\t\t\t}\n\n\t\t\t\tshould_intro = intro;\n\t\t\t\t// @ts-expect-error the public typings are not what the actual function looks like\n\t\t\t\tcomponent = Component(anchor_node, props) || {};\n\t\t\t\tshould_intro = true;\n\n\t\t\t\tif (hydrating) {\n\t\t\t\t\t/** @type {Effect & { nodes: EffectNodes }} */ (active_effect).nodes.end = hydrate_node;\n\n\t\t\t\t\tif (\n\t\t\t\t\t\thydrate_node === null ||\n\t\t\t\t\t\thydrate_node.nodeType !== COMMENT_NODE ||\n\t\t\t\t\t\t/** @type {Comment} */ (hydrate_node).data !== HYDRATION_END\n\t\t\t\t\t) {\n\t\t\t\t\t\tw.hydration_mismatch();\n\t\t\t\t\t\tthrow HYDRATION_ERROR;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tpop();\n\t\t\t},\n\t\t\ttransformError\n\t\t);\n\n\t\t// Setup event delegation _after_ component is mounted - if an error would happen during mount, it would otherwise not be cleaned up\n\t\t/** @type {Set} */\n\t\tvar registered_events = new Set();\n\n\t\t/** @param {Array} events */\n\t\tvar event_handle = (events) => {\n\t\t\tfor (var i = 0; i < events.length; i++) {\n\t\t\t\tvar event_name = events[i];\n\n\t\t\t\tif (registered_events.has(event_name)) continue;\n\t\t\t\tregistered_events.add(event_name);\n\n\t\t\t\tvar passive = is_passive_event(event_name);\n\n\t\t\t\t// Add the event listener to both the container and the document.\n\t\t\t\t// The container listener ensures we catch events from within in case\n\t\t\t\t// the outer content stops propagation of the event.\n\t\t\t\t//\n\t\t\t\t// The document listener ensures we catch events that originate from elements that were\n\t\t\t\t// manually moved outside of the container (e.g. via manual portals).\n\t\t\t\tfor (const node of [target, document]) {\n\t\t\t\t\tvar counts = listeners.get(node);\n\n\t\t\t\t\tif (counts === undefined) {\n\t\t\t\t\t\tcounts = new Map();\n\t\t\t\t\t\tlisteners.set(node, counts);\n\t\t\t\t\t}\n\n\t\t\t\t\tvar count = counts.get(event_name);\n\n\t\t\t\t\tif (count === undefined) {\n\t\t\t\t\t\tnode.addEventListener(event_name, handle_event_propagation, { passive });\n\t\t\t\t\t\tcounts.set(event_name, 1);\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcounts.set(event_name, count + 1);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\tevent_handle(array_from(all_registered_events));\n\t\troot_event_handles.add(event_handle);\n\n\t\treturn () => {\n\t\t\tfor (var event_name of registered_events) {\n\t\t\t\tfor (const node of [target, document]) {\n\t\t\t\t\tvar counts = /** @type {Map} */ (listeners.get(node));\n\t\t\t\t\tvar count = /** @type {number} */ (counts.get(event_name));\n\n\t\t\t\t\tif (--count == 0) {\n\t\t\t\t\t\tnode.removeEventListener(event_name, handle_event_propagation);\n\t\t\t\t\t\tcounts.delete(event_name);\n\n\t\t\t\t\t\tif (counts.size === 0) {\n\t\t\t\t\t\t\tlisteners.delete(node);\n\t\t\t\t\t\t}\n\t\t\t\t\t} else {\n\t\t\t\t\t\tcounts.set(event_name, count);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\troot_event_handles.delete(event_handle);\n\n\t\t\tif (anchor_node !== anchor) {\n\t\t\t\tanchor_node.parentNode?.removeChild(anchor_node);\n\t\t\t}\n\t\t};\n\t});\n\n\tmounted_components.set(component, unmount);\n\treturn component;\n}\n\n/**\n * References of the components that were mounted or hydrated.\n * Uses a `WeakMap` to avoid memory leaks.\n */\nlet mounted_components = new WeakMap();\n\n/**\n * Unmounts a component that was previously mounted using `mount` or `hydrate`.\n *\n * Since 5.13.0, if `options.outro` is `true`, [transitions](https://svelte.dev/docs/svelte/transition) will play before the component is removed from the DOM.\n *\n * Returns a `Promise` that resolves after transitions have completed if `options.outro` is true, or immediately otherwise (prior to 5.13.0, returns `void`).\n *\n * ```js\n * import { mount, unmount } from 'svelte';\n * import App from './App.svelte';\n *\n * const app = mount(App, { target: document.body });\n *\n * // later...\n * unmount(app, { outro: true });\n * ```\n * @param {Record} component\n * @param {{ outro?: boolean }} [options]\n * @returns {Promise}\n */\nexport function unmount(component, options) {\n\tconst fn = mounted_components.get(component);\n\n\tif (fn) {\n\t\tmounted_components.delete(component);\n\t\treturn fn(options);\n\t}\n\n\tif (DEV) {\n\t\tif (STATE_SYMBOL in component) {\n\t\t\tw.state_proxy_unmount();\n\t\t} else {\n\t\t\tw.lifecycle_double_unmount();\n\t\t}\n\t}\n\n\treturn Promise.resolve();\n}\n","/** @import { Effect, TemplateNode } from '#client' */\nimport { Batch, current_batch } from '../../reactivity/batch.js';\nimport {\n\tbranch,\n\tdestroy_effect,\n\tmove_effect,\n\tpause_effect,\n\tresume_effect\n} from '../../reactivity/effects.js';\nimport { hydrate_node, hydrating } from '../hydration.js';\nimport { create_text, should_defer_append } from '../operations.js';\n\n/**\n * @typedef {{ effect: Effect, fragment: DocumentFragment }} Branch\n */\n\n/**\n * @template Key\n */\nexport class BranchManager {\n\t/** @type {TemplateNode} */\n\tanchor;\n\n\t/** @type {Map} */\n\t#batches = new Map();\n\n\t/**\n\t * Map of keys to effects that are currently rendered in the DOM.\n\t * These effects are visible and actively part of the document tree.\n\t * Example:\n\t * ```\n\t * {#if condition}\n\t * \tfoo\n\t * {:else}\n\t * \tbar\n\t * {/if}\n\t * ```\n\t * Can result in the entries `true->Effect` and `false->Effect`\n\t * @type {Map}\n\t */\n\t#onscreen = new Map();\n\n\t/**\n\t * Similar to #onscreen with respect to the keys, but contains branches that are not yet\n\t * in the DOM, because their insertion is deferred.\n\t * @type {Map}\n\t */\n\t#offscreen = new Map();\n\n\t/**\n\t * Keys of effects that are currently outroing\n\t * @type {Set}\n\t */\n\t#outroing = new Set();\n\n\t/**\n\t * Whether to pause (i.e. outro) on change, or destroy immediately.\n\t * This is necessary for ``\n\t */\n\t#transition = true;\n\n\t/**\n\t * @param {TemplateNode} anchor\n\t * @param {boolean} transition\n\t */\n\tconstructor(anchor, transition = true) {\n\t\tthis.anchor = anchor;\n\t\tthis.#transition = transition;\n\t}\n\n\t/**\n\t * @param {Batch} batch\n\t */\n\t#commit = (batch) => {\n\t\t// if this batch was made obsolete, bail\n\t\tif (!this.#batches.has(batch)) return;\n\n\t\tvar key = /** @type {Key} */ (this.#batches.get(batch));\n\n\t\tvar onscreen = this.#onscreen.get(key);\n\n\t\tif (onscreen) {\n\t\t\t// effect is already in the DOM — abort any current outro\n\t\t\tresume_effect(onscreen);\n\t\t\tthis.#outroing.delete(key);\n\t\t} else {\n\t\t\t// effect is currently offscreen. put it in the DOM\n\t\t\tvar offscreen = this.#offscreen.get(key);\n\n\t\t\tif (offscreen) {\n\t\t\t\tthis.#onscreen.set(key, offscreen.effect);\n\t\t\t\tthis.#offscreen.delete(key);\n\n\t\t\t\t// remove the anchor...\n\t\t\t\t/** @type {TemplateNode} */ (offscreen.fragment.lastChild).remove();\n\n\t\t\t\t// ...and append the fragment\n\t\t\t\tthis.anchor.before(offscreen.fragment);\n\t\t\t\tonscreen = offscreen.effect;\n\t\t\t}\n\t\t}\n\n\t\tfor (const [b, k] of this.#batches) {\n\t\t\tthis.#batches.delete(b);\n\n\t\t\tif (b === batch) {\n\t\t\t\t// keep values for newer batches\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tconst offscreen = this.#offscreen.get(k);\n\n\t\t\tif (offscreen) {\n\t\t\t\t// for older batches, destroy offscreen effects\n\t\t\t\t// as they will never be committed\n\t\t\t\tdestroy_effect(offscreen.effect);\n\t\t\t\tthis.#offscreen.delete(k);\n\t\t\t}\n\t\t}\n\n\t\t// outro/destroy all onscreen effects...\n\t\tfor (const [k, effect] of this.#onscreen) {\n\t\t\t// ...except the one that was just committed\n\t\t\t// or those that are already outroing (else the transition is aborted and the effect destroyed right away)\n\t\t\tif (k === key || this.#outroing.has(k)) continue;\n\n\t\t\tconst on_destroy = () => {\n\t\t\t\tconst keys = Array.from(this.#batches.values());\n\n\t\t\t\tif (keys.includes(k)) {\n\t\t\t\t\t// keep the effect offscreen, as another batch will need it\n\t\t\t\t\tvar fragment = document.createDocumentFragment();\n\t\t\t\t\tmove_effect(effect, fragment);\n\n\t\t\t\t\tfragment.append(create_text()); // TODO can we avoid this?\n\n\t\t\t\t\tthis.#offscreen.set(k, { effect, fragment });\n\t\t\t\t} else {\n\t\t\t\t\tdestroy_effect(effect);\n\t\t\t\t}\n\n\t\t\t\tthis.#outroing.delete(k);\n\t\t\t\tthis.#onscreen.delete(k);\n\t\t\t};\n\n\t\t\tif (this.#transition || !onscreen) {\n\t\t\t\tthis.#outroing.add(k);\n\t\t\t\tpause_effect(effect, on_destroy, false);\n\t\t\t} else {\n\t\t\t\ton_destroy();\n\t\t\t}\n\t\t}\n\t};\n\n\t/**\n\t * @param {Batch} batch\n\t */\n\t#discard = (batch) => {\n\t\tthis.#batches.delete(batch);\n\n\t\tconst keys = Array.from(this.#batches.values());\n\n\t\tfor (const [k, branch] of this.#offscreen) {\n\t\t\tif (!keys.includes(k)) {\n\t\t\t\tdestroy_effect(branch.effect);\n\t\t\t\tthis.#offscreen.delete(k);\n\t\t\t}\n\t\t}\n\t};\n\n\t/**\n\t *\n\t * @param {any} key\n\t * @param {null | ((target: TemplateNode) => void)} fn\n\t */\n\tensure(key, fn) {\n\t\tvar batch = /** @type {Batch} */ (current_batch);\n\t\tvar defer = should_defer_append();\n\n\t\tif (fn && !this.#onscreen.has(key) && !this.#offscreen.has(key)) {\n\t\t\tif (defer) {\n\t\t\t\tvar fragment = document.createDocumentFragment();\n\t\t\t\tvar target = create_text();\n\n\t\t\t\tfragment.append(target);\n\n\t\t\t\tthis.#offscreen.set(key, {\n\t\t\t\t\teffect: branch(() => fn(target)),\n\t\t\t\t\tfragment\n\t\t\t\t});\n\t\t\t} else {\n\t\t\t\tthis.#onscreen.set(\n\t\t\t\t\tkey,\n\t\t\t\t\tbranch(() => fn(this.anchor))\n\t\t\t\t);\n\t\t\t}\n\t\t}\n\n\t\tthis.#batches.set(batch, key);\n\n\t\tif (defer) {\n\t\t\tfor (const [k, effect] of this.#onscreen) {\n\t\t\t\tif (k === key) {\n\t\t\t\t\tbatch.unskip_effect(effect);\n\t\t\t\t} else {\n\t\t\t\t\tbatch.skip_effect(effect);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor (const [k, branch] of this.#offscreen) {\n\t\t\t\tif (k === key) {\n\t\t\t\t\tbatch.unskip_effect(branch.effect);\n\t\t\t\t} else {\n\t\t\t\t\tbatch.skip_effect(branch.effect);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tbatch.oncommit(this.#commit);\n\t\t\tbatch.ondiscard(this.#discard);\n\t\t} else {\n\t\t\tif (hydrating) {\n\t\t\t\tthis.anchor = hydrate_node;\n\t\t\t}\n\n\t\t\tthis.#commit(batch);\n\t\t}\n\t}\n}\n","/** @import { TemplateNode } from '#client' */\nimport { EFFECT_TRANSPARENT } from '#client/constants';\nimport {\n\thydrate_next,\n\thydrating,\n\tread_hydration_instruction,\n\tskip_nodes,\n\tset_hydrate_node,\n\tset_hydrating,\n\thydrate_node\n} from '../hydration.js';\nimport { block } from '../../reactivity/effects.js';\nimport { BranchManager } from './branches.js';\n\n/**\n * @param {TemplateNode} node\n * @param {(branch: (fn: (anchor: Node) => void, key?: number | false) => void) => void} fn\n * @param {boolean} [elseif] True if this is an `{:else if ...}` block rather than an `{#if ...}`, as that affects which transitions are considered 'local'\n * @returns {void}\n */\nexport function if_block(node, fn, elseif = false) {\n\t/** @type {TemplateNode | undefined} */\n\tvar marker;\n\tif (hydrating) {\n\t\tmarker = hydrate_node;\n\t\thydrate_next();\n\t}\n\n\tvar branches = new BranchManager(node);\n\tvar flags = elseif ? EFFECT_TRANSPARENT : 0;\n\n\t/**\n\t * @param {number | false} key\n\t * @param {null | ((anchor: Node) => void)} fn\n\t */\n\tfunction update_branch(key, fn) {\n\t\tif (hydrating) {\n\t\t\tvar data = read_hydration_instruction(/** @type {TemplateNode} */ (marker));\n\n\t\t\t// \"[n\" = branch n, \"[-1\" = else\n\t\t\tif (key !== parseInt(data.substring(1))) {\n\t\t\t\t// Hydration mismatch: remove everything inside the anchor and start fresh.\n\t\t\t\t// This could happen with `{#if browser}...{/if}`, for example\n\t\t\t\tvar anchor = skip_nodes();\n\n\t\t\t\tset_hydrate_node(anchor);\n\t\t\t\tbranches.anchor = anchor;\n\n\t\t\t\tset_hydrating(false);\n\t\t\t\tbranches.ensure(key, fn);\n\t\t\t\tset_hydrating(true);\n\n\t\t\t\treturn;\n\t\t\t}\n\t\t}\n\n\t\tbranches.ensure(key, fn);\n\t}\n\n\tblock(() => {\n\t\tvar has_branch = false;\n\n\t\tfn((fn, key = 0) => {\n\t\t\thas_branch = true;\n\t\t\tupdate_branch(key, fn);\n\t\t});\n\n\t\tif (!has_branch) {\n\t\t\tupdate_branch(-1, null);\n\t\t}\n\t}, flags);\n}\n","/** @import { EachItem, EachOutroGroup, EachState, Effect, EffectNodes, MaybeSource, Source, TemplateNode, TransitionManager, Value } from '#client' */\n/** @import { Batch } from '../../reactivity/batch.js'; */\nimport {\n\tEACH_INDEX_REACTIVE,\n\tEACH_IS_ANIMATED,\n\tEACH_IS_CONTROLLED,\n\tEACH_ITEM_IMMUTABLE,\n\tEACH_ITEM_REACTIVE,\n\tHYDRATION_END,\n\tHYDRATION_START_ELSE\n} from '../../../../constants.js';\nimport {\n\thydrate_next,\n\thydrate_node,\n\thydrating,\n\tread_hydration_instruction,\n\tskip_nodes,\n\tset_hydrate_node,\n\tset_hydrating\n} from '../hydration.js';\nimport {\n\tclear_text_content,\n\tcreate_text,\n\tget_first_child,\n\tget_next_sibling,\n\tshould_defer_append\n} from '../operations.js';\nimport {\n\tblock,\n\tbranch,\n\tdestroy_effect,\n\tmove_effect,\n\tpause_effect,\n\tresume_effect\n} from '../../reactivity/effects.js';\nimport { source, mutable_source, internal_set } from '../../reactivity/sources.js';\nimport { array_from, is_array } from '../../../shared/utils.js';\nimport { BRANCH_EFFECT, COMMENT_NODE, DESTROYED, EFFECT_OFFSCREEN, INERT } from '#client/constants';\nimport { queue_micro_task } from '../task.js';\nimport { get } from '../../runtime.js';\nimport { DEV } from 'esm-env';\nimport { derived_safe_equal } from '../../reactivity/deriveds.js';\nimport { current_batch } from '../../reactivity/batch.js';\nimport * as e from '../../errors.js';\n\n// When making substantive changes to this file, validate them with the each block stress test:\n// https://svelte.dev/playground/1972b2cf46564476ad8c8c6405b23b7b\n// This test also exists in this repo, as `packages/svelte/tests/manual/each-stress-test`\n\n/**\n * @param {any} _\n * @param {number} i\n */\nexport function index(_, i) {\n\treturn i;\n}\n\n/**\n * Pause multiple effects simultaneously, and coordinate their\n * subsequent destruction. Used in each blocks\n * @param {EachState} state\n * @param {Effect[]} to_destroy\n * @param {null | Node} controlled_anchor\n */\nfunction pause_effects(state, to_destroy, controlled_anchor) {\n\t/** @type {TransitionManager[]} */\n\tvar transitions = [];\n\tvar length = to_destroy.length;\n\n\t/** @type {EachOutroGroup} */\n\tvar group;\n\tvar remaining = to_destroy.length;\n\n\tfor (var i = 0; i < length; i++) {\n\t\tlet effect = to_destroy[i];\n\n\t\tpause_effect(\n\t\t\teffect,\n\t\t\t() => {\n\t\t\t\tif (group) {\n\t\t\t\t\tgroup.pending.delete(effect);\n\t\t\t\t\tgroup.done.add(effect);\n\n\t\t\t\t\tif (group.pending.size === 0) {\n\t\t\t\t\t\tvar groups = /** @type {Set} */ (state.outrogroups);\n\n\t\t\t\t\t\tdestroy_effects(state, array_from(group.done));\n\t\t\t\t\t\tgroups.delete(group);\n\n\t\t\t\t\t\tif (groups.size === 0) {\n\t\t\t\t\t\t\tstate.outrogroups = null;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\tremaining -= 1;\n\t\t\t\t}\n\t\t\t},\n\t\t\tfalse\n\t\t);\n\t}\n\n\tif (remaining === 0) {\n\t\t// If we're in a controlled each block (i.e. the block is the only child of an\n\t\t// element), and we are removing all items, _and_ there are no out transitions,\n\t\t// we can use the fast path — emptying the element and replacing the anchor\n\t\tvar fast_path = transitions.length === 0 && controlled_anchor !== null;\n\n\t\tif (fast_path) {\n\t\t\tvar anchor = /** @type {Element} */ (controlled_anchor);\n\t\t\tvar parent_node = /** @type {Element} */ (anchor.parentNode);\n\n\t\t\tclear_text_content(parent_node);\n\t\t\tparent_node.append(anchor);\n\n\t\t\tstate.items.clear();\n\t\t}\n\n\t\tdestroy_effects(state, to_destroy, !fast_path);\n\t} else {\n\t\tgroup = {\n\t\t\tpending: new Set(to_destroy),\n\t\t\tdone: new Set()\n\t\t};\n\n\t\t(state.outrogroups ??= new Set()).add(group);\n\t}\n}\n\n/**\n * @param {EachState} state\n * @param {Effect[]} to_destroy\n * @param {boolean} remove_dom\n */\nfunction destroy_effects(state, to_destroy, remove_dom = true) {\n\t/** @type {Set | undefined} */\n\tvar preserved_effects;\n\n\t// The loop-in-a-loop isn't ideal, but we should only hit this in relatively rare cases\n\tif (state.pending.size > 0) {\n\t\tpreserved_effects = new Set();\n\n\t\tfor (const keys of state.pending.values()) {\n\t\t\tfor (const key of keys) {\n\t\t\t\tpreserved_effects.add(/** @type {EachItem} */ (state.items.get(key)).e);\n\t\t\t}\n\t\t}\n\t}\n\n\tfor (var i = 0; i < to_destroy.length; i++) {\n\t\tvar e = to_destroy[i];\n\n\t\tif (preserved_effects?.has(e)) {\n\t\t\te.f |= EFFECT_OFFSCREEN;\n\n\t\t\tconst fragment = document.createDocumentFragment();\n\t\t\tmove_effect(e, fragment);\n\t\t} else {\n\t\t\tdestroy_effect(to_destroy[i], remove_dom);\n\t\t}\n\t}\n}\n\n/** @type {TemplateNode} */\nvar offscreen_anchor;\n\n/**\n * @template V\n * @param {Element | Comment} node The next sibling node, or the parent node if this is a 'controlled' block\n * @param {number} flags\n * @param {() => V[]} get_collection\n * @param {(value: V, index: number) => any} get_key\n * @param {(anchor: Node, item: MaybeSource, index: MaybeSource) => void} render_fn\n * @param {null | ((anchor: Node) => void)} fallback_fn\n * @returns {void}\n */\nexport function each(node, flags, get_collection, get_key, render_fn, fallback_fn = null) {\n\tvar anchor = node;\n\n\t/** @type {Map} */\n\tvar items = new Map();\n\n\tvar is_controlled = (flags & EACH_IS_CONTROLLED) !== 0;\n\n\tif (is_controlled) {\n\t\tvar parent_node = /** @type {Element} */ (node);\n\n\t\tanchor = hydrating\n\t\t\t? set_hydrate_node(get_first_child(parent_node))\n\t\t\t: parent_node.appendChild(create_text());\n\t}\n\n\tif (hydrating) {\n\t\thydrate_next();\n\t}\n\n\t/** @type {Effect | null} */\n\tvar fallback = null;\n\n\t// TODO: ideally we could use derived for runes mode but because of the ability\n\t// to use a store which can be mutated, we can't do that here as mutating a store\n\t// will still result in the collection array being the same from the store\n\tvar each_array = derived_safe_equal(() => {\n\t\tvar collection = get_collection();\n\n\t\treturn is_array(collection) ? collection : collection == null ? [] : array_from(collection);\n\t});\n\n\t/** @type {V[]} */\n\tvar array;\n\n\t/** @type {Map>} */\n\tvar pending = new Map();\n\n\tvar first_run = true;\n\n\t/**\n\t * @param {Batch} batch\n\t */\n\tfunction commit(batch) {\n\t\tif ((state.effect.f & DESTROYED) !== 0) {\n\t\t\treturn;\n\t\t}\n\n\t\tstate.pending.delete(batch);\n\n\t\tstate.fallback = fallback;\n\t\treconcile(state, array, anchor, flags, get_key);\n\n\t\tif (fallback !== null) {\n\t\t\tif (array.length === 0) {\n\t\t\t\tif ((fallback.f & EFFECT_OFFSCREEN) === 0) {\n\t\t\t\t\tresume_effect(fallback);\n\t\t\t\t} else {\n\t\t\t\t\tfallback.f ^= EFFECT_OFFSCREEN;\n\t\t\t\t\tmove(fallback, null, anchor);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tpause_effect(fallback, () => {\n\t\t\t\t\t// TODO only null out if no pending batch needs it,\n\t\t\t\t\t// otherwise re-add `fallback.fragment` and move the\n\t\t\t\t\t// effect into it\n\t\t\t\t\tfallback = null;\n\t\t\t\t});\n\t\t\t}\n\t\t}\n\t}\n\n\t/**\n\t * @param {Batch} batch\n\t */\n\tfunction discard(batch) {\n\t\tstate.pending.delete(batch);\n\t}\n\n\tvar effect = block(() => {\n\t\tarray = /** @type {V[]} */ (get(each_array));\n\t\tvar length = array.length;\n\n\t\t/** `true` if there was a hydration mismatch. Needs to be a `let` or else it isn't treeshaken out */\n\t\tlet mismatch = false;\n\n\t\tif (hydrating) {\n\t\t\tvar is_else = read_hydration_instruction(anchor) === HYDRATION_START_ELSE;\n\n\t\t\tif (is_else !== (length === 0)) {\n\t\t\t\t// hydration mismatch — remove the server-rendered DOM and start over\n\t\t\t\tanchor = skip_nodes();\n\n\t\t\t\tset_hydrate_node(anchor);\n\t\t\t\tset_hydrating(false);\n\t\t\t\tmismatch = true;\n\t\t\t}\n\t\t}\n\n\t\tvar keys = new Set();\n\t\tvar batch = /** @type {Batch} */ (current_batch);\n\t\tvar defer = should_defer_append();\n\n\t\tfor (var index = 0; index < length; index += 1) {\n\t\t\tif (\n\t\t\t\thydrating &&\n\t\t\t\thydrate_node.nodeType === COMMENT_NODE &&\n\t\t\t\t/** @type {Comment} */ (hydrate_node).data === HYDRATION_END\n\t\t\t) {\n\t\t\t\t// The server rendered fewer items than expected,\n\t\t\t\t// so break out and continue appending non-hydrated items\n\t\t\t\tanchor = /** @type {Comment} */ (hydrate_node);\n\t\t\t\tmismatch = true;\n\t\t\t\tset_hydrating(false);\n\t\t\t}\n\n\t\t\tvar value = array[index];\n\t\t\tvar key = get_key(value, index);\n\n\t\t\tif (DEV) {\n\t\t\t\t// Check that the key function is idempotent (returns the same value when called twice)\n\t\t\t\tvar key_again = get_key(value, index);\n\t\t\t\tif (key !== key_again) {\n\t\t\t\t\te.each_key_volatile(String(index), String(key), String(key_again));\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar item = first_run ? null : items.get(key);\n\n\t\t\tif (item) {\n\t\t\t\t// update before reconciliation, to trigger any async updates\n\t\t\t\tif (item.v) internal_set(item.v, value);\n\t\t\t\tif (item.i) internal_set(item.i, index);\n\n\t\t\t\tif (defer) {\n\t\t\t\t\tbatch.unskip_effect(item.e);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\titem = create_item(\n\t\t\t\t\titems,\n\t\t\t\t\tfirst_run ? anchor : (offscreen_anchor ??= create_text()),\n\t\t\t\t\tvalue,\n\t\t\t\t\tkey,\n\t\t\t\t\tindex,\n\t\t\t\t\trender_fn,\n\t\t\t\t\tflags,\n\t\t\t\t\tget_collection\n\t\t\t\t);\n\n\t\t\t\tif (!first_run) {\n\t\t\t\t\titem.e.f |= EFFECT_OFFSCREEN;\n\t\t\t\t}\n\n\t\t\t\titems.set(key, item);\n\t\t\t}\n\n\t\t\tkeys.add(key);\n\t\t}\n\n\t\tif (length === 0 && fallback_fn && !fallback) {\n\t\t\tif (first_run) {\n\t\t\t\tfallback = branch(() => fallback_fn(anchor));\n\t\t\t} else {\n\t\t\t\tfallback = branch(() => fallback_fn((offscreen_anchor ??= create_text())));\n\t\t\t\tfallback.f |= EFFECT_OFFSCREEN;\n\t\t\t}\n\t\t}\n\n\t\tif (length > keys.size) {\n\t\t\tif (DEV) {\n\t\t\t\tvalidate_each_keys(array, get_key);\n\t\t\t} else {\n\t\t\t\t// in prod, the additional information isn't printed, so don't bother computing it\n\t\t\t\te.each_key_duplicate('', '', '');\n\t\t\t}\n\t\t}\n\n\t\t// remove excess nodes\n\t\tif (hydrating && length > 0) {\n\t\t\tset_hydrate_node(skip_nodes());\n\t\t}\n\n\t\tif (!first_run) {\n\t\t\tpending.set(batch, keys);\n\n\t\t\tif (defer) {\n\t\t\t\tfor (const [key, item] of items) {\n\t\t\t\t\tif (!keys.has(key)) {\n\t\t\t\t\t\tbatch.skip_effect(item.e);\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tbatch.oncommit(commit);\n\t\t\t\tbatch.ondiscard(discard);\n\t\t\t} else {\n\t\t\t\tcommit(batch);\n\t\t\t}\n\t\t}\n\n\t\tif (mismatch) {\n\t\t\t// continue in hydration mode\n\t\t\tset_hydrating(true);\n\t\t}\n\n\t\t// When we mount the each block for the first time, the collection won't be\n\t\t// connected to this effect as the effect hasn't finished running yet and its deps\n\t\t// won't be assigned. However, it's possible that when reconciling the each block\n\t\t// that a mutation occurred and it's made the collection MAYBE_DIRTY, so reading the\n\t\t// collection again can provide consistency to the reactive graph again as the deriveds\n\t\t// will now be `CLEAN`.\n\t\tget(each_array);\n\t});\n\n\t/** @type {EachState} */\n\tvar state = { effect, flags, items, pending, outrogroups: null, fallback };\n\n\tfirst_run = false;\n\n\tif (hydrating) {\n\t\tanchor = hydrate_node;\n\t}\n}\n\n/**\n * Skip past any non-branch effects (which could be created with `createSubscriber`, for example) to find the next branch effect\n * @param {Effect | null} effect\n * @returns {Effect | null}\n */\nfunction skip_to_branch(effect) {\n\twhile (effect !== null && (effect.f & BRANCH_EFFECT) === 0) {\n\t\teffect = effect.next;\n\t}\n\treturn effect;\n}\n\n/**\n * Add, remove, or reorder items output by an each block as its input changes\n * @template V\n * @param {EachState} state\n * @param {Array} array\n * @param {Element | Comment | Text} anchor\n * @param {number} flags\n * @param {(value: V, index: number) => any} get_key\n * @returns {void}\n */\nfunction reconcile(state, array, anchor, flags, get_key) {\n\tvar is_animated = (flags & EACH_IS_ANIMATED) !== 0;\n\n\tvar length = array.length;\n\tvar items = state.items;\n\tvar current = skip_to_branch(state.effect.first);\n\n\t/** @type {undefined | Set} */\n\tvar seen;\n\n\t/** @type {Effect | null} */\n\tvar prev = null;\n\n\t/** @type {undefined | Set} */\n\tvar to_animate;\n\n\t/** @type {Effect[]} */\n\tvar matched = [];\n\n\t/** @type {Effect[]} */\n\tvar stashed = [];\n\n\t/** @type {V} */\n\tvar value;\n\n\t/** @type {any} */\n\tvar key;\n\n\t/** @type {Effect | undefined} */\n\tvar effect;\n\n\t/** @type {number} */\n\tvar i;\n\n\tif (is_animated) {\n\t\tfor (i = 0; i < length; i += 1) {\n\t\t\tvalue = array[i];\n\t\t\tkey = get_key(value, i);\n\t\t\teffect = /** @type {EachItem} */ (items.get(key)).e;\n\n\t\t\t// offscreen == coming in now, no animation in that case,\n\t\t\t// else this would happen https://github.com/sveltejs/svelte/issues/17181\n\t\t\tif ((effect.f & EFFECT_OFFSCREEN) === 0) {\n\t\t\t\teffect.nodes?.a?.measure();\n\t\t\t\t(to_animate ??= new Set()).add(effect);\n\t\t\t}\n\t\t}\n\t}\n\n\tfor (i = 0; i < length; i += 1) {\n\t\tvalue = array[i];\n\t\tkey = get_key(value, i);\n\n\t\teffect = /** @type {EachItem} */ (items.get(key)).e;\n\n\t\tif (state.outrogroups !== null) {\n\t\t\tfor (const group of state.outrogroups) {\n\t\t\t\tgroup.pending.delete(effect);\n\t\t\t\tgroup.done.delete(effect);\n\t\t\t}\n\t\t}\n\n\t\tif ((effect.f & EFFECT_OFFSCREEN) !== 0) {\n\t\t\teffect.f ^= EFFECT_OFFSCREEN;\n\n\t\t\tif (effect === current) {\n\t\t\t\tmove(effect, null, anchor);\n\t\t\t} else {\n\t\t\t\tvar next = prev ? prev.next : current;\n\n\t\t\t\tif (effect === state.effect.last) {\n\t\t\t\t\tstate.effect.last = effect.prev;\n\t\t\t\t}\n\n\t\t\t\tif (effect.prev) effect.prev.next = effect.next;\n\t\t\t\tif (effect.next) effect.next.prev = effect.prev;\n\t\t\t\tlink(state, prev, effect);\n\t\t\t\tlink(state, effect, next);\n\n\t\t\t\tmove(effect, next, anchor);\n\t\t\t\tprev = effect;\n\n\t\t\t\tmatched = [];\n\t\t\t\tstashed = [];\n\n\t\t\t\tcurrent = skip_to_branch(prev.next);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\n\t\tif ((effect.f & INERT) !== 0) {\n\t\t\tresume_effect(effect);\n\t\t\tif (is_animated) {\n\t\t\t\teffect.nodes?.a?.unfix();\n\t\t\t\t(to_animate ??= new Set()).delete(effect);\n\t\t\t}\n\t\t}\n\n\t\tif (effect !== current) {\n\t\t\tif (seen !== undefined && seen.has(effect)) {\n\t\t\t\tif (matched.length < stashed.length) {\n\t\t\t\t\t// more efficient to move later items to the front\n\t\t\t\t\tvar start = stashed[0];\n\t\t\t\t\tvar j;\n\n\t\t\t\t\tprev = start.prev;\n\n\t\t\t\t\tvar a = matched[0];\n\t\t\t\t\tvar b = matched[matched.length - 1];\n\n\t\t\t\t\tfor (j = 0; j < matched.length; j += 1) {\n\t\t\t\t\t\tmove(matched[j], start, anchor);\n\t\t\t\t\t}\n\n\t\t\t\t\tfor (j = 0; j < stashed.length; j += 1) {\n\t\t\t\t\t\tseen.delete(stashed[j]);\n\t\t\t\t\t}\n\n\t\t\t\t\tlink(state, a.prev, b.next);\n\t\t\t\t\tlink(state, prev, a);\n\t\t\t\t\tlink(state, b, start);\n\n\t\t\t\t\tcurrent = start;\n\t\t\t\t\tprev = b;\n\t\t\t\t\ti -= 1;\n\n\t\t\t\t\tmatched = [];\n\t\t\t\t\tstashed = [];\n\t\t\t\t} else {\n\t\t\t\t\t// more efficient to move earlier items to the back\n\t\t\t\t\tseen.delete(effect);\n\t\t\t\t\tmove(effect, current, anchor);\n\n\t\t\t\t\tlink(state, effect.prev, effect.next);\n\t\t\t\t\tlink(state, effect, prev === null ? state.effect.first : prev.next);\n\t\t\t\t\tlink(state, prev, effect);\n\n\t\t\t\t\tprev = effect;\n\t\t\t\t}\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tmatched = [];\n\t\t\tstashed = [];\n\n\t\t\twhile (current !== null && current !== effect) {\n\t\t\t\t(seen ??= new Set()).add(current);\n\t\t\t\tstashed.push(current);\n\t\t\t\tcurrent = skip_to_branch(current.next);\n\t\t\t}\n\n\t\t\tif (current === null) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\n\t\tif ((effect.f & EFFECT_OFFSCREEN) === 0) {\n\t\t\tmatched.push(effect);\n\t\t}\n\n\t\tprev = effect;\n\t\tcurrent = skip_to_branch(effect.next);\n\t}\n\n\tif (state.outrogroups !== null) {\n\t\tfor (const group of state.outrogroups) {\n\t\t\tif (group.pending.size === 0) {\n\t\t\t\tdestroy_effects(state, array_from(group.done));\n\t\t\t\tstate.outrogroups?.delete(group);\n\t\t\t}\n\t\t}\n\n\t\tif (state.outrogroups.size === 0) {\n\t\t\tstate.outrogroups = null;\n\t\t}\n\t}\n\n\tif (current !== null || seen !== undefined) {\n\t\t/** @type {Effect[]} */\n\t\tvar to_destroy = [];\n\n\t\tif (seen !== undefined) {\n\t\t\tfor (effect of seen) {\n\t\t\t\tif ((effect.f & INERT) === 0) {\n\t\t\t\t\tto_destroy.push(effect);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\twhile (current !== null) {\n\t\t\t// If the each block isn't inert, then inert effects are currently outroing and will be removed once the transition is finished\n\t\t\tif ((current.f & INERT) === 0 && current !== state.fallback) {\n\t\t\t\tto_destroy.push(current);\n\t\t\t}\n\n\t\t\tcurrent = skip_to_branch(current.next);\n\t\t}\n\n\t\tvar destroy_length = to_destroy.length;\n\n\t\tif (destroy_length > 0) {\n\t\t\tvar controlled_anchor = (flags & EACH_IS_CONTROLLED) !== 0 && length === 0 ? anchor : null;\n\n\t\t\tif (is_animated) {\n\t\t\t\tfor (i = 0; i < destroy_length; i += 1) {\n\t\t\t\t\tto_destroy[i].nodes?.a?.measure();\n\t\t\t\t}\n\n\t\t\t\tfor (i = 0; i < destroy_length; i += 1) {\n\t\t\t\t\tto_destroy[i].nodes?.a?.fix();\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tpause_effects(state, to_destroy, controlled_anchor);\n\t\t}\n\t}\n\n\tif (is_animated) {\n\t\tqueue_micro_task(() => {\n\t\t\tif (to_animate === undefined) return;\n\t\t\tfor (effect of to_animate) {\n\t\t\t\teffect.nodes?.a?.apply();\n\t\t\t}\n\t\t});\n\t}\n}\n\n/**\n * @template V\n * @param {Map} items\n * @param {Node} anchor\n * @param {V} value\n * @param {unknown} key\n * @param {number} index\n * @param {(anchor: Node, item: V | Source, index: number | Value, collection: () => V[]) => void} render_fn\n * @param {number} flags\n * @param {() => V[]} get_collection\n * @returns {EachItem}\n */\nfunction create_item(items, anchor, value, key, index, render_fn, flags, get_collection) {\n\tvar v =\n\t\t(flags & EACH_ITEM_REACTIVE) !== 0\n\t\t\t? (flags & EACH_ITEM_IMMUTABLE) === 0\n\t\t\t\t? mutable_source(value, false, false)\n\t\t\t\t: source(value)\n\t\t\t: null;\n\n\tvar i = (flags & EACH_INDEX_REACTIVE) !== 0 ? source(index) : null;\n\n\tif (DEV && v) {\n\t\t// For tracing purposes, we need to link the source signal we create with the\n\t\t// collection + index so that tracing works as intended\n\t\tv.trace = () => {\n\t\t\t// eslint-disable-next-line @typescript-eslint/no-unused-expressions\n\t\t\tget_collection()[i?.v ?? index];\n\t\t};\n\t}\n\n\treturn {\n\t\tv,\n\t\ti,\n\t\te: branch(() => {\n\t\t\trender_fn(anchor, v ?? value, i ?? index, get_collection);\n\n\t\t\treturn () => {\n\t\t\t\titems.delete(key);\n\t\t\t};\n\t\t})\n\t};\n}\n\n/**\n * @param {Effect} effect\n * @param {Effect | null} next\n * @param {Text | Element | Comment} anchor\n */\nfunction move(effect, next, anchor) {\n\tif (!effect.nodes) return;\n\n\tvar node = effect.nodes.start;\n\tvar end = effect.nodes.end;\n\n\tvar dest =\n\t\tnext && (next.f & EFFECT_OFFSCREEN) === 0\n\t\t\t? /** @type {EffectNodes} */ (next.nodes).start\n\t\t\t: anchor;\n\n\twhile (node !== null) {\n\t\tvar next_node = /** @type {TemplateNode} */ (get_next_sibling(node));\n\t\tdest.before(node);\n\n\t\tif (node === end) {\n\t\t\treturn;\n\t\t}\n\n\t\tnode = next_node;\n\t}\n}\n\n/**\n * @param {EachState} state\n * @param {Effect | null} prev\n * @param {Effect | null} next\n */\nfunction link(state, prev, next) {\n\tif (prev === null) {\n\t\tstate.effect.first = next;\n\t} else {\n\t\tprev.next = next;\n\t}\n\n\tif (next === null) {\n\t\tstate.effect.last = prev;\n\t} else {\n\t\tnext.prev = prev;\n\t}\n}\n\n/**\n * @param {Array} array\n * @param {(item: any, index: number) => string} key_fn\n * @returns {void}\n */\nfunction validate_each_keys(array, key_fn) {\n\tconst keys = new Map();\n\tconst length = array.length;\n\n\tfor (let i = 0; i < length; i++) {\n\t\tconst key = key_fn(array[i], i);\n\n\t\tif (keys.has(key)) {\n\t\t\tconst a = String(keys.get(key));\n\t\t\tconst b = String(i);\n\n\t\t\t/** @type {string | null} */\n\t\t\tlet k = String(key);\n\t\t\tif (k.startsWith('[object ')) k = null;\n\n\t\t\te.each_key_duplicate(a, b, k);\n\t\t}\n\n\t\tkeys.set(key, i);\n\t}\n}\n","/** @import { Effect, TemplateNode } from '#client' */\n/** @import {} from 'trusted-types' */\nimport {\n\tFILENAME,\n\tHYDRATION_ERROR,\n\tNAMESPACE_SVG,\n\tNAMESPACE_MATHML\n} from '../../../../constants.js';\nimport { remove_effect_dom, template_effect } from '../../reactivity/effects.js';\nimport { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from '../hydration.js';\n\nimport { assign_nodes } from '../template.js';\nimport * as w from '../../warnings.js';\nimport { hash, sanitize_location } from '../../../../utils.js';\nimport { DEV } from 'esm-env';\nimport { dev_current_component_function } from '../../context.js';\nimport { create_element, get_first_child, get_next_sibling } from '../operations.js';\nimport { active_effect } from '../../runtime.js';\nimport { COMMENT_NODE } from '#client/constants';\n\n/**\n * @param {Element} element\n * @param {string | null} server_hash\n * @param {string | TrustedHTML} value\n */\nfunction check_hash(element, server_hash, value) {\n\tif (!server_hash || server_hash === hash(String(value ?? ''))) return;\n\n\tlet location;\n\n\t// @ts-expect-error\n\tconst loc = element.__svelte_meta?.loc;\n\tif (loc) {\n\t\tlocation = `near ${loc.file}:${loc.line}:${loc.column}`;\n\t} else if (dev_current_component_function?.[FILENAME]) {\n\t\tlocation = `in ${dev_current_component_function[FILENAME]}`;\n\t}\n\n\tw.hydration_html_changed(sanitize_location(location));\n}\n\n/**\n * @param {Element | Text | Comment} node\n * @param {() => string | TrustedHTML} get_value\n * @param {boolean} [is_controlled]\n * @param {boolean} [svg]\n * @param {boolean} [mathml]\n * @param {boolean} [skip_warning]\n * @returns {void}\n */\nexport function html(\n\tnode,\n\tget_value,\n\tis_controlled = false,\n\tsvg = false,\n\tmathml = false,\n\tskip_warning = false\n) {\n\tvar anchor = node;\n\n\t/** @type {string | TrustedHTML} */\n\tvar value = '';\n\n\tif (is_controlled) {\n\t\tvar parent_node = /** @type {Element} */ (node);\n\n\t\tif (hydrating) {\n\t\t\tanchor = set_hydrate_node(get_first_child(parent_node));\n\t\t}\n\t}\n\n\ttemplate_effect(() => {\n\t\tvar effect = /** @type {Effect} */ (active_effect);\n\n\t\tif (value === (value = get_value() ?? '')) {\n\t\t\tif (hydrating) hydrate_next();\n\t\t\treturn;\n\t\t}\n\n\t\tif (is_controlled && !hydrating) {\n\t\t\t// When @html is the only child, use innerHTML directly.\n\t\t\t// This also handles contenteditable, where the user may delete the anchor comment.\n\t\t\teffect.nodes = null;\n\t\t\tparent_node.innerHTML = /** @type {string} */ (value);\n\n\t\t\tif (value !== '') {\n\t\t\t\tassign_nodes(\n\t\t\t\t\t/** @type {TemplateNode} */ (get_first_child(parent_node)),\n\t\t\t\t\t/** @type {TemplateNode} */ (parent_node.lastChild)\n\t\t\t\t);\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\tif (effect.nodes !== null) {\n\t\t\tremove_effect_dom(effect.nodes.start, /** @type {TemplateNode} */ (effect.nodes.end));\n\t\t\teffect.nodes = null;\n\t\t}\n\n\t\tif (value === '') return;\n\n\t\tif (hydrating) {\n\t\t\t// We're deliberately not trying to repair mismatches between server and client,\n\t\t\t// as it's costly and error-prone (and it's an edge case to have a mismatch anyway)\n\t\t\tvar hash = /** @type {Comment} */ (hydrate_node).data;\n\n\t\t\t/** @type {TemplateNode | null} */\n\t\t\tvar next = hydrate_next();\n\t\t\tvar last = next;\n\n\t\t\twhile (\n\t\t\t\tnext !== null &&\n\t\t\t\t(next.nodeType !== COMMENT_NODE || /** @type {Comment} */ (next).data !== '')\n\t\t\t) {\n\t\t\t\tlast = next;\n\t\t\t\tnext = get_next_sibling(next);\n\t\t\t}\n\n\t\t\tif (next === null) {\n\t\t\t\tw.hydration_mismatch();\n\t\t\t\tthrow HYDRATION_ERROR;\n\t\t\t}\n\n\t\t\tif (DEV && !skip_warning) {\n\t\t\t\tcheck_hash(/** @type {Element} */ (next.parentNode), hash, value);\n\t\t\t}\n\n\t\t\tassign_nodes(hydrate_node, last);\n\t\t\tanchor = set_hydrate_node(next);\n\t\t\treturn;\n\t\t}\n\n\t\t// Don't use create_fragment_with_script_from_html here because that would mean script tags are executed.\n\t\t// @html is basically `.innerHTML = ...` and that doesn't execute scripts either due to security reasons.\n\t\t// Use a